Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Deal.Telegram.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Кэш с ограниченной ёмкостью и вытеснением least-recently-used (LRU): при переполнении удаляется
|
||||
/// элемент, к которому дольше всего не обращались. Нужен там, где раньше жил неограниченный
|
||||
/// <see cref="Dictionary{TKey,TValue}"/> и память росла с числом сущностей (долгоживущий процесс сервиса).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// НЕ потокобезопасен: вызывающий обязан сериализовать доступ (в telegram-service кэши
|
||||
/// WTelegramSessionClient защищены общим <c>_entityCacheGate</c>). Вытеснение — забота производительности,
|
||||
/// а не корректности: потерянное значение всегда можно получить повторным запросом к Telegram.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TKey">Тип ключа (ссылочный или значимый).</typeparam>
|
||||
/// <typeparam name="TValue">Тип значения.</typeparam>
|
||||
public sealed class LruCache<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
private readonly Dictionary<TKey, LinkedListNode<Entry>> _map;
|
||||
private readonly LinkedList<Entry> _recency = new();
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт кэш заданной ёмкости.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Максимальное число элементов (должно быть больше нуля).</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Ёмкость не положительна.</exception>
|
||||
public LruCache(int capacity)
|
||||
{
|
||||
if (capacity <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "Ёмкость LRU-кэша должна быть положительной");
|
||||
}
|
||||
|
||||
Capacity = capacity;
|
||||
_map = new Dictionary<TKey, LinkedListNode<Entry>>(capacity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Максимальное число элементов кэша.
|
||||
/// </summary>
|
||||
public int Capacity { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Текущее число элементов кэша.
|
||||
/// </summary>
|
||||
public int Count => _map.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Пробует получить значение по ключу, отмечая его как недавно использованный.
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ.</param>
|
||||
/// <param name="value">Найденное значение (иначе значение по умолчанию).</param>
|
||||
/// <returns>True — ключ найден.</returns>
|
||||
public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value)
|
||||
{
|
||||
if (!_map.TryGetValue(key, out LinkedListNode<Entry>? node))
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
Touch(node);
|
||||
value = node.Value.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет наличие ключа, не меняя недавность (лёгкая проверка без вытеснения).
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ.</param>
|
||||
/// <returns>True — ключ есть в кэше.</returns>
|
||||
public bool ContainsKey(TKey key) => _map.ContainsKey(key);
|
||||
|
||||
/// <summary>
|
||||
/// Записывает значение по ключу (вставка или обновление) и вытесняет самый давний элемент при
|
||||
/// переполнении. Обновление существующего ключа делает его самым недавним.
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ.</param>
|
||||
/// <param name="value">Значение.</param>
|
||||
public void Set(TKey key, TValue value)
|
||||
{
|
||||
if (_map.TryGetValue(key, out LinkedListNode<Entry>? existing))
|
||||
{
|
||||
existing.Value = new Entry(key, value);
|
||||
Touch(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
var node = new LinkedListNode<Entry>(new Entry(key, value));
|
||||
_recency.AddFirst(node);
|
||||
_map[key] = node;
|
||||
|
||||
if (_map.Count > Capacity)
|
||||
{
|
||||
LinkedListNode<Entry> oldest = _recency.Last!;
|
||||
_recency.RemoveLast();
|
||||
_map.Remove(oldest.Value.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Перемещает узел в начало списка недавности (последний использованный).
|
||||
// node: Узел кэша.
|
||||
private void Touch(LinkedListNode<Entry> node)
|
||||
{
|
||||
if (!ReferenceEquals(node, _recency.First))
|
||||
{
|
||||
_recency.Remove(node);
|
||||
_recency.AddFirst(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Элемент кэша (ключ хранится для удаления при вытеснении из списка недавности).
|
||||
// Key: Ключ.
|
||||
// Value: Значение.
|
||||
private readonly record struct Entry(TKey Key, TValue Value);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Deal.Grpc.Hosting;
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.Telegram.Sessions;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
|
||||
namespace Deal.Telegram.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Исходящий gRPC-канал в ядро: клиент Deal.Grpc.Telegram.IngressService (план Task 10, Core/
|
||||
/// CoreIngressClient.cs; Ruling 1/7). Каждый RPC несёт metadata tenant-id + service-token (Ruling 1);
|
||||
/// принадлежность сообщений тенанту — только по metadata (ядро не доверяет полю). Сбой связи →
|
||||
/// <see cref="Sessions.SessionException"/> UNAVAILABLE «Ядро недоступно…» с логом: буфер недоставленных
|
||||
/// сообщений не ведётся — неподтверждённые сообщения не помечаются прочитанными и догоняются
|
||||
/// realtime_sweep (упущенное после рестарта/разрыва, Ruling 7/план Task 10).
|
||||
/// </summary>
|
||||
public sealed class CoreIngressClient : ICoreIngressClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с id тенанта (зеркало интерцепторов, Ruling 1).
|
||||
/// </summary>
|
||||
public const string TenantIdMetadataKey = "tenant-id";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с service-token (зеркало интерцепторов, Ruling 1).
|
||||
/// </summary>
|
||||
public const string ServiceTokenMetadataKey = "service-token";
|
||||
|
||||
private readonly CoreIngressOptions _options;
|
||||
private readonly ILogger<CoreIngressClient> _logger;
|
||||
private readonly MtlsCertificates? _mtlsCertificates;
|
||||
private readonly object _channelGate = new();
|
||||
private IngressService.IngressServiceClient? _client;
|
||||
private GrpcChannel? _channel;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт клиент ингресса ядра.
|
||||
/// </summary>
|
||||
/// <param name="options">Конфигурация (адрес из env, service-token).</param>
|
||||
/// <param name="logger">Логгер.</param>
|
||||
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал (dev).</param>
|
||||
public CoreIngressClient(CoreIngressOptions options, ILogger<CoreIngressClient> logger, MtlsCertificates? mtlsCertificates = null)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_mtlsCertificates = mtlsCertificates;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PushMessageReply> PushMessageAsync(string tenantId, PushMessageRequest message, CancellationToken cancellationToken)
|
||||
{
|
||||
IngressService.IngressServiceClient client = GetClient();
|
||||
try
|
||||
{
|
||||
return await client.PushMessageAsync(message, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
throw Fail("PushMessage", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> SyncDialogsAsync(string tenantId, IReadOnlyList<DialogEntry> entries, CancellationToken cancellationToken)
|
||||
{
|
||||
IngressService.IngressServiceClient client = GetClient();
|
||||
var request = new SyncDialogsRequest();
|
||||
request.Entries.AddRange(entries);
|
||||
try
|
||||
{
|
||||
SyncDialogsReply reply = await client.SyncDialogsAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return reply.MonitoredIds;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
throw Fail("SyncDialogs", exception);
|
||||
}
|
||||
}
|
||||
|
||||
// Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса).
|
||||
private IngressService.IngressServiceClient GetClient()
|
||||
{
|
||||
lock (_channelGate)
|
||||
{
|
||||
if (_client is null)
|
||||
{
|
||||
// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским
|
||||
// сертификатом и проверяет CA ядра; dev — plaintext-канал (Ruling 2 этапа 6).
|
||||
if (_mtlsCertificates is not null)
|
||||
{
|
||||
_channel = GrpcChannel.ForAddress(
|
||||
_options.IngressEndpoint,
|
||||
new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() });
|
||||
}
|
||||
else
|
||||
{
|
||||
_channel = GrpcChannel.ForAddress(_options.IngressEndpoint);
|
||||
}
|
||||
|
||||
_client = new IngressService.IngressServiceClient(_channel);
|
||||
}
|
||||
|
||||
return _client;
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata вызова (tenant-id + service-token) и deadline (Ruling 1).
|
||||
// tenantId: Id тенанта.
|
||||
private CallOptions CallOptions(string tenantId)
|
||||
{
|
||||
var metadata = new Metadata
|
||||
{
|
||||
{ TenantIdMetadataKey, tenantId },
|
||||
{ ServiceTokenMetadataKey, _options.ServiceToken },
|
||||
};
|
||||
return new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(CoreIngressOptions.RpcTimeoutSeconds));
|
||||
}
|
||||
|
||||
// Переводит сбой вызова в SessionException (UNAVAILABLE) со структурированным логом.
|
||||
// action: Действие (PushMessage/SyncDialogs) для лога аудита (Ruling 13).
|
||||
// exception: Исключение вызова.
|
||||
private SessionException Fail(string action, Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Аудит: ингресс ядра {Action} → сбой ({Endpoint})", action, _options.IngressEndpoint);
|
||||
return new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable, exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace Deal.Telegram.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Конфигурация исходящего канала в ядро (план Task 10; Ruling 2/12/13: только env).
|
||||
///
|
||||
/// Адрес gRPC-ингресса ядра — env `SERVICES__CORE__INGRESS` (Ruling 12: в dev/compose
|
||||
/// http://host.docker.internal:5082; на хосте — http://localhost:5082); service-token — тот же общий
|
||||
/// env `DEAL_SERVICE_TOKEN`, что проверяет интерцептор ядра (Ruling 1: каждый RPC несёт tenant-id и
|
||||
/// service-token). Ключи/токены только env — не читаются из appsettings (Ruling 13).
|
||||
/// </summary>
|
||||
public sealed class CoreIngressOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ адреса gRPC-ингресса ядра (Ruling 12).
|
||||
/// </summary>
|
||||
public const string IngressEndpointEnvVarName = "SERVICES__CORE__INGRESS";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ конфигурации адреса ингресса (env `__` → `:` провайдером env).
|
||||
/// </summary>
|
||||
public const string IngressEndpointConfigKey = "Services:Core:Ingress";
|
||||
|
||||
/// <summary>
|
||||
/// Env-ключ service-token (общий токен сервисов, Ruling 12).
|
||||
/// </summary>
|
||||
public const string ServiceTokenEnvVarName = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// Адрес ингресса ядра по умолчанию (core dev на хосте, Ruling 12).
|
||||
/// </summary>
|
||||
public const string DefaultIngressEndpoint = "http://localhost:5082";
|
||||
|
||||
/// <summary>
|
||||
/// Таймаут одного RPC в ядро (сек).
|
||||
/// </summary>
|
||||
public const int RpcTimeoutSeconds = 15;
|
||||
|
||||
private CoreIngressOptions(string ingressEndpoint, string serviceToken)
|
||||
{
|
||||
IngressEndpoint = ingressEndpoint;
|
||||
ServiceToken = serviceToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Адрес gRPC-ингресса ядра (например, http://localhost:5082).
|
||||
/// </summary>
|
||||
public string IngressEndpoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Service-token для metadata каждого RPC (может быть пустым — ядро откажет).
|
||||
/// </summary>
|
||||
public string ServiceToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт опции с явным адресом и токеном (unit-тесты канала).
|
||||
/// </summary>
|
||||
/// <param name="ingressEndpoint">Адрес ингресса ядра.</param>
|
||||
/// <param name="serviceToken">Service-token (пустой — вызовы будут отвергнуты ядром).</param>
|
||||
public static CoreIngressOptions Create(string ingressEndpoint, string serviceToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ingressEndpoint))
|
||||
{
|
||||
throw new ArgumentException("Адрес ингресса ядра не задан.", nameof(ingressEndpoint));
|
||||
}
|
||||
|
||||
return new CoreIngressOptions(ingressEndpoint, serviceToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Читает конфигурацию из env/конфигурации хоста. Адрес не задан — значение по умолчанию
|
||||
/// <see cref="DefaultIngressEndpoint"/> (localhost-ядро dev); токен — как в env (может быть пуст).
|
||||
/// </summary>
|
||||
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
|
||||
/// <returns>Опции исходящего канала в ядро.</returns>
|
||||
public static CoreIngressOptions FromConfiguration(IConfiguration configuration)
|
||||
{
|
||||
string? endpoint = configuration[IngressEndpointConfigKey];
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
endpoint = DefaultIngressEndpoint;
|
||||
}
|
||||
|
||||
string token = configuration[ServiceTokenEnvVarName] ?? string.Empty;
|
||||
return new CoreIngressOptions(endpoint.Trim(), token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Deal.Grpc.Telegram;
|
||||
|
||||
namespace Deal.Telegram.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Исходящий канал в ядро (IngressService telegram.proto; план Task 10 Core/CoreIngressClient.cs, Ruling 7).
|
||||
///
|
||||
/// telegram-service — клиент Ingress ядра (:5082, `SERVICES__CORE__INGRESS`): сырые сообщения
|
||||
/// мониторящихся диалогов (PushMessage), синхронизация каталога с ответом monitored-набора
|
||||
/// (SyncDialogs). Интерфейс — seam: реальная реализация ходит по gRPC, тесты подставляют фейк/в-proc
|
||||
/// сервер ингресса (план: «PushMessage-клиент к in-proc fake-серверу ингресса»).
|
||||
/// </summary>
|
||||
public interface ICoreIngressClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Отправляет сообщение диалога в ядро (Ingress.PushMessage → очередь пайплайна, Ruling 7).
|
||||
/// Сбой связи — <see cref="Sessions.SessionException"/> (UNAVAILABLE «Ядро недоступно…»);
|
||||
/// недоставленное сообщение не помечается прочитанным и догоняется realtime_sweep.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (metadata tenant-id, Ruling 1).</param>
|
||||
/// <param name="message">Сообщение в контракте PushMessageRequest.</param>
|
||||
/// <param name="cancellationToken">Отмена вызова.</param>
|
||||
/// <returns>Ответ ядра (accepted/duplicate — дубль dialog+msgId в очереди не растёт).</returns>
|
||||
public Task<PushMessageReply> PushMessageAsync(string tenantId, PushMessageRequest message, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Синхронизирует каталог с ядром (Ingress.SyncDialogs → SyncFromTelegram, Ruling 7). Ядро применяет
|
||||
/// entries (авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и отвечает
|
||||
/// актуальным списком monitored id — зеркало сервиса обновляется ответом.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (metadata tenant-id, Ruling 1).</param>
|
||||
/// <param name="entries">Актуальный каталог диалогов (как refresh_dialogs).</param>
|
||||
/// <param name="cancellationToken">Отмена вызова.</param>
|
||||
/// <returns>Список id диалогов с включённым мониторингом (по версии ядра).</returns>
|
||||
public Task<IReadOnlyList<string>> SyncDialogsAsync(string tenantId, IReadOnlyList<DialogEntry> entries, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<!--
|
||||
Deal.Telegram — gRPC-хост telegram-service (план Task 2; Ruling 1/2/12).
|
||||
|
||||
Кодогенерация .proto — в общем проекте src/contracts/Deal.Proto.csproj (Task 1, Ruling 1):
|
||||
сервис подключает его ProjectReference и использует сгенерированную серверную базу
|
||||
Deal.Grpc.Telegram.TelegramServiceBase (решение по способу подключения — T2, см. Note
|
||||
task-1-report). Клиентская сторона telegram.proto (в т.ч. IngressService — исходящие в ядро,
|
||||
Ruling 7) сгенерирована в Deal.Proto (GrpcServices="Both") и понадобится задачам 9–12.
|
||||
|
||||
Сборка: 0 warnings / 0 errors (TreatWarningsAsErrors, Directory.Build.props каталога сервиса).
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Deal.Telegram</AssemblyName>
|
||||
<RootNamespace>Deal.Telegram</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Структурированные логи Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл
|
||||
data/logs/deal-telegram-*.json; конфигурация — Deal.Telegram/DealLogging.cs (Program.cs).
|
||||
Пакет тянет консоль/файл/compact-формат транзитивно. -->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- gRPC-сервер ASP.NET Core (Kestrel HTTP/2) + стандартный gRPC-health (Ruling 12). -->
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
|
||||
<PackageReference Include="Grpc.AspNetCore.HealthChecks" Version="2.83.0" />
|
||||
<PackageReference Include="WTelegramClient" Version="4.4.8" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Общая серверная обвязка gRPC-хостов (C31): интерцепторы/mTLS/DealLogging/GrpcServer —
|
||||
единый источник вместо копий в трёх сервисах. -->
|
||||
<ProjectReference Include="..\..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\contracts\Deal.Proto.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.Telegram.Core;
|
||||
using Deal.Telegram.Sessions;
|
||||
using Deal.Telegram.Telegram;
|
||||
|
||||
namespace Deal.Telegram.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Backfill диалога: перечитывание последних ~10 сообщений и отправка их в ядро потоком PushMessage
|
||||
/// (план Task 10, Dialogs/BackfillService.cs; прототип backfill_dialog/_backfill_dialogs L331–390).
|
||||
///
|
||||
/// 1:1 с прототипом: сообщения читаются от старых к новым (reversed — как реальный поток), между
|
||||
/// отправками — анти-бан-пауза 1.5–3 с/сообщение (Ruling 3, BACKFILL_PER_MESSAGE L35); между
|
||||
/// диалогами одного тенанта — пауза 3–6 с (BACKFILL_PER_DIALOG L36, python sleep между диалогами
|
||||
/// L347). В конце — read-ack (снять «новое» в Telegram). Ошибка середины потока прерывает backfill
|
||||
/// без read-ack: сообщения, не дошедшие до ядра, останутся непрочитанными и будут догнаны
|
||||
/// realtime_sweep; дубли уже доставленных в ядро не растут (дубль-гвард dialog+msgId, Ruling 7).
|
||||
/// Параметр force («Перечитать» по кнопке) прототип использует против своего флага backfilled;
|
||||
/// в разделении флаг живёт в БД ядра, поэтому RPC исполняется всегда — дубли гасит ядро.
|
||||
/// </summary>
|
||||
public sealed class BackfillService
|
||||
{
|
||||
/// <summary>
|
||||
/// Сколько последних сообщений читает backfill (прототип: limit=10, L371).
|
||||
/// </summary>
|
||||
public const int MessagesLimit = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Нижняя граница анти-бан-паузы между сообщениями (BACKFILL_PER_MESSAGE, L35).
|
||||
/// </summary>
|
||||
public const double MinPerMessageDelaySeconds = 1.5;
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница анти-бан-паузы между сообщениями (BACKFILL_PER_MESSAGE, L35).
|
||||
/// </summary>
|
||||
public const double MaxPerMessageDelaySeconds = 3.0;
|
||||
|
||||
/// <summary>
|
||||
/// Нижняя граница паузы между диалогами одного тенанта (BACKFILL_PER_DIALOG, L36).
|
||||
/// </summary>
|
||||
public const double MinPerDialogDelaySeconds = 3.0;
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница паузы между диалогами одного тенанта (BACKFILL_PER_DIALOG, L36).
|
||||
/// </summary>
|
||||
public const double MaxPerDialogDelaySeconds = 6.0;
|
||||
|
||||
private readonly SessionFarm _sessionFarm;
|
||||
private readonly ICoreIngressClient _ingress;
|
||||
private readonly IBackfillPacer _pacer;
|
||||
private readonly ILogger<BackfillService> _logger;
|
||||
|
||||
// Гард повторного входа: (тенант, диалог) уже перечитывается (python `_backfilling` L99).
|
||||
private readonly ConcurrentDictionary<(string TenantId, string DialogId), byte> _running = new();
|
||||
|
||||
// Сериализация backfill'ов одного тенанта (python: один asyncio-loop на все диалоги).
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _tenantGates = new(StringComparer.Ordinal);
|
||||
|
||||
// Момент завершения последнего backfill тенанта (пауза 3–6 с между диалогами).
|
||||
private readonly Dictionary<string, DateTime> _tenantLastFinishUtc = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly object _lastFinishGate = new();
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт службу backfill'а.
|
||||
/// </summary>
|
||||
/// <param name="sessionFarm">Пул сессий тенантов (read-операции диалогов).</param>
|
||||
/// <param name="ingress">Канал в ядро (PushMessage).</param>
|
||||
/// <param name="pacer">Анти-бан-паузы (реальный — случайные, тесты — фейк).</param>
|
||||
/// <param name="logger">Логгер.</param>
|
||||
public BackfillService(
|
||||
SessionFarm sessionFarm,
|
||||
ICoreIngressClient ingress,
|
||||
IBackfillPacer pacer,
|
||||
ILogger<BackfillService> logger)
|
||||
{
|
||||
_sessionFarm = sessionFarm;
|
||||
_ingress = ingress;
|
||||
_pacer = pacer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Перечитывает последние сообщения диалога в ядро (Backfill RPC; кнопка «Перечитать»).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="dialogId">Подписанный id диалога.</param>
|
||||
/// <param name="force">True — «Перечитать» по кнопке (семантика флага — в ядре, см. класс).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Сколько сообщений отправлено в ядро (0 — диалог уже перечитывается/нет текстов).</returns>
|
||||
public async Task<int> ExecuteAsync(string tenantId, string dialogId, bool force, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = (tenantId, dialogId);
|
||||
if (!_running.TryAdd(key, 0))
|
||||
{
|
||||
// Прототип L355–356: повторный вход в уже перечитываемый диалог → 0.
|
||||
_logger.LogInformation("Аудит: backfill {TenantId} {DialogId} → пропущен (уже перечитывается)", tenantId, dialogId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SemaphoreSlim gate = _tenantGates.GetOrAdd(tenantId, static _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await EnsureDialogSpacingAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
int processed = await BackfillDialogAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false);
|
||||
lock (_lastFinishGate)
|
||||
{
|
||||
_tenantLastFinishUtc[tenantId] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Аудит: backfill {TenantId} {DialogId} → {Processed} сообщений в ядро (force {Force})",
|
||||
tenantId,
|
||||
dialogId,
|
||||
processed,
|
||||
force);
|
||||
return processed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// Пауза 3–6 с между backfill'ами разных диалогов одного тенанта (python L347).
|
||||
// tenantId: Id тенанта.
|
||||
// cancellationToken: Отмена операции.
|
||||
private async Task EnsureDialogSpacingAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime? lastFinishUtc;
|
||||
lock (_lastFinishGate)
|
||||
{
|
||||
lastFinishUtc = _tenantLastFinishUtc.TryGetValue(tenantId, out DateTime value) ? value : null;
|
||||
}
|
||||
|
||||
if (lastFinishUtc is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double elapsedSeconds = (DateTime.UtcNow - lastFinishUtc.Value).TotalSeconds;
|
||||
if (elapsedSeconds >= MaxPerDialogDelaySeconds)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double minSeconds = Math.Max(0, MinPerDialogDelaySeconds - elapsedSeconds);
|
||||
double maxSeconds = MaxPerDialogDelaySeconds - elapsedSeconds;
|
||||
if (maxSeconds < minSeconds)
|
||||
{
|
||||
maxSeconds = minSeconds;
|
||||
}
|
||||
|
||||
await _pacer.WaitAsync(minSeconds, maxSeconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Читает и отправляет сообщения одного диалога (паузы между сообщениями, read-ack).
|
||||
// tenantId: Id тенанта.
|
||||
// dialogId: Подписанный id диалога.
|
||||
// cancellationToken: Отмена операции.
|
||||
// Возвращает: Сколько сообщений отправлено в ядро.
|
||||
private async Task<int> BackfillDialogAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<TelegramMessage> messages = await _sessionFarm
|
||||
.GetMessagesAsync(tenantId, dialogId, MessagesLimit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
int processed = 0;
|
||||
foreach (TelegramMessage message in messages.Reverse())
|
||||
{
|
||||
// От старых к новым — как реальный поток (прототип L372: `for m in reversed(msgs)`).
|
||||
PushMessageReply reply = await _ingress
|
||||
.PushMessageAsync(tenantId, DialogProtoMapper.ToPushRequest(message), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (reply.Accepted && !reply.Duplicate)
|
||||
{
|
||||
processed++;
|
||||
}
|
||||
|
||||
// Анти-бан между сообщениями (прототип L381; Ruling 3).
|
||||
await _pacer.WaitAsync(MinPerMessageDelaySeconds, MaxPerMessageDelaySeconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// «Перечитали» — снимаем «новое» в Telegram (прототип L383–386). При ошибке выше read-ack
|
||||
// не выполняется: неотправленное останется непрочитанным и догонится realtime_sweep.
|
||||
await _sessionFarm.MarkReadAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false);
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Deal.Telegram.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Зеркало каталога диалогов тенанта в памяти telegram-service (план Task 10, Dialogs/DialogCatalog.cs;
|
||||
/// Ruling 7: «сервис держит зеркало мониторинга в памяти», ядро — владелец списка мониторинга в своей БД).
|
||||
///
|
||||
/// Тенант хранит два набора id диалогов: полный каталог (из refresh_dialogs/realtime_sweep, L468–519)
|
||||
/// и подмножество с включённым мониторингом. Мониторинг актуализируется тремя путями:
|
||||
/// * командой SetMonitor/SetMonitorAll ядра (обновление одной записи / всех сразу);
|
||||
/// * ответом Ingress.SyncDialogs (ядро применило entries с autoMonitorNew — L244–246 «_reload_monitored»);
|
||||
/// * очисткой после Logout/отключения аккаунта (прототип L203: `_monitored.clear()`).
|
||||
/// Все операции потокобезопасны; зеркало чисто в памяти — потерю при рестарте догоняет realtime_sweep.
|
||||
/// </summary>
|
||||
public sealed class DialogCatalog
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, HashSet<string>> _knownByTenant = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, HashSet<string>> _monitoredByTenant = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Обновляет полный каталог диалогов тенанта (entries refresh/realtime_sweep). Мониторинг при этом
|
||||
/// не трогается — актуальный monitored-набор приходит ответом SyncDialogs (или командами SetMonitor).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="dialogIds">Актуальные id каталога (entries списка диалогов).</param>
|
||||
public void ReplaceKnown(string tenantId, IEnumerable<string> dialogIds)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_knownByTenant[tenantId] = new HashSet<string>(dialogIds, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Включает/выключает мониторинг одного диалога (SetMonitor RPC, set_monitor L536–546). Команда
|
||||
/// приходит от ядра после обновления его БД; зеркало повторяет решение без собственной проверки
|
||||
/// «известности» — запись может появиться раньше каталога (включение после рестарта сервиса).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="dialogId">Id диалога.</param>
|
||||
/// <param name="enabled">Мониторить (true) или выключить (false).</param>
|
||||
public void SetMonitored(string tenantId, string dialogId, bool enabled)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dialogId);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
HashSet<string> monitored = MonitoredSet(tenantId);
|
||||
if (enabled)
|
||||
{
|
||||
monitored.Add(dialogId);
|
||||
}
|
||||
else
|
||||
{
|
||||
monitored.Remove(dialogId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Заменяет monitored-набор ответом SyncDialogs (актуальный список ядра после SyncFromTelegram —
|
||||
/// авто-мониторинг новых/удаление отсутствующих, Ruling 7). Это авторитетный источник зеркала.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="monitoredIds">Id диалогов с включённым мониторингом.</param>
|
||||
public void ReplaceMonitored(string tenantId, IEnumerable<string> monitoredIds)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_monitoredByTenant[tenantId] = new HashSet<string>(monitoredIds, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Включает/выключает мониторинг всех диалогов каталога (SetMonitorAll RPC, set_monitor_all
|
||||
/// L548–567). При включении учитываются и уже мониторящиеся записи (каталог может отставать от БД
|
||||
/// ядра после рестарта — «монитор» из ответа SyncDialogs приедет следующим циклом realtime_sweep).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="enabled">Мониторить все (true) или снять мониторинг со всех (false).</param>
|
||||
/// <returns>Сколько диалогов в каталоге тенанта (SetMonitorAllReply.count).</returns>
|
||||
public int SetAllMonitored(string tenantId, bool enabled)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
HashSet<string> known = KnownSet(tenantId);
|
||||
if (enabled)
|
||||
{
|
||||
HashSet<string> monitored = MonitoredSet(tenantId);
|
||||
monitored.UnionWith(known);
|
||||
}
|
||||
else
|
||||
{
|
||||
_monitoredByTenant[tenantId] = new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return known.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, мониторится ли диалог (фильтр realtime-событий L262 и догона sweep L429).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="dialogId">Id диалога.</param>
|
||||
/// <returns>True — диалог в monitored-наборе тенанта.</returns>
|
||||
public bool IsMonitored(string tenantId, string dialogId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(dialogId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
return _monitoredByTenant.TryGetValue(tenantId, out HashSet<string>? monitored) && monitored.Contains(dialogId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сколько диалогов знает каталог тенанта (ответ monitor-all, count каталога).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <returns>Размер каталога; 0 — каталог ещё не синхронизирован (нет refresh/sweep).</returns>
|
||||
public int KnownCount(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
return _knownByTenant.TryGetValue(tenantId, out HashSet<string>? known) ? known.Count : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает копию мониторящихся id тенанта (для логов/тестов).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <returns>Набор мониторящихся id (пустой, если тенанта нет).</returns>
|
||||
public IReadOnlyCollection<string> SnapshotMonitored(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
return _monitoredByTenant.TryGetValue(tenantId, out HashSet<string>? monitored)
|
||||
? new ReadOnlyCollection<string>(monitored.ToArray())
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сбрасывает состояние тенанта (Logout/отключение аккаунта — прототип L203).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
public void Reset(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_knownByTenant.Remove(tenantId);
|
||||
_monitoredByTenant.Remove(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
// Полный каталог тенанта (создаёт пустой при первом обращении). Вызывается под _gate.
|
||||
// tenantId: Id тенанта.
|
||||
private HashSet<string> KnownSet(string tenantId)
|
||||
{
|
||||
if (!_knownByTenant.TryGetValue(tenantId, out HashSet<string>? known))
|
||||
{
|
||||
known = new HashSet<string>(StringComparer.Ordinal);
|
||||
_knownByTenant[tenantId] = known;
|
||||
}
|
||||
|
||||
return known;
|
||||
}
|
||||
|
||||
// Monitored-набор тенанта (создаёт пустой при первом обращении). Вызывается под _gate.
|
||||
// tenantId: Id тенанта.
|
||||
private HashSet<string> MonitoredSet(string tenantId)
|
||||
{
|
||||
if (!_monitoredByTenant.TryGetValue(tenantId, out HashSet<string>? monitored))
|
||||
{
|
||||
monitored = new HashSet<string>(StringComparer.Ordinal);
|
||||
_monitoredByTenant[tenantId] = monitored;
|
||||
}
|
||||
|
||||
return monitored;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Deal.Telegram.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Детерминированный цвет диалога из палитры DIALOG_HUES (1:1 с dialog_hue python-прототипа
|
||||
/// telegram.py L876–880 + backend/app/constants.py DIALOG_HUES; Ruling 7: «hue считает сервис»).
|
||||
/// Палитра и хэш идентичны прототипу, чтобы цвета источников совпадали между реализациями.
|
||||
/// </summary>
|
||||
public static class DialogHue
|
||||
{
|
||||
// Палитра диалоговых цветов (hex) — 1:1 constants.py DIALOG_HUES (8 цветов).
|
||||
private static readonly string[] Palette =
|
||||
[
|
||||
"#3b82f6",
|
||||
"#38bdf8",
|
||||
"#f472b6",
|
||||
"#f59e0b",
|
||||
"#a78bfa",
|
||||
"#34d399",
|
||||
"#fb7185",
|
||||
"#22c55e",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Цвет источника по id диалога и имени: хэш по кодовым точкам (dialog_id + name) по модулю длины
|
||||
/// палитры — 1:1 dialog_hue прототипа (порядок символов и множитель 31 сохранены; EnumerateRunes
|
||||
/// повторяет итерацию по Unicode-кодовым точкам python `for ch in ...`).
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id диалога.</param>
|
||||
/// <param name="name">Отображаемое имя (title/first_name); пустое — как в прототипе.</param>
|
||||
/// <returns>Цвет палитры, hex "#rrggbb".</returns>
|
||||
public static string Compute(string dialogId, string name)
|
||||
{
|
||||
int hash = 0;
|
||||
foreach (Rune rune in (dialogId + name).EnumerateRunes())
|
||||
{
|
||||
hash = (hash * 31 + rune.Value) % Palette.Length;
|
||||
}
|
||||
|
||||
return Palette[hash];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Globalization;
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.Telegram.Telegram;
|
||||
|
||||
namespace Deal.Telegram.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Маппер нейтральных результатов сессии в protobuf-контракт (telegram.proto; план Task 10).
|
||||
///
|
||||
/// DialogEntry уходит в RefreshDialogsReply и SyncDialogsRequest (hue считает сервис по палитре —
|
||||
/// Ruling 7); PushMessageRequest — в Ingress.PushMessage (канальные поля плоские, Ruling 7).
|
||||
/// </summary>
|
||||
public static class DialogProtoMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Нейтральный диалог → DialogEntry контракта (id/name/username/kind/hue).
|
||||
/// </summary>
|
||||
/// <param name="dialog">Диалог из списка сессии.</param>
|
||||
/// <returns>Запись каталога контракта с цветом палитры.</returns>
|
||||
public static DialogEntry ToEntry(TelegramDialog dialog)
|
||||
=> new()
|
||||
{
|
||||
Id = dialog.Id,
|
||||
Name = dialog.Name,
|
||||
Username = dialog.Username,
|
||||
Kind = dialog.Kind,
|
||||
Hue = DialogHue.Compute(dialog.Id, dialog.Name),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Нейтральное сообщение → PreviewMessage превью (id строкой, время epoch-ms).
|
||||
/// </summary>
|
||||
/// <param name="message">Сообщение диалога (непустой текст).</param>
|
||||
/// <returns>Сообщение превью ReadRecentReply (от новых к старым).</returns>
|
||||
public static PreviewMessage ToPreview(TelegramMessage message)
|
||||
=> new()
|
||||
{
|
||||
Id = message.Id.ToString(CultureInfo.InvariantCulture),
|
||||
Text = message.Text,
|
||||
Time = message.DateMs,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Нейтральное сообщение → PushMessageRequest ингресса (1:1 QueuedMessage, Ruling 7).
|
||||
/// </summary>
|
||||
/// <param name="message">Сообщение диалога (непустой текст).</param>
|
||||
/// <returns>Запрос Ingress.PushMessage с канальными полями и дубль-гвардом msg_id.</returns>
|
||||
public static PushMessageRequest ToPushRequest(TelegramMessage message)
|
||||
=> new()
|
||||
{
|
||||
DialogId = message.DialogId,
|
||||
ChannelName = message.DialogName,
|
||||
ChannelHandle = message.DialogHandle,
|
||||
ChannelHue = DialogHue.Compute(message.DialogId, message.DialogName),
|
||||
MsgId = message.Id,
|
||||
Text = message.Text,
|
||||
MsgAt = message.DateMs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Deal.Telegram.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Seam анти-бан-пауз между сетевыми операциями каталога (Ruling 3: «Внутренний анти-бан сервиса
|
||||
/// (паузы между сетевыми операциями одной сессии): backfill 1.5–3 с/сообщение и 3–6 с/диалог»).
|
||||
///
|
||||
/// Реальная реализация (<see cref="RandomBackfillPacer"/>) спит случайное время в диапазоне как
|
||||
/// `random.uniform` прототипа (BACKFILL_PER_MESSAGE/BACKFILL_PER_DIALOG, telegram.py L35–36);
|
||||
/// тесты подставляют фейк-пейсер, записывающий запрошенные диапазоны (fake clock, план Task 10).
|
||||
/// </summary>
|
||||
public interface IBackfillPacer
|
||||
{
|
||||
/// <summary>
|
||||
/// Ждёт случайное время в диапазоне [minSeconds, maxSeconds] (анти-бан).
|
||||
/// </summary>
|
||||
/// <param name="minSeconds">Нижняя граница паузы, секунды (≥ 0).</param>
|
||||
/// <param name="maxSeconds">Верхняя граница паузы, секунды (≥ minSeconds).</param>
|
||||
/// <param name="cancellationToken">Отмена ожидания.</param>
|
||||
/// <returns>Задача ожидания.</returns>
|
||||
public Task WaitAsync(double minSeconds, double maxSeconds, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Deal.Telegram.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Реальная реализация <see cref="IBackfillPacer"/>: случайная пауза в диапазоне (Random.Shared) —
|
||||
/// эквивалент `await asyncio.sleep(random.uniform(min, max))` прототипа (telegram.py L381/L347).
|
||||
/// </summary>
|
||||
public sealed class RandomBackfillPacer : IBackfillPacer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task WaitAsync(double minSeconds, double maxSeconds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (maxSeconds < 0 || minSeconds < 0 || minSeconds > maxSeconds)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxSeconds), $"Некорректный диапазон паузы: [{minSeconds}, {maxSeconds}].");
|
||||
}
|
||||
|
||||
if (maxSeconds == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double seconds = minSeconds == maxSeconds
|
||||
? minSeconds
|
||||
: minSeconds + Random.Shared.NextDouble() * (maxSeconds - minSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.Telegram.Core;
|
||||
using Deal.Telegram.Sessions;
|
||||
using Deal.Telegram.Telegram;
|
||||
|
||||
namespace Deal.Telegram.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Realtime-listener мониторящихся диалогов одного тенанта (план Task 10, Dialogs/RealtimeListener.cs;
|
||||
/// прототип _on_message L255–283). Подписывается на события <see cref="TenantSession.MessageReceived"/>
|
||||
/// (входящие текстовые сообщения аккаунта), фильтрует по зеркалу мониторинга <see cref="DialogCatalog"/>
|
||||
/// и отправляет сообщение в ядро Ingress.PushMessage; после успешной отправки — mark-as-read
|
||||
/// (send_read_acknowledge, ТЗ: «сразу помечаются прочитанными», Ruling 3).
|
||||
/// Упущенное при сбое/рестарте догоняет realtime_sweep (read-ack после сбоя не выполняется).
|
||||
/// Жизненный цикл экземпляра — за <see cref="Hosting.RealtimeMonitorService"/> (по одной сессии ready).
|
||||
/// </summary>
|
||||
public sealed class RealtimeListener
|
||||
{
|
||||
private readonly TenantSession _session;
|
||||
private readonly DialogCatalog _catalog;
|
||||
private readonly ICoreIngressClient _ingress;
|
||||
private readonly ILogger<RealtimeListener> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт listener сессии.
|
||||
/// </summary>
|
||||
/// <param name="session">Ready-сессия тенанта (события сообщений её клиента).</param>
|
||||
/// <param name="catalog">Зеркало мониторинга (фильтр «мониторится ли диалог», Ruling 7).</param>
|
||||
/// <param name="ingress">Канал в ядро (PushMessage).</param>
|
||||
/// <param name="logger">Логгер.</param>
|
||||
public RealtimeListener(
|
||||
TenantSession session,
|
||||
DialogCatalog catalog,
|
||||
ICoreIngressClient ingress,
|
||||
ILogger<RealtimeListener> logger)
|
||||
{
|
||||
_session = session;
|
||||
_catalog = catalog;
|
||||
_ingress = ingress;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подписывает listener на события сессии (вызывается при готовности сессии).
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_session.MessageReceived += OnMessageReceivedAsync;
|
||||
_session.SetListenerActive(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отписывает listener (сессия ушла из ready/остановка хоста).
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_session.MessageReceived -= OnMessageReceivedAsync;
|
||||
_session.SetListenerActive(false);
|
||||
}
|
||||
|
||||
// Обработчик входящего сообщения: фильтр по зеркалу → PushMessage в ядро → mark-as-read.
|
||||
// Сбой отправки не роняет realtime: сообщение остаётся непрочитанным и догоняется realtime_sweep.
|
||||
// message: Входящее сообщение аккаунта.
|
||||
private async Task OnMessageReceivedAsync(TelegramMessage message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_catalog.IsMonitored(_session.TenantId, message.DialogId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PushMessageReply reply = await _ingress
|
||||
.PushMessageAsync(_session.TenantId, DialogProtoMapper.ToPushRequest(message), CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
if (reply.Accepted)
|
||||
{
|
||||
await _session.MarkReadAsync(message.DialogId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Realtime {TenantId} {DialogId} msg {MessageId} → ядро (duplicate {Duplicate})",
|
||||
_session.TenantId,
|
||||
message.DialogId,
|
||||
message.Id,
|
||||
reply.Duplicate);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Без read-ack: непрочитанное сообщение подберёт realtime_sweep (Ruling 7/план Task 10).
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Realtime {TenantId} {DialogId} msg {MessageId}: не доставлено в ядро — догонит sweep",
|
||||
_session.TenantId,
|
||||
message.DialogId,
|
||||
message.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.Telegram.Core;
|
||||
using Deal.Telegram.Sessions;
|
||||
using Deal.Telegram.Telegram;
|
||||
|
||||
namespace Deal.Telegram.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Страховочная догонялка realtime (план Task 10, Dialogs/RealtimeSweep.cs; прототип realtime_sweep
|
||||
/// L392–456, цикл 30 с). Для каждой ready-сессии: список диалогов → синхронизация каталога с ядром
|
||||
/// (SyncDialogs: ядро применяет entries с autoMonitorNew и отвечает актуальным monitored — зеркало
|
||||
/// восстанавливается после рестарта сервиса) → по мониторящимся диалогам с unread_count > 0 читает
|
||||
/// до 10 непрочитанных (от старых к новым), отправляет в ядро PushMessage и снимает «новое»
|
||||
/// (read-ack). Потерянные realtime-события (рестарт/разрыв/сбой PushMessage) догоняются здесь.
|
||||
/// </summary>
|
||||
public sealed class RealtimeSweep
|
||||
{
|
||||
/// <summary>
|
||||
/// Период цикла догона (сек; прототип вызывается планировщиком ~30 с).
|
||||
/// </summary>
|
||||
public const int SweepPeriodSeconds = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница списка диалогов за цикл (как refresh_dialogs L510: limit=500).
|
||||
/// </summary>
|
||||
public const int DialogsLimit = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Запас сообщений сверх unread_count при чтении (прототип L435: min(unread + 2, 10)).
|
||||
/// </summary>
|
||||
public const int UnreadSlackMessages = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Потолок сообщений одного диалога за цикл (прототип L435: 10).
|
||||
/// </summary>
|
||||
public const int MaxMessagesPerDialog = 10;
|
||||
|
||||
private readonly SessionFarm _sessionFarm;
|
||||
private readonly DialogCatalog _catalog;
|
||||
private readonly ICoreIngressClient _ingress;
|
||||
private readonly ILogger<RealtimeSweep> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт догонялку realtime.
|
||||
/// </summary>
|
||||
/// <param name="sessionFarm">Пул сессий тенантов.</param>
|
||||
/// <param name="catalog">Зеркало каталога/мониторинга (актуализируется ответом SyncDialogs).</param>
|
||||
/// <param name="ingress">Канал в ядро (PushMessage/SyncDialogs).</param>
|
||||
/// <param name="logger">Логгер.</param>
|
||||
public RealtimeSweep(SessionFarm sessionFarm, DialogCatalog catalog, ICoreIngressClient ingress, ILogger<RealtimeSweep> logger)
|
||||
{
|
||||
_sessionFarm = sessionFarm;
|
||||
_catalog = catalog;
|
||||
_ingress = ingress;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один цикл догона: все ready-сессии по очереди (сбой тенанта не останавливает остальных).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Отмена цикла.</param>
|
||||
public async Task SweepAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (TenantSession session in _sessionFarm.Sessions)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.Phase != AuthPhase.Ready)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SweepTenantAsync(session.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "realtime sweep {TenantId}: цикл завершился с ошибкой", session.TenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Догон одного тенанта: синк каталога + непрочитанные мониторящихся диалогов.
|
||||
// tenantId: Id тенанта.
|
||||
// cancellationToken: Отмена операции.
|
||||
private async Task SweepTenantAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<TelegramDialog> dialogs = await _sessionFarm
|
||||
.ListDialogsAsync(tenantId, DialogsLimit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (dialogs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Синхронизация каталога: зеркало мониторинга восстанавливается ответом ядра (L399–426).
|
||||
List<string> dialogIds = dialogs.Select(dialog => dialog.Id).ToList();
|
||||
_catalog.ReplaceKnown(tenantId, dialogIds);
|
||||
List<DialogEntry> entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList();
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string> monitored = await _ingress.SyncDialogsAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
|
||||
_catalog.ReplaceMonitored(tenantId, monitored);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Ядро недоступно: упущенное догонит следующий цикл, зеркало остаётся прежним.
|
||||
_logger.LogWarning(exception, "realtime sweep {TenantId}: синхронизация каталога не выполнена", tenantId);
|
||||
}
|
||||
|
||||
foreach (TelegramDialog dialog in dialogs)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_catalog.IsMonitored(tenantId, dialog.Id) || dialog.UnreadCount <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await SweepDialogAsync(tenantId, dialog, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Догон одного диалога: чтение непрочитанных → PushMessage → read-ack (L427–456).
|
||||
// tenantId: Id тенанта.
|
||||
// dialog: Диалог с unread_count > 0.
|
||||
// cancellationToken: Отмена операции.
|
||||
private async Task SweepDialogAsync(string tenantId, TelegramDialog dialog, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
int limit = Math.Min(dialog.UnreadCount + UnreadSlackMessages, MaxMessagesPerDialog);
|
||||
IReadOnlyList<TelegramMessage> messages = await _sessionFarm
|
||||
.GetMessagesAsync(tenantId, dialog.Id, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
int added = 0;
|
||||
foreach (TelegramMessage message in messages.Reverse())
|
||||
{
|
||||
PushMessageReply reply = await _ingress
|
||||
.PushMessageAsync(tenantId, DialogProtoMapper.ToPushRequest(message), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (reply.Accepted && !reply.Duplicate)
|
||||
{
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
if (added > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"realtime sweep {TenantId} {DialogId}: +{Added} в ядро (потерянные события)",
|
||||
tenantId,
|
||||
dialog.Id,
|
||||
added);
|
||||
}
|
||||
|
||||
// Снимаем «новое» в Telegram (прототип L454). При ошибке выше — без read-ack: следующий
|
||||
// цикл увидит unread снова и дочитает то, что не ушло в ядро.
|
||||
await _sessionFarm.MarkReadAsync(tenantId, dialog.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "realtime sweep {TenantId} {DialogId}: диалог не догнан", tenantId, dialog.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Deal.Telegram.Dialogs;
|
||||
using Deal.Telegram.Sessions;
|
||||
using Deal.Telegram.Telegram;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Telegram.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Discovery-операции telegram-service поверх пула сессий тенантов (план Task 11; 1:1 discovery_* методов
|
||||
/// python-прототипа telegram.py L622–873). Каждая операция исполняется на сессии своего тенанта
|
||||
/// (SessionFarm → TenantSession, Ruling 1): поиск (Search), инфо об источнике (GetInfo), чтение выборки
|
||||
/// для оценки (ReadForEval), вступление (Join) и выход (Leave).
|
||||
///
|
||||
/// Внутренний анти-бан сервиса (Ruling 3) — пауза 2–4 с после поискового запроса (ban_guard.search_pause
|
||||
/// L78–80). Внешний анти-бан вступления (суточный лимит 50/тенант, паузы 50–70 с, flood-день, стоп-кран) —
|
||||
/// владение core-воркера Discovery (Ruling 10): здесь Join — ручная операция вне квот (прототип L818–823),
|
||||
/// FloodWait переводится в RESOURCE_EXHAUSTED (detail с префиксом "flood") — базовый флуд-гард сессии.
|
||||
/// </summary>
|
||||
public sealed class DiscoveryOps
|
||||
{
|
||||
/// <summary>
|
||||
/// Верхняя граница поиска по умолчанию, если core не передал (прототип: 30, L624).
|
||||
/// </summary>
|
||||
public const int SearchDefaultLimit = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Нижняя граница анти-бан-паузы после поиска (ban_guard.search_pause, 2 с).
|
||||
/// </summary>
|
||||
public const double SearchPauseMinSeconds = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница анти-бан-паузы после поиска (ban_guard.search_pause, 4 с).
|
||||
/// </summary>
|
||||
public const double SearchPauseMaxSeconds = 4.0;
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки чтения по умолчанию, если core не передал (ReadForEvalRequest limit ≥ 1).
|
||||
/// </summary>
|
||||
public const int ReadForEvalDefaultLimit = 30;
|
||||
|
||||
private readonly SessionFarm _sessionFarm;
|
||||
private readonly IBackfillPacer _pacer;
|
||||
private readonly ILogger<DiscoveryOps> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт службу discovery-операций.
|
||||
/// </summary>
|
||||
/// <param name="sessionFarm">Пул сессий тенантов (операции только на сессии своего тенанта).</param>
|
||||
/// <param name="pacer">Анти-бан-паузы (реальный — случайные 2–4 с, тесты — фейк).</param>
|
||||
/// <param name="logger">Логгер.</param>
|
||||
public DiscoveryOps(SessionFarm sessionFarm, IBackfillPacer pacer, ILogger<DiscoveryOps> logger)
|
||||
{
|
||||
_sessionFarm = sessionFarm;
|
||||
_pacer = pacer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Глобальный поиск источников по ключу (discovery_search L624–664). Выполняет поиск на сессии тенанта,
|
||||
/// затем держит анти-бан-паузу 2–4 с (Ruling 3) и возвращает результат без дублей и не длиннее лимита.
|
||||
/// Личные чаты/боты (kind=chat) не отсеиваются — это делает ядро (Ruling 10).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
|
||||
/// <param name="limit">Верхняя граница результата (≤ 0 — прототип-дефолт 30).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Найденные источники (каналы/группы/личные) без дублей, не длиннее limit.</returns>
|
||||
public async Task<IReadOnlyList<TelegramDialog>> SearchAsync(string tenantId, string query, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
int effectiveLimit = limit > 0 ? limit : SearchDefaultLimit;
|
||||
IReadOnlyList<TelegramDialog> found = await _sessionFarm
|
||||
.SearchAsync(tenantId, query, effectiveLimit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Пауза между поисковыми запросами (анти-бан; Ruling 3, ban_guard.search_pause L78–80).
|
||||
await _pacer.WaitAsync(SearchPauseMinSeconds, SearchPauseMaxSeconds, cancellationToken).ConfigureAwait(false);
|
||||
return DedupeAndCap(found, effectiveLimit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Инфо об источнике для оценки кандидата (discovery_info L666–716).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="dialogId">Подписанный id источника («-100…»/«-…»/«+…»).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Инфо об источнике (сбои определения не бросаются — см. ISessionClient.GetInfoAsync).</returns>
|
||||
public Task<TelegramSourceInfo> GetInfoAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
|
||||
=> _sessionFarm.GetInfoAsync(tenantId, dialogId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Выборка последних сообщений источника для оценки (discovery_read L718–800).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <param name="limit">Размер выборки (≤ 0 — дефолт; форумы читаются по активным темам).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>ok/сообщения либо ok=false/error="no_history" (не ошибка RPC).</returns>
|
||||
public Task<DiscoveryReadResult> ReadForEvalAsync(string tenantId, string dialogId, int limit, CancellationToken cancellationToken)
|
||||
=> _sessionFarm.ReadForEvalAsync(tenantId, dialogId, limit > 0 ? limit : ReadForEvalDefaultLimit, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Вступить в канал/группу по username (discovery_join L818–839; ручной join из API — вне квот, паузу
|
||||
/// перед авто-join делает воркер ядра, Ruling 10). Пустой username → INVALID_ARGUMENT; FloodWait →
|
||||
/// SessionException RESOURCE_EXHAUSTED с префиксом "flood" (флуд-гард, контракт telegram.proto).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="username">Username источника («@»/пробелы нормализуются).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
public async Task JoinAsync(string tenantId, string username, CancellationToken cancellationToken)
|
||||
{
|
||||
string normalized = NormalizeUsername(username);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.JoinUsernameMissing);
|
||||
}
|
||||
|
||||
await _sessionFarm.JoinAsync(tenantId, normalized, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Discovery: вступили в @{Username} от tenant {TenantId}", normalized, tenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выйти из канала/группы (discovery_leave L841–848).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="dialogId">Подписанный id диалога.</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
public Task LeaveAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
|
||||
=> _sessionFarm.LeaveAsync(tenantId, dialogId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует username вступления 1:1 прототипа L826 (strip + lstrip "@"): срезает пробелы и ведущие «@».
|
||||
/// </summary>
|
||||
/// <param name="username">Username из запроса (может быть пуст/null).</param>
|
||||
/// <returns>Нормализованный username (пустой — вступать не по чему).</returns>
|
||||
public static string NormalizeUsername(string? username)
|
||||
=> (username ?? string.Empty).Trim().TrimStart('@');
|
||||
|
||||
// Убирает дубли id и обрезает результат до лимита, сохраняя порядок (1:1 L643–663).
|
||||
// found: Результат поиска сессии (chats затем users).
|
||||
// limit: Верхняя граница числа записей.
|
||||
private static IReadOnlyList<TelegramDialog> DedupeAndCap(IReadOnlyList<TelegramDialog> found, int limit)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var items = new List<TelegramDialog>(Math.Min(found.Count, limit));
|
||||
foreach (TelegramDialog item in found)
|
||||
{
|
||||
if (!seen.Add(item.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
items.Add(item);
|
||||
if (items.Count >= limit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.Telegram.Dialogs;
|
||||
using Deal.Telegram.Telegram;
|
||||
|
||||
namespace Deal.Telegram.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Маппер нейтральных результатов discovery в protobuf-контракт (telegram.proto; план Task 11).
|
||||
///
|
||||
/// Чистый и без зависимостей от TL-слоя — единое место разбора, которое используют RPC-реализации
|
||||
/// (TelegramServiceImpl) и unit-тесты формата ответов (записи поиска — как в каталоге,
|
||||
/// ChannelInfo/ReadForEvalReply — здесь). hue считает сервис (Ruling 7), не TL-слой.
|
||||
/// </summary>
|
||||
public static class DiscoveryProtoMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Нейтральное инфо источника → ChannelInfo контракта (participants/is_forum, hue).
|
||||
/// </summary>
|
||||
/// <param name="info">Инфо из сессии (по умолчанию — только id/name).</param>
|
||||
public static ChannelInfo ToChannelInfo(TelegramSourceInfo info)
|
||||
{
|
||||
var result = new ChannelInfo
|
||||
{
|
||||
Id = info.Id,
|
||||
Name = info.Name,
|
||||
Username = info.Username,
|
||||
Kind = info.Kind,
|
||||
Hue = DialogHue.Compute(info.Id, info.Name),
|
||||
IsForum = info.IsForum,
|
||||
};
|
||||
|
||||
if (info.Participants is int participants)
|
||||
{
|
||||
result.Participants = participants;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение выборки → EvalMessage контракта (темы форума — optional-поля).
|
||||
/// </summary>
|
||||
/// <param name="message">Сообщение discovery-read (непустой текст).</param>
|
||||
public static EvalMessage ToEvalMessage(DiscoveryMessage message)
|
||||
{
|
||||
var result = new EvalMessage
|
||||
{
|
||||
Id = message.Id,
|
||||
Text = message.Text,
|
||||
DateMs = message.DateMs,
|
||||
};
|
||||
|
||||
if (message.TopicId is long topicId)
|
||||
{
|
||||
result.TopicId = topicId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(message.TopicTitle))
|
||||
{
|
||||
result.TopicTitle = message.TopicTitle;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Результат чтения выборки → ReadForEvalReply контракта (ok/error/messages).
|
||||
/// </summary>
|
||||
/// <param name="result">Результат из сессии (ok=false → error="no_history").</param>
|
||||
public static ReadForEvalReply ToReadForEvalReply(DiscoveryReadResult result)
|
||||
{
|
||||
var reply = new ReadForEvalReply { Ok = result.Ok };
|
||||
if (!result.Ok)
|
||||
{
|
||||
reply.Error = result.Error ?? DiscoveryReadResult.NoHistoryError;
|
||||
return reply;
|
||||
}
|
||||
|
||||
foreach (DiscoveryMessage message in result.Messages)
|
||||
{
|
||||
reply.Messages.Add(ToEvalMessage(message));
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# telegram-service: gRPC-хост Telegram (план Task 2; Ruling 12 — запись в deploy/compose.dev.yml).
|
||||
#
|
||||
# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Telegram.csproj ссылается на src/contracts/Deal.Proto.csproj
|
||||
# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из
|
||||
# src/telegram-service. Запуск из корня: docker build -f src/telegram-service/Deal.Telegram/Dockerfile .
|
||||
# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5101.
|
||||
|
||||
# --- Этап сборки: restore + publish ---
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /repo
|
||||
|
||||
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
|
||||
COPY src/contracts/Deal.Proto.csproj src/contracts/
|
||||
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
|
||||
COPY src/telegram-service/Directory.Build.props src/telegram-service/
|
||||
COPY src/telegram-service/Deal.Telegram/Deal.Telegram.csproj src/telegram-service/Deal.Telegram/
|
||||
RUN dotnet restore src/telegram-service/Deal.Telegram/Deal.Telegram.csproj
|
||||
|
||||
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
|
||||
COPY src/contracts/ src/contracts/
|
||||
COPY src/grpc-hosting/ src/grpc-hosting/
|
||||
COPY src/telegram-service/Deal.Telegram/ src/telegram-service/Deal.Telegram/
|
||||
RUN dotnet publish src/telegram-service/Deal.Telegram/Deal.Telegram.csproj -c Release -o /app/publish
|
||||
|
||||
# --- Runtime-этап ---
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
EXPOSE 5101
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token
|
||||
# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata.
|
||||
COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe
|
||||
|
||||
# Сессии тенантов — файлы /data/sessions (Ruling 3): каталог монтируется volume-ом deal_tg_sessions
|
||||
# из compose.dev.yml; создаётся при первом сохранении сессии (задачи 9–11).
|
||||
|
||||
ENTRYPOINT ["dotnet", "Deal.Telegram.dll"]
|
||||
@@ -0,0 +1,132 @@
|
||||
using Deal.Telegram.Core;
|
||||
using Deal.Telegram.Dialogs;
|
||||
using Deal.Telegram.Sessions;
|
||||
|
||||
namespace Deal.Telegram.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый reconcile realtime-listener'ов (план Task 10; «подписка: новые сообщения → PushMessage»).
|
||||
///
|
||||
/// Держит по одному <see cref="RealtimeListener"/> на ready-сессию: сессия перешла в ready — listener
|
||||
/// подписывается на её события (GetStatus.listener → true); сессия ушла из ready (Logout/ошибка) —
|
||||
/// отписка. Период мал (2 с) — подписка появляется сразу после QR-сканирования/auto_resume; упущенное
|
||||
/// в окне между ready и подпиской догоняет realtime_sweep (без read-ack сообщения остаются unread).
|
||||
/// </summary>
|
||||
public sealed class RealtimeMonitorService : BackgroundService
|
||||
{
|
||||
/// <summary>
|
||||
/// Период reconcile подписок (сек).
|
||||
/// </summary>
|
||||
public const int ReconcileIntervalSeconds = 2;
|
||||
|
||||
private readonly SessionFarm _sessionFarm;
|
||||
private readonly DialogCatalog _catalog;
|
||||
private readonly ICoreIngressClient _ingress;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<RealtimeMonitorService> _logger;
|
||||
private readonly Dictionary<TenantSession, RealtimeListener> _listeners = new();
|
||||
private readonly object _listenersGate = new();
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт фоновый reconcile listener'ов.
|
||||
/// </summary>
|
||||
/// <param name="sessionFarm">Пул сессий тенантов.</param>
|
||||
/// <param name="catalog">Зеркало мониторинга (фильтр сообщений).</param>
|
||||
/// <param name="ingress">Канал в ядро (PushMessage).</param>
|
||||
/// <param name="loggerFactory">Фабрика логгеров (логгеры listener'ов).</param>
|
||||
public RealtimeMonitorService(
|
||||
SessionFarm sessionFarm,
|
||||
DialogCatalog catalog,
|
||||
ICoreIngressClient ingress,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_sessionFarm = sessionFarm;
|
||||
_catalog = catalog;
|
||||
_ingress = ingress;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<RealtimeMonitorService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Периодический reconcile подписок ready-сессий.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">Токен остановки хоста.</param>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using PeriodicTimer timer = new(TimeSpan.FromSeconds(ReconcileIntervalSeconds));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
Reconcile();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Reconcile realtime-listener'ов завершился с ошибкой — следующий цикл");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// При остановке хоста отписывает все listener'ы.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Токен остановки.</param>
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.StopAsync(cancellationToken).ConfigureAwait(false);
|
||||
DetachAll();
|
||||
}
|
||||
|
||||
// Сверяет подписки с ready-сессиями пула (подписка/отписка по факту фазы).
|
||||
private void Reconcile()
|
||||
{
|
||||
HashSet<TenantSession> readySessions = _sessionFarm.Sessions
|
||||
.Where(session => session.Phase == AuthPhase.Ready)
|
||||
.ToHashSet();
|
||||
|
||||
lock (_listenersGate)
|
||||
{
|
||||
foreach (TenantSession session in _listeners.Keys.ToArray())
|
||||
{
|
||||
if (!readySessions.Contains(session))
|
||||
{
|
||||
if (_listeners.Remove(session, out RealtimeListener? listener))
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (TenantSession session in readySessions)
|
||||
{
|
||||
if (_listeners.ContainsKey(session))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var listener = new RealtimeListener(session, _catalog, _ingress, _loggerFactory.CreateLogger<RealtimeListener>());
|
||||
listener.Start();
|
||||
_listeners[session] = listener;
|
||||
_logger.LogInformation("Realtime-listener {TenantId} подписан", session.TenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отписывает все listener'ы (остановка хоста).
|
||||
private void DetachAll()
|
||||
{
|
||||
lock (_listenersGate)
|
||||
{
|
||||
foreach (RealtimeListener listener in _listeners.Values)
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
_listeners.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Deal.Telegram.Dialogs;
|
||||
|
||||
namespace Deal.Telegram.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый цикл догона realtime (план Task 10; эталон SessionHeartbeatService). Каждые 30 секунд
|
||||
/// вызывает <see cref="RealtimeSweep.SweepAllAsync"/> по ready-сессиям; первый проход — сразу после
|
||||
/// старта (зеркало мониторинга восстанавливается после рестарта, упущенное догоняется — Ruling 7).
|
||||
/// Сбои отдельного цикла не роняют хост.
|
||||
/// </summary>
|
||||
public sealed class RealtimeSweepService : BackgroundService
|
||||
{
|
||||
private readonly RealtimeSweep _sweep;
|
||||
private readonly ILogger<RealtimeSweepService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт фоновый цикл догона.
|
||||
/// </summary>
|
||||
/// <param name="sweep">Логика догона realtime.</param>
|
||||
/// <param name="logger">Логгер.</param>
|
||||
public RealtimeSweepService(RealtimeSweep sweep, ILogger<RealtimeSweepService> logger)
|
||||
{
|
||||
_sweep = sweep;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Первый проход сразу, затем цикл 30 с.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">Токен остановки хоста.</param>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using PeriodicTimer timer = new(TimeSpan.FromSeconds(RealtimeSweep.SweepPeriodSeconds));
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sweep.SweepAllAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Цикл realtime sweep завершился с ошибкой — следующий цикл");
|
||||
}
|
||||
|
||||
if (!await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Deal.Telegram.Sessions;
|
||||
|
||||
namespace Deal.Telegram.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый цикл сессий telegram-service (план Task 9: «heartbeat/авто-возобновление фоновым циклом
|
||||
/// 30 с»; heartbeat прототипа L318–327). На старте — auto_resume сохранённых сессий тенантов
|
||||
/// (авторизованная сессия → ready), далее каждые 30 секунд — повторное подключение оборвавшихся
|
||||
/// сессий и (при остановке хоста) сохранение живых сессий в файлы (Ruling 3).
|
||||
/// Сетевых вызовов без сессий не делает; с фейковой фабрикой в тестах — no-op.
|
||||
/// </summary>
|
||||
public sealed class SessionHeartbeatService : BackgroundService
|
||||
{
|
||||
/// <summary>
|
||||
/// Период цикла сердцебиения (сек; прототип heartbeat вызывается планировщиком).
|
||||
/// </summary>
|
||||
public const int HeartbeatIntervalSeconds = 30;
|
||||
|
||||
private readonly SessionFarm _sessionFarm;
|
||||
private readonly ILogger<SessionHeartbeatService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт фоновый цикл сессий.
|
||||
/// </summary>
|
||||
/// <param name="sessionFarm">Пул сессий тенантов.</param>
|
||||
/// <param name="logger">Логгер.</param>
|
||||
public SessionHeartbeatService(SessionFarm sessionFarm, ILogger<SessionHeartbeatService> logger)
|
||||
{
|
||||
_sessionFarm = sessionFarm;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Первый проход — auto_resume файлов сессий (создаёт ready-сессии после рестарта контейнера);
|
||||
/// затем периодический heartbeat оборвавшихся соединений.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">Токен остановки хоста.</param>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sessionFarm.ResumeAllAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(exception, "auto_resume сессий не выполнен — продолжаем без возобновления");
|
||||
}
|
||||
|
||||
using PeriodicTimer timer = new(TimeSpan.FromSeconds(HeartbeatIntervalSeconds));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sessionFarm.HeartbeatTickAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Heartbeat сессий завершился с ошибкой — следующий цикл");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// При остановке хоста сохраняет живые сессии (перешифровка при остановке, Ruling 3).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Токен остановки.</param>
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.StopAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _sessionFarm.ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// telegram-service — точка входа gRPC-хоста (план Task 2, L227–238; Ruling 1/2/12).
|
||||
//
|
||||
// Kestrel HTTP/2 на порту 5101 (env GRPC_PORT, затем PORT) + AddGrpc с интерцепторами service-token
|
||||
// и access-лога + стандартный gRPC-health (grpc.health.v1.Health). Транспорт: dev — plaintext
|
||||
// (Ruling 2); mTLS (TLS + клиентский сертификат) — при DEAL_MTLS_ENABLED=1 (Ruling 6, план Task 13;
|
||||
// сертификаты deploy/certs — scripts/mtls-certs.sh, env передаёт compose-prod Task 14); fail-closed:
|
||||
// Production без mTLS не стартует (GrpcHostEnvironment.RequireMtlsInProduction).
|
||||
// Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика
|
||||
// TelegramServiceHost.Create используется и интеграционными тестами (Deal.Telegram.Tests), которые
|
||||
// поднимают его в своём процессе на эфемерном порту. Реализованы RPC сессий (Task 9),
|
||||
// каталога/мониторинга (Task 10) и discovery-операции Search/GetInfo/ReadForEval/Join/Leave
|
||||
// (Task 11, см. TelegramServiceImpl/DiscoveryOps).
|
||||
|
||||
using Deal.Grpc.Hosting;
|
||||
using Deal.Telegram;
|
||||
|
||||
// Порт по умолчанию — 5101 (Ruling 12, compose.dev.yml); переопределяется env GRPC_PORT (контейнер)
|
||||
// или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort.
|
||||
const int defaultGrpcPort = 5101;
|
||||
// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-telegram-<дата>.json.
|
||||
const string telegramProcessName = "telegram";
|
||||
|
||||
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
|
||||
// Порт эндпоинта метрик /metrics (HTTP/1.1, отдельно от gRPC HTTP/2; этап 12, пакет A).
|
||||
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
|
||||
|
||||
// Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл data/logs/deal-telegram-*.json —
|
||||
// конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают
|
||||
// без Serilog, DealLogging.Configure в TelegramServiceHost/Create вызывается только здесь). Метрики
|
||||
// (OTel → Prometheus, /metrics) — тем же хуком до builder.Build().
|
||||
WebApplication app = TelegramServiceHost.Create(
|
||||
grpcPort,
|
||||
configureBuilder: builder =>
|
||||
{
|
||||
DealLogging.Configure(builder, telegramProcessName);
|
||||
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
||||
});
|
||||
|
||||
// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A).
|
||||
DealMetricsHosting.MapDealMetrics(app);
|
||||
|
||||
// Режим транспорта — из тех же env, что читал хост (Ruling 6, Task 13): mTLS при DEAL_MTLS_ENABLED=1.
|
||||
MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration);
|
||||
|
||||
// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать
|
||||
// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше.
|
||||
GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions);
|
||||
|
||||
app.Logger.LogInformation(
|
||||
"telegram-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)",
|
||||
mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token",
|
||||
grpcPort);
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Deal.Telegram.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Фаза входа/подключения аккаунта тенанта (1:1 с каноном telegram.proto:
|
||||
/// idle|phone|code|password|qr|ready — status() прототипа L85, план Task 9).
|
||||
/// </summary>
|
||||
public enum AuthPhase
|
||||
{
|
||||
/// <summary>
|
||||
/// Активного входа нет (клиент не создан, вход не начат или завершён Logout).
|
||||
/// </summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>
|
||||
/// Фаза ввода номера телефона (в прототипе используется как промежуточная; код ещё не запрошен).
|
||||
/// </summary>
|
||||
Phone,
|
||||
|
||||
/// <summary>
|
||||
/// SMS-код отправлен, ожидается код (submit_code).
|
||||
/// </summary>
|
||||
Code,
|
||||
|
||||
/// <summary>
|
||||
/// Включена двухфакторная аутентификация — нужен облачный пароль (submit_password).
|
||||
/// </summary>
|
||||
Password,
|
||||
|
||||
/// <summary>
|
||||
/// QR-вход запущен: ожидается сканирование, qr_url актуален.
|
||||
/// </summary>
|
||||
Qr,
|
||||
|
||||
/// <summary>
|
||||
/// Аккаунт авторизован, сессия сохранена (клиент готов исполнять команды).
|
||||
/// </summary>
|
||||
Ready,
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
namespace Deal.Telegram.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Канонические тексты причин сессионных ошибок (detail RPC и статус-поле GetStatus.error).
|
||||
/// Тексты 1:1 с прототипом backend/app/services/telegram.py и перечнем строк плана
|
||||
/// (задачи: «Telegram не подключён», «Сначала сохраните Telegram api_id и api_hash в настройках»,
|
||||
/// «Неверный код», «Код истёк — запросите новый», «Неверный облачный пароль»).
|
||||
/// </summary>
|
||||
public static class SessionErrorMessages
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключи приложения не переданы ядром (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
public const string NoApiKeys = "Сначала сохраните Telegram api_id и api_hash в настройках";
|
||||
|
||||
/// <summary>
|
||||
/// Аккаунт тенанта не подключён — нет сессии (FAILED_PRECONDITION).
|
||||
/// </summary>
|
||||
public const string NotConnected = "Telegram не подключён";
|
||||
|
||||
/// <summary>
|
||||
/// Неверный SMS-код входа (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
public const string WrongCode = "Неверный код";
|
||||
|
||||
/// <summary>
|
||||
/// SMS-код истёк, нужен новый (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
public const string CodeExpired = "Код истёк — запросите новый";
|
||||
|
||||
/// <summary>
|
||||
/// Неверный облачный пароль 2FA (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
public const string WrongPassword = "Неверный облачный пароль";
|
||||
|
||||
/// <summary>
|
||||
/// SendCode вызван вне фазы "code" (FAILED_PRECONDITION).
|
||||
/// </summary>
|
||||
public const string CodeNotRequested = "Код не запрашивался — начните вход по номеру телефона";
|
||||
|
||||
/// <summary>
|
||||
/// SendPassword вызван вне фазы "password" (FAILED_PRECONDITION).
|
||||
/// </summary>
|
||||
public const string PasswordNotRequested = "2FA-пароль не запрашивался — сначала отправьте код";
|
||||
|
||||
/// <summary>
|
||||
/// Metadata tenant-id отсутствует или пуст (UNAUTHENTICATED).
|
||||
/// </summary>
|
||||
public const string TenantIdMissing = "tenant-id отсутствует в metadata";
|
||||
|
||||
/// <summary>
|
||||
/// Некорректный tenant-id (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
public const string InvalidTenantId = "Некорректный tenant-id в metadata";
|
||||
|
||||
/// <summary>
|
||||
/// Telegram/сеть недоступны (UNAVAILABLE).
|
||||
/// </summary>
|
||||
public const string TelegramUnavailable = "Telegram недоступен — повторите попытку позже";
|
||||
|
||||
/// <summary>
|
||||
/// Ядро (gRPC-ингресс) недоступно — PushMessage/SyncDialogs не доставлены (UNAVAILABLE).
|
||||
/// </summary>
|
||||
public const string IngressUnavailable = "Ядро недоступно — повторите попытку позже";
|
||||
|
||||
/// <summary>
|
||||
/// Диалог не найден в аккаунте/кэше сущностей сессии (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
public const string UnknownDialog = "Источник не найден в аккаунте — обновите список каналов";
|
||||
|
||||
/// <summary>
|
||||
/// Пустой username вступления (INVALID_ARGUMENT; 1:1 ValueError discovery_join L828).
|
||||
/// </summary>
|
||||
public const string JoinUsernameMissing = "Не указан username для вступления";
|
||||
|
||||
/// <summary>
|
||||
/// Поисковый запрос длиннее верхней границы (INVALID_ARGUMENT; защита границы сервиса).
|
||||
/// </summary>
|
||||
public const string SearchQueryTooLong = "Слишком длинный поисковый запрос";
|
||||
|
||||
/// <summary>
|
||||
/// Username вступления длиннее лимита Telegram (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
public const string UsernameTooLong = "Слишком длинный username";
|
||||
|
||||
/// <summary>
|
||||
/// Внутренняя ошибка сервиса (INTERNAL; сбой реализации, а не транспорт/сеть).
|
||||
/// </summary>
|
||||
public const string InternalError = "Внутренняя ошибка сервиса — повторите попытку позже";
|
||||
|
||||
/// <summary>
|
||||
/// По username найден не канал/группа (личный чат/бот) — вступить нельзя (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
public const string JoinTargetNotChannel = "По этому username найден не канал/группа — вступить нельзя";
|
||||
|
||||
/// <summary>
|
||||
/// Некорректный (неподписанный) id диалога в запросе (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
public const string InvalidDialogId = "Некорректный id источника";
|
||||
|
||||
/// <summary>
|
||||
/// Телефон не зарегистрирован в Telegram (регистрация из сервиса не выполняется).
|
||||
/// </summary>
|
||||
public const string SignUpRequired = "Номер не зарегистрирован в Telegram — зарегистрируйте его в приложении Telegram";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ шифрования сессий не задан в env (служебная ошибка конфигурации).
|
||||
/// </summary>
|
||||
public const string SessionKeyNotConfigured = "Ключ шифрования сессий не задан (DEAL_TELEGRAM_SESSION_KEY)";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Telegram.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Доменная ошибка сессий telegram-service (план Task 9; Ruling 1/3).
|
||||
///
|
||||
/// Ошибки Telegram/логики подключения переводятся в gRPC-статусы ядром только через этот тип:
|
||||
/// обработчики TelegramServiceImpl ловят его и возвращают RpcException с кодом <see cref="Code"/>
|
||||
/// и detail = сообщению (текст причины 1:1 с прототипом, см. <see cref="SessionErrorMessages"/>).
|
||||
/// Неизвестные/сетевые сбои адаптер WTelegramClient также оборачивает в этот тип (UNAVAILABLE).
|
||||
/// </summary>
|
||||
public sealed class SessionException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт ошибку сессии с gRPC-кодом, в который она должна превратиться на границе.
|
||||
/// </summary>
|
||||
/// <param name="code">gRPC-статус ошибки (контракт telegram.proto, шапка файла).</param>
|
||||
/// <param name="message">Текст причины — detail RPC (1:1 с текстами прототипа).</param>
|
||||
/// <param name="innerException">Внутренняя причина (исключение Telegram/адаптера), если есть.</param>
|
||||
public SessionException(StatusCode code, string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-статус, в который ошибка превращается на границе сервиса.
|
||||
/// </summary>
|
||||
public StatusCode Code { get; }
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Deal.Telegram.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// AES-256-GCM-обёртка файла сессии тенанта (план Task 9; Ruling 3).
|
||||
///
|
||||
/// Дублирование подхода AesGcmSecretCipher этапа 2 в отдельном процессе: сервисы этапа не имеют
|
||||
/// ссылок на core (Ruling — общий только .proto и NuGet), поэтому маленький шифр реализован локально.
|
||||
/// Формат значения тот же, что в core: <c>enc:</c> + Base64(nonce ‖ шифротекст ‖ tag)
|
||||
/// (nonce 12 байт, tag 16 байт, ключ 32 байта из env DEAL_TELEGRAM_SESSION_KEY).
|
||||
/// Экземпляр AesGcm создаётся на операцию — разделяемого криптографического состояния нет.
|
||||
/// </summary>
|
||||
public sealed class SessionFileCipher
|
||||
{
|
||||
/// <summary>
|
||||
/// Префикс зашифрованного значения (маркер формата в файле сессии).
|
||||
/// </summary>
|
||||
public const string EncryptedPrefix = "enc:";
|
||||
|
||||
// Размер nonce AES-GCM (рекомендованный NIST — 96 бит).
|
||||
private const int NonceSizeBytes = 12;
|
||||
|
||||
// Размер тега аутентичности.
|
||||
private const int TagSizeBytes = 16;
|
||||
|
||||
private readonly byte[] _key;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт шифр с ключом из опций (env DEAL_TELEGRAM_SESSION_KEY).
|
||||
/// </summary>
|
||||
/// <param name="options">Опции хранения сессий.</param>
|
||||
public SessionFileCipher(TgOptions options)
|
||||
{
|
||||
_key = options.SessionKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Шифрует содержимое файла сессии: случайный nonce + AES-GCM, возвращает значение
|
||||
/// <c>enc:</c>+Base64(nonce ‖ шифротекст ‖ tag) — готовый текст файла data/sessions/<tenant>.session.
|
||||
/// </summary>
|
||||
/// <param name="plainBytes">Открытое содержимое сессии (расшифрованная копия в памяти процесса).</param>
|
||||
/// <returns>Зашифрованное значение для записи в файл.</returns>
|
||||
public string EncryptBytes(byte[] plainBytes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plainBytes);
|
||||
|
||||
byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
|
||||
byte[] cipherBytes = new byte[plainBytes.Length];
|
||||
byte[] tag = new byte[TagSizeBytes];
|
||||
|
||||
using (AesGcm aesGcm = new AesGcm(_key, TagSizeBytes))
|
||||
{
|
||||
aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
|
||||
}
|
||||
|
||||
byte[] payload = new byte[nonce.Length + cipherBytes.Length + tag.Length];
|
||||
nonce.CopyTo(payload, 0);
|
||||
cipherBytes.CopyTo(payload, nonce.Length);
|
||||
tag.CopyTo(payload, nonce.Length + cipherBytes.Length);
|
||||
|
||||
return EncryptedPrefix + Convert.ToBase64String(payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Расшифровывает значение файла сессии. null — значение не в формате сервиса (не enc: или
|
||||
/// не base64): файл чужой/повреждён и трактуется как отсутствующий. Несовпадение тега/чужой
|
||||
/// ключ — <see cref="CryptographicException"/> (файл повреждён или ключ сменился).
|
||||
/// </summary>
|
||||
/// <param name="encryptedValue">Содержимое файла сессии (enc: + base64).</param>
|
||||
/// <returns>Открытые байты сессии либо null (не наш формат).</returns>
|
||||
/// <exception cref="CryptographicException">Тег аутентичности не совпал (битый файл/чужой ключ).</exception>
|
||||
public byte[]? DecryptBytes(string encryptedValue)
|
||||
{
|
||||
if (string.IsNullOrEmpty(encryptedValue) || !encryptedValue.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = Convert.FromBase64String(encryptedValue[EncryptedPrefix.Length..]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.Length < NonceSizeBytes + TagSizeBytes)
|
||||
{
|
||||
// Слишком короткий payload: nonce и tag в нём не помещаются.
|
||||
return null;
|
||||
}
|
||||
|
||||
int cipherTextLength = payload.Length - NonceSizeBytes - TagSizeBytes;
|
||||
byte[] plainBytes = new byte[cipherTextLength];
|
||||
|
||||
using (AesGcm aesGcm = new AesGcm(_key, TagSizeBytes))
|
||||
{
|
||||
aesGcm.Decrypt(
|
||||
new ReadOnlySpan<byte>(payload, 0, NonceSizeBytes),
|
||||
new ReadOnlySpan<byte>(payload, NonceSizeBytes, cipherTextLength),
|
||||
new ReadOnlySpan<byte>(payload, NonceSizeBytes + cipherTextLength, TagSizeBytes),
|
||||
plainBytes);
|
||||
}
|
||||
|
||||
return plainBytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Telegram.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Файловое хранилище сессий тенантов (план Task 9; Ruling 3) — аналог SessionManager/SessionStore
|
||||
/// задачи: файлы data/sessions/<tenantId>.session, содержимое — AES-GCM-обёртка
|
||||
/// (<see cref="SessionFileCipher"/>) сериализованного <see cref="StoredSession"/>.
|
||||
///
|
||||
/// Запись — атомарная (временный файл в том же каталоге + File.Move), чтобы рестарт/сбой
|
||||
/// не оставил битый файл сессии; все записи сериализованы одним семафором (файлы маленькие,
|
||||
/// запись редкая). Нечитаемый/повреждённый файл трактуется как отсутствие сессии (лог-warning),
|
||||
/// файл не удаляется — диагностика сохраняется.
|
||||
/// </summary>
|
||||
public sealed class SessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Расширение файла сессии (data/sessions/<tenantId>.session).
|
||||
/// </summary>
|
||||
public const string SessionFileExtension = ".session";
|
||||
|
||||
private readonly string _sessionsDirectory;
|
||||
private readonly SessionFileCipher _cipher;
|
||||
private readonly ILogger<SessionStore> _logger;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт хранилище над каталогом из опций.
|
||||
/// </summary>
|
||||
/// <param name="options">Опции хранения сессий (каталог, ключ шифрования).</param>
|
||||
/// <param name="cipher">AES-GCM-обёртка файлов сессий.</param>
|
||||
/// <param name="logger">Логгер.</param>
|
||||
public SessionStore(TgOptions options, SessionFileCipher cipher, ILogger<SessionStore> logger)
|
||||
{
|
||||
_sessionsDirectory = options.SessionsDirectory;
|
||||
_cipher = cipher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Читает и расшифровывает сессию тенанта. Возвращает null, если файла нет или он нечитаем
|
||||
/// (чужой формат/повреждён/другой ключ — лог-warning; файл сохраняется для диагностики).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (принадлежность сессии; Ruling 3 — 1 аккаунт на тенанта).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
public async Task<StoredSession?> LoadAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string filePath = PathFor(tenantId);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string encryptedValue;
|
||||
try
|
||||
{
|
||||
encryptedValue = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(exception, "Сессия {TenantId} не читается — трактуется как отсутствующая", tenantId);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[]? plainBytes = _cipher.DecryptBytes(encryptedValue);
|
||||
if (plainBytes is null)
|
||||
{
|
||||
_logger.LogWarning("Файл сессии {TenantId} не в формате сервиса — трактуется как отсутствующий", tenantId);
|
||||
return null;
|
||||
}
|
||||
|
||||
StoredSession? stored = JsonSerializer.Deserialize<StoredSession>(plainBytes);
|
||||
if (stored is null || stored.FormatVersion != StoredSession.CurrentFormatVersion)
|
||||
{
|
||||
_logger.LogWarning("Файл сессии {TenantId} имеет неизвестную версию формата — трактуется как отсутствующий", tenantId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
catch (CryptographicException exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Сессия {TenantId} не расшифрована (повреждён файл или сменился ключ)", tenantId);
|
||||
return null;
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Содержимое сессии {TenantId} не является корректным JSON", tenantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Шифрует и атомарно сохраняет сессию тенанта (создаёт каталог при первом сохранении).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="session">Открытое содержимое сессии для шифрования at-rest.</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
public async Task SaveAsync(string tenantId, StoredSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string filePath = PathFor(tenantId);
|
||||
byte[] plainBytes = JsonSerializer.SerializeToUtf8Bytes(session);
|
||||
string encryptedValue = _cipher.EncryptBytes(plainBytes);
|
||||
|
||||
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_sessionsDirectory);
|
||||
|
||||
string tempPath = filePath + ".tmp";
|
||||
await File.WriteAllTextAsync(tempPath, encryptedValue, cancellationToken).ConfigureAwait(false);
|
||||
File.Move(tempPath, filePath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет файл сессии тенанта (Logout). Отсутствие файла не считается ошибкой.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта.</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
public Task DeleteAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string filePath = PathFor(tenantId);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Перечисляет id тенантов, для которых на диске есть файл сессии (auto_resume на старте).
|
||||
/// Каталога нет — пустой список (каталог создаётся лениво, при первом сохранении).
|
||||
/// </summary>
|
||||
public IEnumerable<string> ListTenantIds()
|
||||
{
|
||||
if (!Directory.Exists(_sessionsDirectory))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return Directory
|
||||
.EnumerateFiles(_sessionsDirectory, "*" + SessionFileExtension)
|
||||
.Select(Path.GetFileNameWithoutExtension)
|
||||
.Where(fileName => fileName is not null)
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// Путь файла сессии тенанта (валидация tenant-id: только безопасное имя файла).
|
||||
// tenantId: Id тенанта.
|
||||
// Исключение SessionException: Tenant-id пуст или содержит недопустимые для имени файла символы.
|
||||
private string PathFor(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || tenantId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
{
|
||||
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidTenantId);
|
||||
}
|
||||
|
||||
return Path.Combine(_sessionsDirectory, tenantId + SessionFileExtension);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Deal.Telegram.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Открытое содержимое файла сессии тенанта (план Task 9; Ruling 3).
|
||||
///
|
||||
/// Файл data/sessions/<tenant>.session хранит AES-GCM-обёртку (SessionFileCipher) сериализованного
|
||||
/// <see cref="StoredSession"/>. Вместе с байтами сессии WTelegramClient сохраняются api_id/api_hash
|
||||
/// приложения, под которыми сессия создана: ядро передаёт ключи в теле StartQr/StartPhone (Ruling 3),
|
||||
/// а при auto_resume после рестарта сервиса других источников ключей нет — они восстанавливаются из
|
||||
/// зашифрованного файла (иначе «авторизованная сессия → ready» на старте невозможна).
|
||||
/// </summary>
|
||||
public sealed record StoredSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Версия формата файла (для будущих изменений контейнера).
|
||||
/// </summary>
|
||||
public const int CurrentFormatVersion = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Версия формата; при несовпадении файл считается нечитаемым.
|
||||
/// </summary>
|
||||
public int FormatVersion { get; init; } = CurrentFormatVersion;
|
||||
|
||||
/// <summary>
|
||||
/// api_id приложения Telegram, под которым авторизована сессия.
|
||||
/// </summary>
|
||||
public int ApiId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// api_hash приложения Telegram, под которым авторизована сессия.
|
||||
/// </summary>
|
||||
public string ApiHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Байты файла сессии WTelegramClient (внутренне уже зашифрованы библиотекой).
|
||||
/// </summary>
|
||||
public byte[] SessionBytes { get; init; } = [];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
namespace Deal.Telegram.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Снимок состояния сессии тенанта для GetStatus/ответов RPC подключения (план Task 9).
|
||||
/// Live-поля GetStatusReply: phase/connected/listener/account/error/qr_url (статус прототипа L110–118);
|
||||
/// qrUrl заполнен только при phase == Qr (как в прототипе L118), monitored/keysSet ядро считает само.
|
||||
/// </summary>
|
||||
public sealed record TenantSessionSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт снимок состояния сессии.
|
||||
/// </summary>
|
||||
/// <param name="phase">Текущая фаза входа.</param>
|
||||
/// <param name="connected">Клиент Telegram соединён.</param>
|
||||
/// <param name="listener">Жив ли realtime-listener (включается задачами каталога, Task 10).</param>
|
||||
/// <param name="account">Аккаунт "@username" авторизованного пользователя (иначе null).</param>
|
||||
/// <param name="error">Текст последней ошибки (иначе null).</param>
|
||||
/// <param name="qrUrl">URL QR-входа (только при phase == Qr; иначе null).</param>
|
||||
public TenantSessionSnapshot(
|
||||
AuthPhase phase,
|
||||
bool connected,
|
||||
bool listener,
|
||||
string? account,
|
||||
string? error,
|
||||
string? qrUrl)
|
||||
{
|
||||
Phase = phase;
|
||||
Connected = connected;
|
||||
Listener = listener;
|
||||
Account = account;
|
||||
Error = error;
|
||||
QrUrl = qrUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Фаза входа (idle|phone|code|password|qr|ready).
|
||||
/// </summary>
|
||||
public AuthPhase Phase { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Клиент Telegram соединён (bool connected статуса прототипа).
|
||||
/// </summary>
|
||||
public bool Connected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Realtime-listener жив (заполняется с Task 10; в задаче сессий — false).
|
||||
/// </summary>
|
||||
public bool Listener { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Аккаунт "@username" (для справки; источник истины — KV tgAccount ядра).
|
||||
/// </summary>
|
||||
public string? Account { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Текст последней ошибки входа/соединения (null — ошибки нет).
|
||||
/// </summary>
|
||||
public string? Error { get; }
|
||||
|
||||
/// <summary>
|
||||
/// URL QR-входа (заполнен только при phase == Qr).
|
||||
/// </summary>
|
||||
public string? QrUrl { get; }
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
namespace Deal.Telegram.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Конфигурация хранения сессий telegram-service (план Task 9 L316–318; Ruling 3/12/13).
|
||||
///
|
||||
/// Два источника — только env и корень хоста:
|
||||
/// * DEAL_TELEGRAM_SESSION_KEY — ключ AES-256-GCM обёртки файлов сессий (32 байта, base64);
|
||||
/// * DEAL_TELEGRAM_SESSION_DIR — каталог файлов сессий (volume /data/sessions в compose,
|
||||
/// Ruling 12); по умолчанию data/sessions относительно ContentRoot.
|
||||
/// Ключ — только env (Ruling 13: ключи/секреты не логируются и не читаются из appsettings).
|
||||
/// </summary>
|
||||
public sealed class TgOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ ключа шифрования сессий (32 байта base64; Ruling 3).
|
||||
/// </summary>
|
||||
public const string SessionKeyEnvVarName = "DEAL_TELEGRAM_SESSION_KEY";
|
||||
|
||||
/// <summary>
|
||||
/// Env-ключ каталога сессий (опционально; по умолчанию data/sessions под ContentRoot).
|
||||
/// </summary>
|
||||
public const string SessionDirEnvVarName = "DEAL_TELEGRAM_SESSION_DIR";
|
||||
|
||||
/// <summary>
|
||||
/// Относительный каталог сессий по умолчанию (под ContentRoot хоста).
|
||||
/// </summary>
|
||||
public const string DefaultSessionDirRelative = "data/sessions";
|
||||
|
||||
/// <summary>
|
||||
/// Размер ключа AES-256 (байт).
|
||||
/// </summary>
|
||||
public const int KeySizeBytes = 32;
|
||||
|
||||
private TgOptions(byte[] sessionKey, string sessionsDirectory)
|
||||
{
|
||||
SessionKey = sessionKey;
|
||||
SessionsDirectory = sessionsDirectory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ключ AES-256-GCM обёртки файлов сессий (из env DEAL_TELEGRAM_SESSION_KEY).
|
||||
/// </summary>
|
||||
public byte[] SessionKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Абсолютный путь к каталогу файлов сессий data/sessions/<tenant>.session.
|
||||
/// </summary>
|
||||
public string SessionsDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт опции с уже известными ключом и каталогом (unit-тесты хранилища; прод-путь —
|
||||
/// <see cref="FromConfiguration"/>). Ключ обязан быть 32 байтами AES-256.
|
||||
/// </summary>
|
||||
/// <param name="sessionKey">Ключ AES-256-GCM обёртки файлов сессий.</param>
|
||||
/// <param name="sessionsDirectory">Каталог файлов сессий.</param>
|
||||
public static TgOptions Create(byte[] sessionKey, string sessionsDirectory)
|
||||
{
|
||||
if (sessionKey is null || sessionKey.Length != KeySizeBytes)
|
||||
{
|
||||
throw new ArgumentException($"Ключ AES-256 должен быть длиной {KeySizeBytes} байта; получено {sessionKey?.Length ?? 0}.", nameof(sessionKey));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sessionsDirectory))
|
||||
{
|
||||
throw new ArgumentException("Каталог сессий не задан.", nameof(sessionsDirectory));
|
||||
}
|
||||
|
||||
return new TgOptions(sessionKey, sessionsDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Читает конфигурацию из env. Отсутствующий/некорректный DEAL_TELEGRAM_SESSION_KEY —
|
||||
/// ошибка конфигурации (fail-closed: файлы сессий не могут храниться в открытом виде).
|
||||
/// </summary>
|
||||
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
|
||||
/// <param name="environment">Окружение хоста (ContentRootPath для каталога по умолчанию).</param>
|
||||
/// <returns>Опции хранения сессий.</returns>
|
||||
/// <exception cref="InvalidOperationException">Ключ отсутствует или не является base64-представлением 32 байт.</exception>
|
||||
public static TgOptions FromConfiguration(IConfiguration configuration, IHostEnvironment environment)
|
||||
{
|
||||
string? keyBase64 = configuration[SessionKeyEnvVarName];
|
||||
if (string.IsNullOrWhiteSpace(keyBase64))
|
||||
{
|
||||
throw new InvalidOperationException($"{SessionKeyEnvVarName} не задан — без ключа сессии нельзя шифровать (fail-closed).");
|
||||
}
|
||||
|
||||
byte[] sessionKey;
|
||||
try
|
||||
{
|
||||
sessionKey = Convert.FromBase64String(keyBase64);
|
||||
}
|
||||
catch (FormatException exception)
|
||||
{
|
||||
throw new InvalidOperationException($"{SessionKeyEnvVarName} не является корректным base64.", exception);
|
||||
}
|
||||
|
||||
if (sessionKey.Length != KeySizeBytes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{SessionKeyEnvVarName} должен быть base64-представлением {KeySizeBytes} байт (AES-256); получено {sessionKey.Length}.");
|
||||
}
|
||||
|
||||
string? configuredDir = configuration[SessionDirEnvVarName];
|
||||
string sessionsDirectory = ResolveSessionsDirectory(configuredDir, environment.ContentRootPath);
|
||||
return new TgOptions(sessionKey, sessionsDirectory);
|
||||
}
|
||||
|
||||
// Разрешает каталог сессий: абсолютный env-путь как есть, иначе — под ContentRoot.
|
||||
// configuredDir: Значение DEAL_TELEGRAM_SESSION_DIR (может быть пустым).
|
||||
// contentRootPath: ContentRoot хоста.
|
||||
private static string ResolveSessionsDirectory(string? configuredDir, string contentRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredDir))
|
||||
{
|
||||
return Path.Combine(contentRootPath, DefaultSessionDirRelative);
|
||||
}
|
||||
|
||||
string trimmed = configuredDir.Trim();
|
||||
return Path.IsPathRooted(trimmed)
|
||||
? trimmed
|
||||
: Path.Combine(contentRootPath, trimmed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Deal.Telegram.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Фабрика реальных клиентов WTelegramClient (план Task 9; Ruling 3).
|
||||
///
|
||||
/// Каждый вызов создаёт изолированный клиент для сессии тенанта: api_id/api_hash — из запроса
|
||||
/// (их передаёт ядро из настроек tgKeys, Ruling 3), байты сессии — расшифрованная копия файла
|
||||
/// data/sessions/<tenant>.session. Анти-бан-паузы между сетевыми операциями (Ruling 3:
|
||||
/// backfill 1.5–3 с/сообщение, 3–6 с/диалог, поиск 2–4 с) добавляются на операции задач каталога
|
||||
/// (Task 10–11), вход/QR пауз не требуют.
|
||||
/// </summary>
|
||||
public sealed class ClientFactory : ITelegramClientFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ISessionClient Create(int apiId, string apiHash, byte[]? storedSession)
|
||||
=> new WTelegramSessionClient(apiId, apiHash, storedSession);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Deal.Telegram.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Канон типов диалогов/источников контракта (шапка src/contracts/telegram.proto: DialogEntry.kind,
|
||||
/// ChannelInfo.kind — channel|group|forum|chat). Значения строковые 1:1 с proto; python-прототип хранит
|
||||
/// русские «канал»/«группа»/«чат», на границе контракта используется EN-канон (task-1-report).
|
||||
/// </summary>
|
||||
public static class DialogKinds
|
||||
{
|
||||
/// <summary>
|
||||
/// Канал (broadcast): kind "channel".
|
||||
/// </summary>
|
||||
public const string Channel = "channel";
|
||||
|
||||
/// <summary>
|
||||
/// Группа (базовая или супергруппа без тем): kind "group".
|
||||
/// </summary>
|
||||
public const string Group = "group";
|
||||
|
||||
/// <summary>
|
||||
/// Супергруппа с темами (форум): kind "forum".
|
||||
/// </summary>
|
||||
public const string Forum = "forum";
|
||||
|
||||
/// <summary>
|
||||
/// Личный чат (пользователь): kind "chat".
|
||||
/// </summary>
|
||||
public const string Chat = "chat";
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace Deal.Telegram.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение выборки discovery-read для оценки кандидата в нейтральном для TL-слоя виде (план
|
||||
/// Task 11; 1:1 _discovery_message_item python-прототипа telegram.py L803–816).
|
||||
///
|
||||
/// В отличие от <see cref="TelegramMessage"/> (поток каталога) здесь не нужны канальные поля — выборка
|
||||
/// идёт «внутри» уже известного диалога, а темы форума помечаются topic_id/topic_title (для обычных
|
||||
/// источников оба пусты). Только непустые тексты: пустые/media/service отбрасывает TL-слой, как python.
|
||||
/// </summary>
|
||||
public sealed record DiscoveryMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт сообщение выборки discovery-read.
|
||||
/// </summary>
|
||||
/// <param name="id">Id сообщения в Telegram.</param>
|
||||
/// <param name="text">Текст сообщения (непустой).</param>
|
||||
/// <param name="dateMs">Время сообщения, epoch-ms.</param>
|
||||
/// <param name="topicId">Id темы форума (для обычных источников пуст).</param>
|
||||
/// <param name="topicTitle">Название темы форума (для обычных источников пусто).</param>
|
||||
public DiscoveryMessage(long id, string text, long dateMs, long? topicId, string? topicTitle)
|
||||
{
|
||||
Id = id;
|
||||
Text = text;
|
||||
DateMs = dateMs;
|
||||
TopicId = topicId;
|
||||
TopicTitle = topicTitle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Id сообщения в Telegram.
|
||||
/// </summary>
|
||||
public long Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения (непустой).
|
||||
/// </summary>
|
||||
public string Text { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Время сообщения, epoch-ms.
|
||||
/// </summary>
|
||||
public long DateMs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Id темы форума (для обычных источников пуст).
|
||||
/// </summary>
|
||||
public long? TopicId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Название темы форума (для обычных источников пусто).
|
||||
/// </summary>
|
||||
public string? TopicTitle { get; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace Deal.Telegram.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Результат чтения выборки источника для оценки кандидата (план Task 11; 1:1 discovery_read
|
||||
/// python-прототипа telegram.py L718–760: {ok, error, messages}).
|
||||
///
|
||||
/// ok=false — история недоступна (приватный/закрытый источник без членства), error="no_history";
|
||||
/// это НЕ ошибка сессии/RPC, а нормальный ответ контракта (ReadForEvalReply.ok=false).
|
||||
/// </summary>
|
||||
public sealed record DiscoveryReadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Код причины ok=false: история недоступна без членства (1:1 прототип L726).
|
||||
/// </summary>
|
||||
public const string NoHistoryError = "no_history";
|
||||
|
||||
/// <summary>
|
||||
/// Пустой успешный результат (limit ≤ 0 — выборка не запрашивалась, прототип L730–732).
|
||||
/// </summary>
|
||||
public static DiscoveryReadResult Empty { get; } = new(true, null, []);
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт результат чтения выборки.
|
||||
/// </summary>
|
||||
/// <param name="ok">True — выборка получена; false — история недоступна.</param>
|
||||
/// <param name="error">Код причины при ok=false ("no_history"); иначе null.</param>
|
||||
/// <param name="messages">Сообщения выборки (форумы — по темам, topic_id/topic_title заполнены).</param>
|
||||
public DiscoveryReadResult(bool ok, string? error, IReadOnlyList<DiscoveryMessage> messages)
|
||||
{
|
||||
Ok = ok;
|
||||
Error = error;
|
||||
Messages = messages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт результат «история недоступна» (ok=false, error=no_history, сообщений нет).
|
||||
/// </summary>
|
||||
public static DiscoveryReadResult NoHistory()
|
||||
=> new(false, NoHistoryError, []);
|
||||
|
||||
/// <summary>
|
||||
/// True — выборка получена; false — история недоступна без членства.
|
||||
/// </summary>
|
||||
public bool Ok { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Код причины при ok=false: "no_history"; иначе null.
|
||||
/// </summary>
|
||||
public string? Error { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Сообщения выборки (для обычных источников topic_id/topic_title пусты).
|
||||
/// </summary>
|
||||
public IReadOnlyList<DiscoveryMessage> Messages { get; }
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
namespace Deal.Telegram.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Абстракция клиента Telegram для сессии тенанта (план Task 9: «абстракция ISessionClient»; план
|
||||
/// Task 10: операции каталога/мониторинга на том же seam).
|
||||
///
|
||||
/// Это seam между логикой фаз/состояния (TenantSession, SessionFarm) и WTelegramClient:
|
||||
/// реальная реализация (WTelegramSessionClient) говорит с сетью Telegram, фейки в тестах —
|
||||
/// нет. Контракт повторяет шаги веб-входа прототипа (start_phone/submit_code/submit_password/
|
||||
/// qr_start L134–312): код запрашивается по номеру, код/пароль отправляются отдельными вызовами,
|
||||
/// QR-вход выполняется в фоне до авторизации с обновлением URL через колбэк.
|
||||
/// Сетевые ошибки и ошибки домена реализация переводит в <see cref="Sessions.SessionException"/>.
|
||||
/// </summary>
|
||||
public interface ISessionClient : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Авторизован ли клиент (в сессии есть пользователь Telegram).
|
||||
/// </summary>
|
||||
public bool IsAuthorized { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Есть ли активное соединение с Telegram.
|
||||
/// </summary>
|
||||
public bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// api_id приложения, под которым создан клиент (для сохранения в файл сессии).
|
||||
/// </summary>
|
||||
public int ApiId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// api_hash приложения, под которым создан клиент.
|
||||
/// </summary>
|
||||
public string ApiHash { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Последние байты сессии WTelegramClient (обновляются библиотекой в момент сохранения сессии).
|
||||
/// Хранилище шифрует их в файл data/sessions/<tenant>.session (Ruling 3).
|
||||
/// </summary>
|
||||
public byte[]? SessionBytes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает соединение с Telegram (идемпотентно для уже соединённого клиента).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
public Task ConnectAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Запрашивает SMS-код для номера (start_phone L134–147). После успеха клиент готов принять код.
|
||||
/// Ошибки: нет соединения/недоступен Telegram, некорректный номер.
|
||||
/// </summary>
|
||||
/// <param name="phone">Номер в международном формате (как ввёл пользователь).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
public Task RequestCodeAsync(string phone, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Отправляет SMS-код (submit_code L149–166). Возвращает следующий запрашиваемый шаг:
|
||||
/// "password" — включён 2FA, нужен облачный пароль; null — авторизация завершена (готово).
|
||||
/// Ошибки: «Неверный код», «Код истёк — запросите новый» (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
/// <param name="code">Код из SMS/Telegram-сообщения.</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>"password" при необходимости 2FA, иначе null.</returns>
|
||||
public Task<string?> SubmitCodeAsync(string code, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Отправляет облачный пароль 2FA (submit_password L168–176). Возвращается после успешной
|
||||
/// авторизации. Ошибка: «Неверный облачный пароль» (INVALID_ARGUMENT).
|
||||
/// </summary>
|
||||
/// <param name="password">Облачный пароль.</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет QR-вход (qr_start L286–300): метод возвращается после авторизации; новые URL
|
||||
/// (в т.ч. после истечения токена) приходят через <paramref name="onQrUrl"/> до завершения.
|
||||
/// Отмена токена прерывает ожидание сканирования.
|
||||
/// </summary>
|
||||
/// <param name="onQrUrl">Колбэк нового URL QR-входа (tg://login?token=...).</param>
|
||||
/// <param name="cancellationToken">Отмена операции (прерывает ожидание сканирования).</param>
|
||||
public Task StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Полный выход: отзывает авторизацию на стороне Telegram (Auth_LogOut).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
public Task LogOutAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает строку аккаунта для статуса: "@username" авторизованного пользователя либо
|
||||
/// "@user", если username не задан (1:1 _finalize L180: f"@{me.username or 'user'}").
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
public Task<string> GetAccountAsync(CancellationToken cancellationToken);
|
||||
|
||||
// --- Каталог и сообщения (план Task 10; Ruling 3/7; нейтральные типы Telegram/*) ---
|
||||
|
||||
/// <summary>
|
||||
/// Список диалогов аккаунта (refresh_dialogs L505–519 / iter_dialogs). Возвращает диалоги от
|
||||
/// свежих к старым (как список Telegram), верхняя граница <paramref name="limit"/>.
|
||||
/// Ошибки сети/Telegram — <see cref="Sessions.SessionException"/>.
|
||||
/// </summary>
|
||||
/// <param name="limit">Верхняя граница числа диалогов (прототип: 500).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Диалоги аккаунта в нейтральном виде.</returns>
|
||||
public Task<IReadOnlyList<TelegramDialog>> GetDialogsAsync(int limit, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Последние сообщения диалога (get_messages прототипа L371/L435/L590): от новых к старым,
|
||||
/// только непустые тексты (пустые/media/service отбрасывает реализация — как python).
|
||||
/// Ошибки сети/неизвестный источник — <see cref="Sessions.SessionException"/>.
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id диалога (каналы "-100…", группы "-…", личные "+…").</param>
|
||||
/// <param name="limit">Сколько последних сообщений запросить.</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Сообщения диалога (с канальными полями для PushMessage).</returns>
|
||||
public Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(string dialogId, int limit, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Помечает весь диалог прочитанным (send_read_acknowledge прототипа L277/L383/L454/L609).
|
||||
/// Ошибки сети/неизвестный источник — <see cref="Sessions.SessionException"/>.
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id диалога.</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Задача завершения.</returns>
|
||||
public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Событие входящего текстового сообщения аккаунта (realtime; events.NewMessage прототипа
|
||||
/// L255–283). Реализация поднимает событие для всех входящих сообщений с непустым текстом;
|
||||
/// фильтр по зеркалу мониторинга делает служба каталога (Ruling 7). Подписчики исполняются
|
||||
/// последовательно; исключение подписчика не роняет остальных и realtime-цикл.
|
||||
/// </summary>
|
||||
public event Func<TelegramMessage, Task>? MessageReceived;
|
||||
|
||||
// --- Discovery (план Task 11; discovery_search/info/read/join/leave L622–873; Ruling 3/7) ---
|
||||
|
||||
/// <summary>
|
||||
/// Глобальный поиск каналов/групп по ключу (contacts.search прототипа L624–664). Возвращает
|
||||
/// сущности результата (чаты и пользователи) нейтральными записями от chats к users; id подписанные.
|
||||
/// Личные чаты/ботов (kind=chat) отсеивает ядро (Ruling 10). Пауза анти-бана 2–4 с после поиска —
|
||||
/// уровень службы (DiscoveryOps), не клиента. Ошибки — <see cref="Sessions.SessionException"/>.
|
||||
/// </summary>
|
||||
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
|
||||
/// <param name="limit">Верхняя граница результата (прототип: default 30).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Найденные источники в нейтральном виде (каналы/группы/личные).</returns>
|
||||
public Task<IReadOnlyList<TelegramDialog>> SearchAsync(string query, int limit, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Инфо об источнике для оценки кандидата (discovery_info L666–716): имя/username/kind + участники
|
||||
/// из полного чата (GetFullChannel/GetFullChat) и признак форума. Сбои определения не бросаются —
|
||||
/// возвращается <see cref="TelegramSourceInfo"/> с тем, что удалось получить (прототип наружу
|
||||
/// исключения не выпускает: participants пуст, недоступная сущность — поля по умолчанию).
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id источника («-100…»/«-…»/«+…»).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Инфо об источнике (по умолчанию — только id и name=id).</returns>
|
||||
public Task<TelegramSourceInfo> GetInfoAsync(string dialogId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Последние сообщения источника для оценки кандидата (discovery_read L718–800): форумы читаются
|
||||
/// по активным темам (GetForumTopics + по каждой теме getReplies), обычные источники — лентой;
|
||||
/// темы помечаются topic_id/topic_title. История недоступна (приватный/закрытый источник) —
|
||||
/// результат ok=false/error="no_history" (это НЕ ошибка сессии, прототип L752–753). Только непустые
|
||||
/// тексты. Ошибка тем форума — безопасный фолбэк на обычную ленту (прототип L743–748).
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <param name="limit">Размер выборки (прототип: limit сообщений/тем; limit ≤ 0 — пусто без сети).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Результат чтения выборки (ok + сообщения либо no_history).</returns>
|
||||
public Task<DiscoveryReadResult> ReadForEvalAsync(string dialogId, int limit, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Вступить в канал/группу по username (discovery_join L818–839; ручной join вне квот — паузу перед
|
||||
/// авто-join делает воркер ядра, Ruling 10). Username нормализует уровень службы (DiscoveryOps).
|
||||
/// FloodWait Telegram → SessionException RESOURCE_EXHAUSTED (detail с префиксом "flood", контракт
|
||||
/// telegram.proto; базовый флуд-гард — здесь, суточный стоп — в ядре).
|
||||
/// </summary>
|
||||
/// <param name="username">Username канала/группы (без «@»).</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Задача завершения (ok=true при успехе).</returns>
|
||||
public Task JoinAsync(string username, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Выйти из канала/группы (discovery_leave L841–848). Неизвестный/недоступный источник —
|
||||
/// SessionException (уровень контракта Leave: нет диалога/членства).
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id диалога.</param>
|
||||
/// <param name="cancellationToken">Отмена операции.</param>
|
||||
/// <returns>Задача завершения (ok=true при успехе).</returns>
|
||||
public Task LeaveAsync(string dialogId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Deal.Telegram.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Фабрика клиентов Telegram для сессий тенантов (план Task 9, Telegram/ClientFactory.cs).
|
||||
///
|
||||
/// Создаёт <see cref="ISessionClient"/> для сессии тенанта по ключам приложения и байтам сохранённой
|
||||
/// сессии (или пустой — новый вход). Интерфейс — seam для тестов: тесты регистрируют фейковую
|
||||
/// фабрику, реальный <see cref="ClientFactory"/> сеть Telegram не трогает до первого вызова.
|
||||
/// </summary>
|
||||
public interface ITelegramClientFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт клиент Telegram для сессии тенанта.
|
||||
/// </summary>
|
||||
/// <param name="apiId">api_id приложения Telegram (настройка tgKeys тенанта, из тела запроса).</param>
|
||||
/// <param name="apiHash">api_hash приложения Telegram.</param>
|
||||
/// <param name="storedSession">
|
||||
/// Байты сохранённой сессии WTelegramClient (расшифрованная копия файла data/sessions/<tenant>.session)
|
||||
/// либо null — новая сессия (нет файла или ключи приложения сменились).
|
||||
/// </param>
|
||||
/// <returns>Клиент, готовый к ConnectAsync/логину.</returns>
|
||||
public ISessionClient Create(int apiId, string apiHash, byte[]? storedSession);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace Deal.Telegram.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Диалог (источник) аккаунта в нейтральном для TL-слоя виде (план Task 10; Ruling 3/7).
|
||||
///
|
||||
/// Это результат «списка диалогов» сессии (refresh_dialogs прототипа L505–519): id в подписанном
|
||||
/// каноне контракта («-100…» каналы, «-…» группы, «+…» личные), отображаемое имя, username, тип
|
||||
/// канона channel|group|forum|chat и счётчики для догонялки непрочитанных (realtime_sweep L427–456).
|
||||
/// TL-реализация (<see cref="WTelegramSessionClient"/>) и фейки тестов возвращают именно этот тип;
|
||||
/// службы каталога не зависят от библиотеки WTelegramClient.
|
||||
/// </summary>
|
||||
public sealed record TelegramDialog
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт описание диалога.
|
||||
/// </summary>
|
||||
/// <param name="id">Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).</param>
|
||||
/// <param name="name">Отображаемое имя (title/first_name) или id, если имени нет.</param>
|
||||
/// <param name="username">Username (handle); пуст, если публичного username нет.</param>
|
||||
/// <param name="kind">Тип канона: channel|group|forum|chat (шапка telegram.proto).</param>
|
||||
/// <param name="unreadCount">Число непрочитанных сообщений (для realtime_sweep).</param>
|
||||
/// <param name="topMessageId">Id самого свежего сообщения диалога (для read-ack).</param>
|
||||
public TelegramDialog(string id, string name, string username, string kind, int unreadCount, int topMessageId)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Username = username;
|
||||
Kind = kind;
|
||||
UnreadCount = unreadCount;
|
||||
TopMessageId = topMessageId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Отображаемое имя (title/first_name) или id, если имени нет.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Username (handle); пуст, если публичного username нет.
|
||||
/// </summary>
|
||||
public string Username { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип канона контракта: channel|group|forum|chat.
|
||||
/// </summary>
|
||||
public string Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Число непрочитанных сообщений диалога (для догона realtime_sweep).
|
||||
/// </summary>
|
||||
public int UnreadCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Id самого свежего сообщения диалога.
|
||||
/// </summary>
|
||||
public int TopMessageId { get; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace Deal.Telegram.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Текстовое сообщение диалога в нейтральном для TL-слоя виде (план Task 10; Ruling 3/7).
|
||||
///
|
||||
/// Используется тремя путями сообщений: backfill («Перечитать» L349–390), realtime-listener
|
||||
/// (L255–283) и realtime_sweep (L392–456). Пустые тексты и служебные сообщения (media/service)
|
||||
/// TL-слой не отдаёт — только непустой текст. Канальные поля (имя/username) нужны для PushMessage
|
||||
/// в ядро (PushMessageRequest.channel_name/channel_handle, Ruling 7): hue считает служба каталога.
|
||||
/// </summary>
|
||||
public sealed record TelegramMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт сообщение диалога.
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id диалога-источника («-100…»/«-…»/«+…»).</param>
|
||||
/// <param name="id">Id сообщения в Telegram (дубль-гвард dialog+msgId ядра).</param>
|
||||
/// <param name="text">Текст сообщения (непустой).</param>
|
||||
/// <param name="dateMs">Время сообщения, epoch-ms.</param>
|
||||
/// <param name="dialogName">Имя диалога (title/first_name) для PushMessage.channel_name.</param>
|
||||
/// <param name="dialogHandle">Username диалога для PushMessage.channel_handle.</param>
|
||||
public TelegramMessage(string dialogId, int id, string text, long dateMs, string dialogName, string dialogHandle)
|
||||
{
|
||||
DialogId = dialogId;
|
||||
Id = id;
|
||||
Text = text;
|
||||
DateMs = dateMs;
|
||||
DialogName = dialogName;
|
||||
DialogHandle = dialogHandle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подписанный id диалога-источника.
|
||||
/// </summary>
|
||||
public string DialogId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Id сообщения в Telegram (дубль-гвард диалога ядра).
|
||||
/// </summary>
|
||||
public int Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения (непустой).
|
||||
/// </summary>
|
||||
public string Text { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Время сообщения, epoch-ms.
|
||||
/// </summary>
|
||||
public long DateMs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Имя диалога (title/first_name) — для PushMessage.channel_name.
|
||||
/// </summary>
|
||||
public string DialogName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Username диалога (пуст, если нет) — для PushMessage.channel_handle.
|
||||
/// </summary>
|
||||
public string DialogHandle { get; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace Deal.Telegram.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Инфо об источнике для оценки кандидата discovery в нейтральном для TL-слоя виде (план Task 11;
|
||||
/// 1:1 результат discovery_info python-прототипа telegram.py L666–716).
|
||||
///
|
||||
/// Поля повторяют словарь прототипа {id, name, username, kind, participants, is_forum}; hue прототип
|
||||
/// не хранит — его считает маппер ответа (Ruling 7: цвет считает сервис). kind — EN-канон контракта
|
||||
/// channel|group|forum|chat; пустая строка — тип определить не удалось (прототип L678: kind "").
|
||||
/// </summary>
|
||||
public sealed record TelegramSourceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт инфо об источнике.
|
||||
/// </summary>
|
||||
/// <param name="id">Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).</param>
|
||||
/// <param name="name">Отображаемое имя (title/first_name) или id, если имени нет.</param>
|
||||
/// <param name="username">Username (handle); пуст, если публичного username нет.</param>
|
||||
/// <param name="kind">Тип канона контракта: channel|group|forum|chat (пусто — не определён).</param>
|
||||
/// <param name="participants">Число участников (full_chat); null — определить не удалось.</param>
|
||||
/// <param name="isForum">True — мегагруппа с темами (форум; core трактует kind как forum).</param>
|
||||
public TelegramSourceInfo(string id, string name, string username, string kind, int? participants, bool isForum)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Username = username;
|
||||
Kind = kind;
|
||||
Participants = participants;
|
||||
IsForum = isForum;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Отображаемое имя (title/first_name) или id, если имени нет.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Username (handle); пуст, если публичного username нет.
|
||||
/// </summary>
|
||||
public string Username { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип канона контракта: channel|group|forum|chat (пусто — не определён).
|
||||
/// </summary>
|
||||
public string Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Число участников (full_chat); null — определить не удалось.
|
||||
/// </summary>
|
||||
public int? Participants { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True — мегагруппа с темами (форум; core трактует kind как forum, Ruling 10).
|
||||
/// </summary>
|
||||
public bool IsForum { get; }
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Globalization;
|
||||
using Deal.Telegram.Sessions;
|
||||
using Grpc.Core;
|
||||
using TL;
|
||||
|
||||
namespace Deal.Telegram.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Чистый маппер TL-объектов каталога/сообщений в нейтральные типы сервиса (план Task 10; Ruling 3/7).
|
||||
///
|
||||
/// Статический и без зависимостей от клиента — единое место разбора, используемое WTelegramSessionClient
|
||||
/// (список диалогов, история, realtime-события) и unit-тестами на фейковых TL-объектах (без сети):
|
||||
/// ветки типов обновлений, подписанные id, фильтр пустых/служебных/исходящих текстов.
|
||||
/// </summary>
|
||||
public static class TlMessageMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Извлекает сообщение из «нового сообщения» обновления. Канон обновлений библиотеки:
|
||||
/// обычные чаты — UpdateNewMessage; каналы/супергруппы — UpdateNewChannelMessage (подкласс
|
||||
/// UpdateNewMessage); короткие UpdateShortMessage/UpdateShortChatMessage синтезируются в
|
||||
/// UpdateNewMessage списком UpdateList; собственные исходящие (UpdateShortSentMessage) менеджер
|
||||
/// обновлений не поднимает. Прочие обновления (edit/delete/…) → null (не «новое сообщение»).
|
||||
/// </summary>
|
||||
/// <param name="update">Одно нормализованное обновление.</param>
|
||||
/// <returns>Сообщение нового входящего события или null.</returns>
|
||||
public static MessageBase? NewMessageFrom(Update update)
|
||||
=> update is UpdateNewMessage { message: MessageBase message } ? message : null;
|
||||
|
||||
/// <summary>
|
||||
/// Превращает диалог списка в нейтральный <see cref="TelegramDialog"/> (null — нет сущности).
|
||||
/// </summary>
|
||||
/// <param name="dialog">Диалог из ответа getDialogs.</param>
|
||||
/// <param name="chats">Сущности чатов контейнера (по raw id).</param>
|
||||
/// <param name="users">Сущности пользователей контейнера (по raw id).</param>
|
||||
public static TelegramDialog? ToDialog(DialogBase dialog, IReadOnlyDictionary<long, ChatBase> chats, IReadOnlyDictionary<long, User> users)
|
||||
{
|
||||
if (dialog.Peer is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string signedId = SignedIdOf(dialog.Peer);
|
||||
(string name, string handle, string kind) = DescribePeer(signedId, FindChat(dialog.Peer, chats), FindUser(dialog.Peer, users));
|
||||
int unreadCount = dialog is Dialog fullDialog ? fullDialog.unread_count : 0;
|
||||
return new TelegramDialog(signedId, name, handle, kind, unreadCount, dialog.TopMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Превращает сообщение истории/обновления в нейтральное (null — не текст/служебное/своё исходящее).
|
||||
/// Пустые тексты и media/service (MessageService/MessageEmpty) отбрасываются — как python
|
||||
/// `if not m.text or not m.text.strip(): continue`; исходящие (out_) тоже (incoming-семантика).
|
||||
/// </summary>
|
||||
/// <param name="message">Сообщение (MessageBase).</param>
|
||||
/// <param name="chats">Сущности чатов контейнера.</param>
|
||||
/// <param name="users">Сущности пользователей контейнера.</param>
|
||||
public static TelegramMessage? ToMessage(MessageBase message, IReadOnlyDictionary<long, ChatBase> chats, IReadOnlyDictionary<long, User> users)
|
||||
{
|
||||
if (message is not Message textMessage
|
||||
|| string.IsNullOrWhiteSpace(textMessage.message)
|
||||
|| (textMessage.flags & Message.Flags.out_) != 0
|
||||
|| textMessage.Peer is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string signedId = SignedIdOf(textMessage.Peer);
|
||||
(string name, string handle, _) = DescribePeer(signedId, FindChat(textMessage.Peer, chats), FindUser(textMessage.Peer, users));
|
||||
return new TelegramMessage(signedId, textMessage.id, textMessage.message, ToEpochMs(textMessage.Date), name, handle);
|
||||
}
|
||||
|
||||
// --- Discovery (план Task 11; contacts.search entity → TelegramDialog, сообщение выборки → DiscoveryMessage) ---
|
||||
|
||||
/// <summary>
|
||||
/// Сущность чата/канала результата contacts.SearchRequest → запись поиска (1:1 discovery_search
|
||||
/// L644–661: id подписанный, name/username из сущности, kind EN-канона; счётчики пустые — у результата
|
||||
/// поиска их нет). Используется TL-слоем поиска (Search) для chats результата.
|
||||
/// </summary>
|
||||
/// <param name="chat">Сущность канала/группы из chats результата поиска.</param>
|
||||
public static TelegramDialog ToFoundChat(ChatBase chat)
|
||||
{
|
||||
string signedId = SignedChatId(chat);
|
||||
(string name, string handle, string kind) = DescribePeer(signedId, chat, null);
|
||||
return new TelegramDialog(signedId, name, handle, kind, unreadCount: 0, topMessageId: 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сущность пользователя результата contacts.SearchRequest → запись поиска (личный чат/бот; core
|
||||
/// отсеивает kind=chat сам — Ruling 10). Поля и id — как у <see cref="ToFoundChat"/>.
|
||||
/// </summary>
|
||||
/// <param name="user">Сущность пользователя из users результата поиска.</param>
|
||||
public static TelegramDialog ToFoundUser(User user)
|
||||
{
|
||||
string signedId = "+" + user.id.ToString(CultureInfo.InvariantCulture);
|
||||
(string name, string handle, string kind) = DescribePeer(signedId, null, user);
|
||||
return new TelegramDialog(signedId, name, handle, kind, unreadCount: 0, topMessageId: 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение выборки discovery-read → нейтральное (1:1 _discovery_message_item L803–816): только
|
||||
/// непустые тексты (пустые/media/service отбрасываются); темы форума помечены topicId/topicTitle.
|
||||
/// </summary>
|
||||
/// <param name="message">Сообщение ленты/темы форума (MessageBase).</param>
|
||||
/// <param name="topicId">Id темы форума (для обычных источников null).</param>
|
||||
/// <param name="topicTitle">Название темы форума (для обычных источников null).</param>
|
||||
/// <param name="now">Текущее время (фолбэк даты сообщения без времени, как python `now`).</param>
|
||||
public static DiscoveryMessage? ToEvalMessage(MessageBase message, long? topicId, string? topicTitle, DateTimeOffset now)
|
||||
{
|
||||
if (message is not Message textMessage
|
||||
|| string.IsNullOrWhiteSpace(textMessage.message))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DiscoveryMessage(
|
||||
textMessage.id,
|
||||
textMessage.message,
|
||||
ToEpochMsOrNow(textMessage.Date, now),
|
||||
topicId,
|
||||
topicTitle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подписанный id канона контракта по peer диалога/сообщения.
|
||||
/// </summary>
|
||||
/// <param name="peer">Peer (PeerChannel/PeerChat/PeerUser).</param>
|
||||
public static string SignedIdOf(Peer peer)
|
||||
=> peer switch
|
||||
{
|
||||
PeerChannel channel => "-100" + channel.channel_id.ToString(CultureInfo.InvariantCulture),
|
||||
PeerChat chat => "-" + chat.chat_id.ToString(CultureInfo.InvariantCulture),
|
||||
PeerUser user => "+" + user.user_id.ToString(CultureInfo.InvariantCulture),
|
||||
_ => throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId),
|
||||
};
|
||||
|
||||
// Имя/username/тип диалога по его сущности (пустой сущности — имя и тип по умолчанию).
|
||||
// signedId: Подписанный id диалога (фолбэк имени).
|
||||
// chat: Сущность чата/канала (или null).
|
||||
// user: Сущность пользователя (или null).
|
||||
private static (string Name, string Handle, string Kind) DescribePeer(string signedId, ChatBase? chat, UserBase? user)
|
||||
{
|
||||
if (chat is not null)
|
||||
{
|
||||
string title = chat.Title ?? string.Empty;
|
||||
return (title.Length > 0 ? title : signedId, chat.MainUsername ?? string.Empty, KindOf(chat));
|
||||
}
|
||||
|
||||
if (user is User regularUser)
|
||||
{
|
||||
string name = (regularUser.first_name + " " + regularUser.last_name).Trim();
|
||||
string username = regularUser.MainUsername ?? string.Empty;
|
||||
if (name.Length == 0)
|
||||
{
|
||||
name = username.Length > 0 ? username : signedId;
|
||||
}
|
||||
|
||||
return (name, username, DialogKinds.Chat);
|
||||
}
|
||||
|
||||
return (signedId, string.Empty, DialogKinds.Chat);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Тип диалога канона контракта по сущности чата/канала (Ruling 3, kind-маппинг task-1).
|
||||
/// </summary>
|
||||
/// <param name="chat">Сущность канала/группы.</param>
|
||||
public static string KindOf(ChatBase chat)
|
||||
=> chat switch
|
||||
{
|
||||
Channel channel when (channel.flags & Channel.Flags.forum) != 0 => DialogKinds.Forum,
|
||||
Channel channel when (channel.flags & Channel.Flags.broadcast) != 0 => DialogKinds.Channel,
|
||||
_ => DialogKinds.Group,
|
||||
};
|
||||
|
||||
// Подписанный id чата/канала по самой сущности (каналы «-100…», группы «-…»).
|
||||
// chat: Сущность канала/группы.
|
||||
private static string SignedChatId(ChatBase chat)
|
||||
=> chat switch
|
||||
{
|
||||
Channel channel => "-100" + channel.id.ToString(CultureInfo.InvariantCulture),
|
||||
Chat group => "-" + group.id.ToString(CultureInfo.InvariantCulture),
|
||||
_ => throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId),
|
||||
};
|
||||
|
||||
// Дата сообщения → epoch-ms; без даты (default) — текущее время (python `date or now`).
|
||||
// date: Дата сообщения.
|
||||
// now: Текущее время (фолбэк).
|
||||
private static long ToEpochMsOrNow(DateTime date, DateTimeOffset now)
|
||||
=> date == default ? now.ToUnixTimeMilliseconds() : ToEpochMs(date);
|
||||
|
||||
// Ищет сущность чата/канала диалога в словаре ответа.
|
||||
// peer: Peer сообщения/диалога.
|
||||
// chats: Сущности чатов контейнера.
|
||||
private static ChatBase? FindChat(Peer peer, IReadOnlyDictionary<long, ChatBase> chats)
|
||||
=> peer switch
|
||||
{
|
||||
PeerChannel channel when chats.TryGetValue(channel.channel_id, out ChatBase? chat) => chat,
|
||||
PeerChat chat when chats.TryGetValue(chat.chat_id, out ChatBase? group) => group,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// Ищет сущность пользователя диалога в словаре ответа.
|
||||
// peer: Peer сообщения/диалога.
|
||||
// users: Сущности пользователей контейнера.
|
||||
private static UserBase? FindUser(Peer peer, IReadOnlyDictionary<long, User> users)
|
||||
=> peer is PeerUser user && users.TryGetValue(user.user_id, out User? regularUser) ? regularUser : null;
|
||||
|
||||
// DateTime (UTC от Telegram) → epoch-ms (1:1 int(date.timestamp()*1000)).
|
||||
// date: Время сообщения.
|
||||
private static long ToEpochMs(DateTime date)
|
||||
=> new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)).ToUnixTimeMilliseconds();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
using Deal.Grpc.Hosting;
|
||||
using Deal.Telegram.Core;
|
||||
using Deal.Telegram.Dialogs;
|
||||
using Deal.Telegram.Discovery;
|
||||
using Deal.Telegram.Hosting;
|
||||
using Deal.Telegram.Sessions;
|
||||
using Deal.Telegram.Telegram;
|
||||
|
||||
namespace Deal.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает WebApplication gRPC-хоста telegram-service (план Task 2/9/10; L227–238 + задачи сессий и
|
||||
/// диалогов/мониторинга).
|
||||
///
|
||||
/// Продакшн-точка входа вызывает <see cref="Create"/> из Program.cs (порт из env GRPC_PORT/PORT);
|
||||
/// интеграционные тесты (Deal.Telegram.Tests) — из своего процесса на эфемерном порту, поэтому
|
||||
/// конфигурация хоста живёт здесь один раз и не дублируется в тестах.
|
||||
/// Транспорт/AddGrpc/health — общая серверная обвязка <see cref="GrpcServer"/> (Deal.Grpc.Hosting,
|
||||
/// C31): mTLS (env DEAL_MTLS_*, Ruling 6/Task 13), Kestrel HTTP/2, интерцепторы service-token и
|
||||
/// access-лога, gRPC-health; здесь — только регистрации логики telegram-service.
|
||||
/// Регистрации задачи сессий: хранение (TgOptions/SessionFileCipher/SessionStore), ферма сессий
|
||||
/// (SessionFarm + ClientFactory), фоновый цикл auto_resume/heartbeat (SessionHeartbeatService);
|
||||
/// обязательный env DEAL_TELEGRAM_SESSION_KEY проверяется при сборке хоста (fail-closed, Ruling 13).
|
||||
/// Регистрации задачи диалогов/мониторинга (Task 10, Ruling 7): DialogCatalog, исходящий канал в ядро
|
||||
/// (ICoreIngressClient/CoreIngressClient — SERVICES__CORE__INGRESS), BackfillService с анти-бан-
|
||||
/// пейсером и фоновые циклы RealtimeSweepService/RealtimeMonitorService.
|
||||
/// </summary>
|
||||
public static class TelegramServiceHost
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт (не запускает) хост: общая обвязка GrpcServer (Kestrel HTTP/2 на 0.0.0.0:grpcPort,
|
||||
/// dev — plaintext + service-token, Ruling 2; при DEAL_MTLS_ENABLED=1 — HTTPS с серверным
|
||||
/// сертификатом и требованием клиентского, Ruling 6/Task 13), затем регистрации сессий и
|
||||
/// диалогов/мониторинга и маппинг <see cref="TelegramServiceImpl"/>.
|
||||
/// </summary>
|
||||
/// <param name="grpcPort">TCP-порт Kestrel.</param>
|
||||
/// <param name="args">Аргументы командной строки (Program.cs); в тестах не нужны.</param>
|
||||
/// <param name="configureServices">
|
||||
/// Опциональный хук DI для тестов (подмена зависимостей фейками, напр. ITelegramClientFactory).
|
||||
/// </param>
|
||||
/// <param name="configureBuilder">
|
||||
/// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog
|
||||
/// (DealLogging.Configure, Ruling 7/Task 14). Тесты хост поднимают БЕЗ этого хука — логирование
|
||||
/// файлов/консоли тестам не нужно.
|
||||
/// </param>
|
||||
/// <returns>Собранный хост; запуск — StartAsync/RunAsync у вызывающего.</returns>
|
||||
public static WebApplication Create(
|
||||
int grpcPort,
|
||||
string[]? args = null,
|
||||
Action<IServiceCollection>? configureServices = null,
|
||||
Action<WebApplicationBuilder>? configureBuilder = null)
|
||||
{
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []);
|
||||
|
||||
// Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка
|
||||
// сертификатов сразу с fail-fast (compose-prod монтирует deploy/certs, scripts/mtls-certs.sh);
|
||||
// Kestrel HTTP/2 (dev — plaintext + обязательный service-token, Ruling 2); AddGrpc
|
||||
// (access-лог первым, затем service-token, потолок сообщения) и gRPC-health (Ruling 12).
|
||||
// Один экземпляр mtlsCertificates используют и Kestrel ниже, и исходящий канал в ядро
|
||||
// (CoreIngressClient).
|
||||
MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
|
||||
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
|
||||
builder.Services.AddDealGrpcServer();
|
||||
builder.Services.AddReadyHealthCheck("хост telegram-service готов");
|
||||
|
||||
// Сессии тенантов (задача «сессии и QR-подключение», план Task 9; Ruling 3): хранилище
|
||||
// файлов data/sessions/<tenant>.session (AES-GCM, ключ из env), пул 1 аккаунт/тенант и
|
||||
// фоновый цикл auto_resume/heartbeat (30 с). DEAL_TELEGRAM_SESSION_KEY обязателен —
|
||||
// иначе хост не стартует (сессии не могут храниться в открытом виде).
|
||||
TgOptions sessionOptions = TgOptions.FromConfiguration(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddSingleton(sessionOptions);
|
||||
builder.Services.AddSingleton<SessionFileCipher>();
|
||||
builder.Services.AddSingleton<SessionStore>();
|
||||
builder.Services.AddSingleton<ITelegramClientFactory, ClientFactory>();
|
||||
builder.Services.AddSingleton<SessionFarm>();
|
||||
builder.Services.AddHostedService<SessionHeartbeatService>();
|
||||
|
||||
/// Диалоги и мониторинг (задача «диалоги/backfill/мониторинг», план Task 10; Ruling 7): зеркало
|
||||
/// каталога/мониторинга (DialogCatalog), исходящий канал в ядро (CoreIngressClient — адрес
|
||||
/// SERVICES__CORE__INGRESS, Ruling 12), backfill с анти-бан-паузами и фоновые циклы:
|
||||
/// догон непрочитанных (RealtimeSweepService, 30 с) и reconcile realtime-listener'ов
|
||||
/// (RealtimeMonitorService). Discovery-операции (план Task 11): DiscoveryOps поверх SessionFarm
|
||||
/// и общего анти-бан-пейсера (поиск 2–4 с, Ruling 3). configureServices (тесты) может подменить
|
||||
/// ICoreIngressClient/пейсер.
|
||||
CoreIngressOptions ingressOptions = CoreIngressOptions.FromConfiguration(builder.Configuration);
|
||||
builder.Services.AddSingleton(ingressOptions);
|
||||
builder.Services.AddSingleton<ICoreIngressClient>(provider => new CoreIngressClient(
|
||||
ingressOptions,
|
||||
provider.GetRequiredService<ILogger<CoreIngressClient>>(),
|
||||
mtlsCertificates));
|
||||
builder.Services.AddSingleton<DialogCatalog>();
|
||||
builder.Services.AddSingleton<IBackfillPacer, RandomBackfillPacer>();
|
||||
builder.Services.AddSingleton<BackfillService>();
|
||||
builder.Services.AddSingleton<DiscoveryOps>();
|
||||
builder.Services.AddSingleton<RealtimeSweep>();
|
||||
builder.Services.AddHostedService<RealtimeSweepService>();
|
||||
builder.Services.AddHostedService<RealtimeMonitorService>();
|
||||
|
||||
configureServices?.Invoke(builder.Services);
|
||||
configureBuilder?.Invoke(builder);
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
app.MapGrpcService<TelegramServiceImpl>();
|
||||
app.MapGrpcHealthChecksService();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
using System.Net.Http;
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.Telegram.Core;
|
||||
using Deal.Telegram.Dialogs;
|
||||
using Deal.Telegram.Discovery;
|
||||
using Deal.Telegram.Sessions;
|
||||
using Deal.Telegram.Telegram;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Реализация серверной стороны Deal.Grpc.Telegram.TelegramService — команды core →
|
||||
/// telegram-service (telegram.proto, контракты Task 1; Ruling 1/7).
|
||||
///
|
||||
/// Реализованы RPC подключения аккаунта (задача «сессии и QR-подключение», план Task 9, Ruling 3):
|
||||
/// GetStatus/StartPhone/StartQr/SendCode/SendPassword/Logout поверх <see cref="SessionFarm"/>
|
||||
/// (1 аккаунт на тенанта; tenantId только из metadata, полю не доверяем — Ruling 1). Доменные ошибки
|
||||
/// (SessionException) переводятся в RPC-статусы контракта (detail = текст 1:1, шапка telegram.proto).
|
||||
/// RPC каталога/мониторинга (план Task 10, Ruling 7): RefreshDialogs/SetMonitor/SetMonitorAll/Backfill/
|
||||
/// ReadRecent поверх SessionFarm + DialogCatalog (зеркало мониторинга) + BackfillService.
|
||||
/// RPC discovery (план Task 11): Search/GetInfo/ReadForEval/Join/Leave поверх SessionFarm через
|
||||
/// DiscoveryOps (поиск с анти-бан-паузой 2–4 с, форумы по темам, join по username вне квот — паузу перед
|
||||
/// авто-join делает воркер ядра, Ruling 10). IngressService здесь сервером не выставляется (его сервер —
|
||||
/// Deal.Api, Ruling 7; сервис — клиент через CoreIngressClient).
|
||||
/// </summary>
|
||||
public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1).
|
||||
/// </summary>
|
||||
public const string TenantIdMetadataKey = "tenant-id";
|
||||
|
||||
// Верхняя граница списка диалогов refresh (как refresh_dialogs L510: limit=500).
|
||||
private const int DialogListLimit = 500;
|
||||
|
||||
// Лимит превью по умолчанию, если core не передал (dialog_messages прототипа, limit=24).
|
||||
private const int PreviewDefaultLimit = 24;
|
||||
|
||||
// Максимальный лимит превью (контракт ReadRecentRequest: 1..50).
|
||||
private const int PreviewMaxLimit = 50;
|
||||
|
||||
// Верхняя граница поискового запроса (ключ discovery; защита границы сервиса).
|
||||
private const int MaxSearchQueryLength = 200;
|
||||
|
||||
// Верхняя граница username вступления (лимит Telegram: 5..32 символа).
|
||||
private const int MaxUsernameLength = 32;
|
||||
|
||||
private readonly SessionFarm _sessionFarm;
|
||||
private readonly DialogCatalog _catalog;
|
||||
private readonly ICoreIngressClient _ingress;
|
||||
private readonly BackfillService _backfill;
|
||||
private readonly DiscoveryOps _discovery;
|
||||
private readonly ILogger<TelegramServiceImpl> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт сервис команд core.
|
||||
/// </summary>
|
||||
/// <param name="sessionFarm">Пул сессий тенантов.</param>
|
||||
/// <param name="catalog">Зеркало каталога/мониторинга диалогов.</param>
|
||||
/// <param name="ingress">Исходящий канал в ядро (SyncDialogs — актуализация зеркала).</param>
|
||||
/// <param name="backfill">Backfill последних сообщений диалога.</param>
|
||||
/// <param name="discovery">Discovery-операции (поиск/инфо/чтение/join/leave).</param>
|
||||
/// <param name="logger">Логгер.</param>
|
||||
public TelegramServiceImpl(
|
||||
SessionFarm sessionFarm,
|
||||
DialogCatalog catalog,
|
||||
ICoreIngressClient ingress,
|
||||
BackfillService backfill,
|
||||
DiscoveryOps discovery,
|
||||
ILogger<TelegramServiceImpl> logger)
|
||||
{
|
||||
_sessionFarm = sessionFarm;
|
||||
_catalog = catalog;
|
||||
_ingress = ingress;
|
||||
_backfill = backfill;
|
||||
_discovery = discovery;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// --- Подключение аккаунта и статус (Ruling 3, WTelegramClient) ---
|
||||
|
||||
/// <summary>
|
||||
/// GetStatus — статус и фаза входа аккаунта тенанта (status L103–119).
|
||||
/// </summary>
|
||||
public override async Task<GetStatusReply> GetStatus(GetStatusRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
|
||||
TenantSessionSnapshot? snapshot = await _sessionFarm.GetStatusAsync(tenantId, context.CancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
throw ToRpc(new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected));
|
||||
}
|
||||
|
||||
var reply = new GetStatusReply
|
||||
{
|
||||
Phase = PhaseToString(snapshot.Phase),
|
||||
Connected = snapshot.Connected,
|
||||
Listener = snapshot.Listener,
|
||||
Account = snapshot.Account ?? string.Empty,
|
||||
};
|
||||
|
||||
if (snapshot.Error is not null)
|
||||
{
|
||||
reply.Error = snapshot.Error;
|
||||
}
|
||||
|
||||
if (snapshot.QrUrl is not null)
|
||||
{
|
||||
reply.QrUrl = snapshot.QrUrl;
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StartPhone — запросить код по номеру телефона (start_phone L134–147).
|
||||
/// </summary>
|
||||
public override async Task<StartPhoneReply> StartPhone(StartPhoneRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
|
||||
TenantSessionSnapshot snapshot = await ExecuteAsync(
|
||||
tenantId,
|
||||
ct => _sessionFarm.StartPhoneAsync(tenantId, request.ApiId, request.ApiHash, request.Phone, ct),
|
||||
"start_phone",
|
||||
context).ConfigureAwait(false);
|
||||
|
||||
return new StartPhoneReply { Phase = PhaseToString(snapshot.Phase) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StartQr — начать вход по QR (qr_start L286–300): фаза + qrUrl.
|
||||
/// </summary>
|
||||
public override async Task<StartQrReply> StartQr(StartQrRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
|
||||
TenantSessionSnapshot snapshot = await ExecuteAsync(
|
||||
tenantId,
|
||||
ct => _sessionFarm.StartQrAsync(tenantId, request.ApiId, request.ApiHash, ct),
|
||||
"start_qr",
|
||||
context).ConfigureAwait(false);
|
||||
|
||||
var reply = new StartQrReply { Phase = PhaseToString(snapshot.Phase) };
|
||||
if (snapshot.QrUrl is not null)
|
||||
{
|
||||
reply.QrUrl = snapshot.QrUrl;
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SendCode — отправить SMS-код входа (submit_code L149–166).
|
||||
/// </summary>
|
||||
public override async Task<SendCodeReply> SendCode(SendCodeRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
|
||||
TenantSessionSnapshot snapshot = await ExecuteAsync(
|
||||
tenantId,
|
||||
ct => _sessionFarm.SendCodeAsync(tenantId, request.Code, ct),
|
||||
"send_code",
|
||||
context).ConfigureAwait(false);
|
||||
|
||||
return new SendCodeReply { Phase = PhaseToString(snapshot.Phase) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SendPassword — облачный пароль 2FA (submit_password L168–176).
|
||||
/// </summary>
|
||||
public override async Task<SendPasswordReply> SendPassword(SendPasswordRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
|
||||
TenantSessionSnapshot snapshot = await ExecuteAsync(
|
||||
tenantId,
|
||||
ct => _sessionFarm.SendPasswordAsync(tenantId, request.Password, ct),
|
||||
"send_password",
|
||||
context).ConfigureAwait(false);
|
||||
|
||||
return new SendPasswordReply { Phase = PhaseToString(snapshot.Phase) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout — отключить аккаунт, удалить сессию тенанта (disconnect L189–207).
|
||||
/// </summary>
|
||||
public override async Task<LogoutReply> Logout(LogoutRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId,
|
||||
ct => _sessionFarm.LogoutAsync(tenantId, ct),
|
||||
"logout",
|
||||
context).ConfigureAwait(false);
|
||||
|
||||
// Отключение аккаунта: зеркало мониторинга очищается (прототип L203: `_monitored.clear()`).
|
||||
_catalog.Reset(tenantId);
|
||||
return new LogoutReply { Ok = true };
|
||||
}
|
||||
|
||||
// --- Каталог и мониторинг (задача Task 10; Ruling 7) ---
|
||||
|
||||
/// <summary>
|
||||
/// RefreshDialogs — актуальный каталог диалогов аккаунта (refresh_dialogs L505–519).
|
||||
/// </summary>
|
||||
public override async Task<RefreshDialogsReply> RefreshDialogs(RefreshDialogsRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
|
||||
RefreshDialogsReply reply = await ExecuteAsync(
|
||||
tenantId,
|
||||
async ct =>
|
||||
{
|
||||
IReadOnlyList<Telegram.TelegramDialog> dialogs = await _sessionFarm
|
||||
.ListDialogsAsync(tenantId, DialogListLimit, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
List<DialogEntry> entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList();
|
||||
_catalog.ReplaceKnown(tenantId, dialogs.Select(dialog => dialog.Id).ToList());
|
||||
|
||||
// Актуализация зеркала мониторинга ответом SyncDialogs (Ruling 7). Сбой ядра не роняет
|
||||
// ответ — зеркало догонит realtime_sweep следующим циклом.
|
||||
await SyncCatalogSilentlyAsync(tenantId, entries, ct).ConfigureAwait(false);
|
||||
|
||||
var result = new RefreshDialogsReply();
|
||||
result.Entries.AddRange(entries);
|
||||
return result;
|
||||
},
|
||||
"refresh_dialogs",
|
||||
context).ConfigureAwait(false);
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SetMonitor — включить/выключить мониторинг диалога (set_monitor L536–546).
|
||||
/// </summary>
|
||||
public override Task<SetMonitorReply> SetMonitor(SetMonitorRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
RequireDialogId(request.DialogId);
|
||||
|
||||
_catalog.SetMonitored(tenantId, request.DialogId, request.Enabled);
|
||||
_logger.LogInformation(
|
||||
"Аудит: tenant {TenantId} set_monitor {DialogId} → {Enabled}",
|
||||
tenantId,
|
||||
request.DialogId,
|
||||
request.Enabled);
|
||||
return Task.FromResult(new SetMonitorReply { Ok = true, Enabled = request.Enabled });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SetMonitorAll — мониторинг всех диалогов каталога (set_monitor_all L548–567).
|
||||
/// </summary>
|
||||
public override Task<SetMonitorAllReply> SetMonitorAll(SetMonitorAllRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
|
||||
int count = _catalog.SetAllMonitored(tenantId, request.Enabled);
|
||||
_logger.LogInformation(
|
||||
"Аудит: tenant {TenantId} set_monitor_all → {Enabled} (каталог {Count})",
|
||||
tenantId,
|
||||
request.Enabled,
|
||||
count);
|
||||
return Task.FromResult(new SetMonitorAllReply { Ok = true, Count = count, Enabled = request.Enabled });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill — перечитать последние сообщения диалога в ядро (backfill L568–582/«Перечитать»).
|
||||
/// </summary>
|
||||
public override async Task<BackfillReply> Backfill(BackfillRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
RequireDialogId(request.DialogId);
|
||||
|
||||
int processed = await ExecuteAsync(
|
||||
tenantId,
|
||||
ct => _backfill.ExecuteAsync(tenantId, request.DialogId, request.Force, ct),
|
||||
"backfill",
|
||||
context).ConfigureAwait(false);
|
||||
return new BackfillReply { Processed = processed };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ReadRecent — последние сообщения диалога для превью (dialog_messages L583–620).
|
||||
/// </summary>
|
||||
public override async Task<ReadRecentReply> ReadRecent(ReadRecentRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
RequireDialogId(request.DialogId);
|
||||
int limit = request.Limit <= 0 ? PreviewDefaultLimit : Math.Min(request.Limit, PreviewMaxLimit);
|
||||
|
||||
ReadRecentReply reply = await ExecuteAsync(
|
||||
tenantId,
|
||||
async ct =>
|
||||
{
|
||||
IReadOnlyList<Telegram.TelegramMessage> messages = await _sessionFarm
|
||||
.GetMessagesAsync(tenantId, request.DialogId, limit, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var result = new ReadRecentReply();
|
||||
result.Messages.AddRange(messages.Select(DialogProtoMapper.ToPreview));
|
||||
|
||||
// Вручную вытащили сообщения — снимаем «новое» в Telegram (прототип L607–611).
|
||||
await _sessionFarm.MarkReadAsync(tenantId, request.DialogId, ct).ConfigureAwait(false);
|
||||
return result;
|
||||
},
|
||||
"read_recent",
|
||||
context).ConfigureAwait(false);
|
||||
return reply;
|
||||
}
|
||||
|
||||
// --- Discovery-операции (задача Task 11; Ruling 3/7/10) ---
|
||||
|
||||
/// <summary>
|
||||
/// Search — глобальный поиск каналов/групп по ключу (discovery_search L624–664).
|
||||
/// </summary>
|
||||
public override async Task<SearchReply> Search(SearchRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
RequireSearchQueryWithinBounds(request.Query);
|
||||
|
||||
SearchReply reply = await ExecuteAsync(
|
||||
tenantId,
|
||||
async ct =>
|
||||
{
|
||||
IReadOnlyList<Telegram.TelegramDialog> found = await _discovery
|
||||
.SearchAsync(tenantId, request.Query, request.Limit, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var result = new SearchReply();
|
||||
result.Results.AddRange(found.Select(DialogProtoMapper.ToEntry));
|
||||
return result;
|
||||
},
|
||||
"search",
|
||||
context).ConfigureAwait(false);
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GetInfo — инфо об источнике для оценки кандидата (discovery_info L666–716).
|
||||
/// </summary>
|
||||
public override async Task<GetInfoReply> GetInfo(GetInfoRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
RequireDialogId(request.DialogId);
|
||||
|
||||
GetInfoReply reply = await ExecuteAsync(
|
||||
tenantId,
|
||||
async ct =>
|
||||
{
|
||||
TelegramSourceInfo info = await _discovery
|
||||
.GetInfoAsync(tenantId, request.DialogId, ct)
|
||||
.ConfigureAwait(false);
|
||||
return new GetInfoReply { Info = DiscoveryProtoMapper.ToChannelInfo(info) };
|
||||
},
|
||||
"discovery_info",
|
||||
context).ConfigureAwait(false);
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ReadForEval — выборка сообщений источника для оценки (discovery_read L718–800).
|
||||
/// </summary>
|
||||
public override async Task<ReadForEvalReply> ReadForEval(ReadForEvalRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
RequireDialogId(request.DialogId);
|
||||
|
||||
ReadForEvalReply reply = await ExecuteAsync(
|
||||
tenantId,
|
||||
async ct =>
|
||||
{
|
||||
DiscoveryReadResult result = await _discovery
|
||||
.ReadForEvalAsync(tenantId, request.DialogId, request.Limit, ct)
|
||||
.ConfigureAwait(false);
|
||||
return DiscoveryProtoMapper.ToReadForEvalReply(result);
|
||||
},
|
||||
"discovery_read",
|
||||
context).ConfigureAwait(false);
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Join — вступить в канал/группу по @username (discovery_join L818–839; вне квот, Ruling 10).
|
||||
/// </summary>
|
||||
public override async Task<JoinReply> Join(JoinRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
string normalizedUsername = DiscoveryOps.NormalizeUsername(request.Username);
|
||||
RequireUsernameWithinBounds(normalizedUsername);
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId,
|
||||
async ct =>
|
||||
{
|
||||
await _discovery.JoinAsync(tenantId, normalizedUsername, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
},
|
||||
"join",
|
||||
context).ConfigureAwait(false);
|
||||
return new JoinReply { Ok = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leave — выйти из канала/группы (discovery_leave L841–848).
|
||||
/// </summary>
|
||||
public override async Task<LeaveReply> Leave(LeaveRequest request, ServerCallContext context)
|
||||
{
|
||||
string tenantId = RequireTenantId(context);
|
||||
RequireDialogId(request.DialogId);
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId,
|
||||
async ct =>
|
||||
{
|
||||
await _discovery.LeaveAsync(tenantId, request.DialogId, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
},
|
||||
"leave",
|
||||
context).ConfigureAwait(false);
|
||||
return new LeaveReply { Ok = true };
|
||||
}
|
||||
|
||||
// Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, Ruling 1).
|
||||
// context: Контекст вызова.
|
||||
private static string RequireTenantId(ServerCallContext context)
|
||||
{
|
||||
string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, SessionErrorMessages.TenantIdMissing));
|
||||
}
|
||||
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
// Id диалога обязателен в командах каталога (пустое — INVALID_ARGUMENT).
|
||||
// dialogId: Id диалога из запроса.
|
||||
private static void RequireDialogId(string dialogId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dialogId))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId));
|
||||
}
|
||||
}
|
||||
|
||||
// Поисковый запрос ограничен сверху (INVALID_ARGUMENT; защита границы сервиса).
|
||||
// query: Поисковый запрос.
|
||||
private static void RequireSearchQueryWithinBounds(string query)
|
||||
{
|
||||
if (query.Trim().Length > MaxSearchQueryLength)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.SearchQueryTooLong));
|
||||
}
|
||||
}
|
||||
|
||||
// Username вступления ограничен сверху (INVALID_ARGUMENT; лимит Telegram 5..32).
|
||||
// username: Нормализованный username (без «@»/пробелов).
|
||||
private static void RequireUsernameWithinBounds(string username)
|
||||
{
|
||||
if (username.Length > MaxUsernameLength)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.UsernameTooLong));
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort-синхронизация зеркала мониторинга с ядром (SyncDialogs): сбой (ядро недоступно)
|
||||
// логируется и не роняет команду — упущенное догоняет realtime_sweep (Ruling 7/план Task 10).
|
||||
// tenantId: Id тенанта.
|
||||
// entries: Актуальный каталог диалогов.
|
||||
// cancellationToken: Отмена операции.
|
||||
private async Task SyncCatalogSilentlyAsync(string tenantId, IReadOnlyList<DialogEntry> entries, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string> monitored = await _ingress.SyncDialogsAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
|
||||
_catalog.ReplaceMonitored(tenantId, monitored);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(exception, "Аудит: tenant {TenantId} sync_dialogs → сбой (зеркало прежнее; догонит sweep)", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
// Исполняет операцию сессии с единым переводом ошибок: SessionException → RPC-статус контракта,
|
||||
// прочие — UNAVAILABLE «Telegram недоступен…» + структурированный лог (аудит команд, Ruling 13).
|
||||
// TResult: Тип результата операции.
|
||||
// tenantId: Id тенанта (для лога аудита).
|
||||
// operation: Операция сессии.
|
||||
// action: Действие (имя метода прототипа, для лога).
|
||||
// context: Контекст вызова gRPC.
|
||||
private async Task<TResult> ExecuteAsync<TResult>(
|
||||
string tenantId,
|
||||
Func<CancellationToken, Task<TResult>> operation,
|
||||
string action,
|
||||
ServerCallContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
TResult result = await operation(context.CancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Аудит: tenant {TenantId} {Action} → ok", tenantId, action);
|
||||
return result;
|
||||
}
|
||||
catch (SessionException exception)
|
||||
{
|
||||
_logger.LogInformation("Аудит: tenant {TenantId} {Action} → ошибка {Error}", tenantId, action, exception.Message);
|
||||
throw ToRpc(exception);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(exception, "Аудит: tenant {TenantId} {Action} → сбой", tenantId, action);
|
||||
throw ToRpc(ToSessionFailure(exception));
|
||||
}
|
||||
}
|
||||
|
||||
// Переводит не-SessionException в доменную ошибку: транспорт/сеть — UNAVAILABLE («Telegram недоступен…»),
|
||||
// внутренние сбои реализации (NRE/InvalidOperation и пр.) — INTERNAL. Раньше любые ошибки маскировались
|
||||
// UNAVAILABLE (замечание code-review): внутренние дефекты должны быть видны как Internal.
|
||||
// exception: Необработанное исключение операции.
|
||||
private static SessionException ToSessionFailure(Exception exception)
|
||||
=> IsTransportFailure(exception)
|
||||
? new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception)
|
||||
: new SessionException(StatusCode.Internal, SessionErrorMessages.InternalError, exception);
|
||||
|
||||
// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE (безопасный повтор).
|
||||
// exception: Исключение для классификации.
|
||||
private static bool IsTransportFailure(Exception exception)
|
||||
=> exception is HttpRequestException or IOException or TimeoutException or RpcException;
|
||||
|
||||
// Фаза AuthPhase → строка канона контракта (idle|phone|code|password|qr|ready).
|
||||
// phase: Фаза сессии.
|
||||
private static string PhaseToString(AuthPhase phase)
|
||||
=> phase switch
|
||||
{
|
||||
AuthPhase.Phone => "phone",
|
||||
AuthPhase.Code => "code",
|
||||
AuthPhase.Password => "password",
|
||||
AuthPhase.Qr => "qr",
|
||||
AuthPhase.Ready => "ready",
|
||||
_ => "idle",
|
||||
};
|
||||
|
||||
// Доменная ошибка сессии → RpcException контракта (код + detail = текст причины).
|
||||
// exception: Ошибка сессии.
|
||||
private static RpcException ToRpc(SessionException exception)
|
||||
=> new(new Status(exception.Code, exception.Message));
|
||||
}
|
||||
Reference in New Issue
Block a user