Почистить комментарии от упоминаний процесса

Удалены <remarks>, <summary> сжаты до короткой фразы, вырезаны
ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками
на процесс удалены; то же в .proto. Правила обновлены в
docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:39:39 +03:00
parent 5f5538d33b
commit b053d58335
902 changed files with 3902 additions and 12074 deletions
@@ -3,17 +3,10 @@ using System.Diagnostics.CodeAnalysis;
namespace Deal.Telegram.Caching;
/// <summary>
/// Кэш с ограниченной ёмкостью и вытеснением least-recently-used (LRU): при переполнении удаляется
/// элемент, к которому дольше всего не обращались. Нужен там, где раньше жил неограниченный
/// <see cref="Dictionary{TKey,TValue}"/> и память росла с числом сущностей (долгоживущий процесс сервиса).
/// Кэш с ограниченной ёмкостью и вытеснением least-recently-used
/// </summary>
/// <remarks>
/// НЕ потокобезопасен: вызывающий обязан сериализовать доступ (в telegram-service кэши
/// WTelegramSessionClient защищены общим <c>_entityCacheGate</c>). Вытеснение — забота производительности,
/// а не корректности: потерянное значение всегда можно получить повторным запросом к Telegram.
/// </remarks>
/// <typeparam name="TKey">Тип ключа (ссылочный или значимый).</typeparam>
/// <typeparam name="TValue">Тип значения.</typeparam>
/// <param name="TKey">Тип ключа (ссылочный или значимый).</param>
/// <param name="TValue">Тип значения.</param>
public sealed class LruCache<TKey, TValue>
where TKey : notnull
{
@@ -66,15 +59,14 @@ public sealed class LruCache<TKey, TValue>
}
/// <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>
@@ -10,22 +10,17 @@ 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).
/// Исходящий gRPC-канал в ядро
/// </summary>
public sealed class CoreIngressClient : ICoreIngressClient
{
/// <summary>
/// Ключ gRPC-metadata с id тенанта (зеркало интерцепторов, Ruling 1).
/// Ключ gRPC-metadata с id тенанта.
/// </summary>
public const string TenantIdMetadataKey = "tenant-id";
/// <summary>
/// Ключ gRPC-metadata с service-token (зеркало интерцепторов, Ruling 1).
/// Ключ gRPC-metadata с service-token.
/// </summary>
public const string ServiceTokenMetadataKey = "service-token";
@@ -41,7 +36,7 @@ public sealed class CoreIngressClient : ICoreIngressClient
/// </summary>
/// <param name="options">Конфигурация (адрес из env, service-token).</param>
/// <param name="logger">Логгер.</param>
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал (dev).</param>
/// <param name="mtlsCertificates">Сертификаты mTLS: null — plaintext-канал (dev).</param>
public CoreIngressClient(
CoreIngressOptions options,
ILogger<CoreIngressClient> logger,
@@ -96,8 +91,6 @@ public sealed class CoreIngressClient : ICoreIngressClient
{
if (_client is null)
{
// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским
// сертификатом и проверяет CA ядра; dev — plaintext-канал (Ruling 2 этапа 6).
if (_mtlsCertificates is not null)
{
_channel = GrpcChannel.ForAddress(
@@ -116,7 +109,6 @@ public sealed class CoreIngressClient : ICoreIngressClient
}
}
// Metadata вызова (tenant-id + service-token) и deadline (Ruling 1).
// tenantId: Id тенанта.
private CallOptions CallOptions(string tenantId)
{
@@ -129,7 +121,6 @@ public sealed class CoreIngressClient : ICoreIngressClient
}
// Переводит сбой вызова в SessionException (UNAVAILABLE) со структурированным логом.
// action: Действие (PushMessage/SyncDialogs) для лога аудита (Ruling 13).
// exception: Исключение вызова.
private SessionException Fail(string action, Exception exception)
{
@@ -1,37 +1,32 @@
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).
/// Env-ключ адреса gRPC-ингресса ядра.
/// </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).
/// Env-ключ service-token.
/// </summary>
public const string ServiceTokenEnvVarName = "DEAL_SERVICE_TOKEN";
/// <summary>
/// Адрес ингресса ядра по умолчанию (core dev на хосте, Ruling 12).
/// Адрес ингресса ядра по умолчанию.
/// </summary>
public const string DefaultIngressEndpoint = "http://localhost:5082";
/// <summary>
/// Таймаут одного RPC в ядро (сек).
/// Таймаут одного RPC в ядро
/// </summary>
public const int RpcTimeoutSeconds = 15;
@@ -42,17 +37,17 @@ public sealed class CoreIngressOptions
}
/// <summary>
/// Адрес gRPC-ингресса ядра (например, http://localhost:5082).
/// Адрес gRPC-ингресса ядра
/// </summary>
public string IngressEndpoint { get; }
/// <summary>
/// Service-token для metadata каждого RPC (может быть пустым — ядро откажет).
/// Service-token для metadata каждого RPC
/// </summary>
public string ServiceToken { get; }
/// <summary>
/// Создаёт опции с явным адресом и токеном (unit-тесты канала).
/// Создаёт опции с явным адресом и токеном
/// </summary>
/// <param name="ingressEndpoint">Адрес ингресса ядра.</param>
/// <param name="serviceToken">Service-token (пустой — вызовы будут отвергнуты ядром).</param>
@@ -67,8 +62,7 @@ public sealed class CoreIngressOptions
}
/// <summary>
/// Читает конфигурацию из env/конфигурации хоста. Адрес не задан — значение по умолчанию
/// <see cref="DefaultIngressEndpoint"/> (localhost-ядро dev); токен — как в env (может быть пуст).
/// Читает конфигурацию из env/конфигурации хоста.
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
/// <returns>Опции исходящего канала в ядро.</returns>
@@ -3,21 +3,14 @@ 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="tenantId">Id тенанта.</param>
/// <param name="message">Сообщение в контракте PushMessageRequest.</param>
/// <param name="cancellationToken">Отмена вызова.</param>
/// <returns>Ответ ядра (accepted/duplicate — дубль dialog+msgId в очереди не растёт).</returns>
@@ -27,11 +20,9 @@ public interface ICoreIngressClient
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="tenantId">Id тенанта.</param>
/// <param name="entries">Актуальный каталог диалогов (как refresh_dialogs).</param>
/// <param name="cancellationToken">Отмена вызова.</param>
/// <returns>Список id диалогов с включённым мониторингом (по версии ядра).</returns>
@@ -7,42 +7,32 @@ using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Backfill диалога: перечитывание последних ~10 сообщений и отправка их в ядро потоком PushMessage
/// (план Task 10, Dialogs/BackfillService.cs; прототип backfill_dialog/_backfill_dialogs L331390).
///
/// 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 исполняется всегда — дубли гасит ядро.
/// Backfill диалога
/// </summary>
public sealed class BackfillService
{
/// <summary>
/// Сколько последних сообщений читает backfill (прототип: limit=10, L371).
/// Сколько последних сообщений читает backfill.
/// </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;
@@ -51,10 +41,8 @@ public sealed class BackfillService
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 с между диалогами).
@@ -82,7 +70,7 @@ public sealed class BackfillService
}
/// <summary>
/// Перечитывает последние сообщения диалога в ядро (Backfill RPC; кнопка «Перечитать»).
/// Перечитывает последние сообщения диалога в ядро
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
@@ -98,7 +86,6 @@ public sealed class BackfillService
var key = (tenantId, dialogId);
if (!_running.TryAdd(key, 0))
{
// Прототип L355–356: повторный вход в уже перечитываемый диалог → 0.
_logger.LogInformation("Аудит: backfill {TenantId} {DialogId} → пропущен (уже перечитывается)", tenantId, dialogId);
return 0;
}
@@ -135,7 +122,6 @@ public sealed class BackfillService
}
}
// Пауза 36 с между backfill'ами разных диалогов одного тенанта (python L347).
// tenantId: Id тенанта.
// cancellationToken: Отмена операции.
private async Task EnsureDialogSpacingAsync(string tenantId, CancellationToken cancellationToken)
@@ -184,7 +170,6 @@ public sealed class BackfillService
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);
@@ -193,11 +178,9 @@ public sealed class BackfillService
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;
@@ -3,15 +3,7 @@ using System.Collections.ObjectModel;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Зеркало каталога диалогов тенанта в памяти telegram-service (план Task 10, Dialogs/DialogCatalog.cs;
/// Ruling 7: «сервис держит зеркало мониторинга в памяти», ядро — владелец списка мониторинга в своей БД).
///
/// Тенант хранит два набора id диалогов: полный каталог (из refresh_dialogs/realtime_sweep, L468519)
/// и подмножество с включённым мониторингом. Мониторинг актуализируется тремя путями:
/// * командой SetMonitor/SetMonitorAll ядра (обновление одной записи / всех сразу);
/// * ответом Ingress.SyncDialogs (ядро применило entries с autoMonitorNew — L244246 «_reload_monitored»);
/// * очисткой после Logout/отключения аккаунта (прототип L203: `_monitored.clear()`).
/// Все операции потокобезопасны; зеркало чисто в памяти — потерю при рестарте догоняет realtime_sweep.
/// Зеркало каталога диалогов тенанта в памяти telegram-service.
/// </summary>
public sealed class DialogCatalog
{
@@ -20,8 +12,7 @@ public sealed class DialogCatalog
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>
@@ -36,9 +27,7 @@ public sealed class DialogCatalog
}
/// <summary>
/// Включает/выключает мониторинг одного диалога (SetMonitor RPC, set_monitor L536546). Команда
/// приходит от ядра после обновления его БД; зеркало повторяет решение без собственной проверки
/// «известности» — запись может появиться раньше каталога (включение после рестарта сервиса).
/// Включает/выключает мониторинг одного диалога.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Id диалога.</param>
@@ -66,8 +55,7 @@ public sealed class DialogCatalog
}
/// <summary>
/// Заменяет monitored-набор ответом SyncDialogs (актуальный список ядра после SyncFromTelegram —
/// авто-мониторинг новых/удаление отсутствующих, Ruling 7). Это авторитетный источник зеркала.
/// Заменяет monitored-набор ответом SyncDialogs.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="monitoredIds">Id диалогов с включённым мониторингом.</param>
@@ -82,9 +70,7 @@ public sealed class DialogCatalog
}
/// <summary>
/// Включает/выключает мониторинг всех диалогов каталога (SetMonitorAll RPC, set_monitor_all
/// L548–567). При включении учитываются и уже мониторящиеся записи (каталог может отставать от БД
/// ядра после рестарта — «монитор» из ответа SyncDialogs приедет следующим циклом realtime_sweep).
/// Включает/выключает мониторинг всех диалогов каталога.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="enabled">Мониторить все (true) или снять мониторинг со всех (false).</param>
@@ -111,7 +97,7 @@ public sealed class DialogCatalog
}
/// <summary>
/// Проверяет, мониторится ли диалог (фильтр realtime-событий L262 и догона sweep L429).
/// Проверяет, мониторится ли диалог.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Id диалога.</param>
@@ -130,7 +116,7 @@ public sealed class DialogCatalog
}
/// <summary>
/// Сколько диалогов знает каталог тенанта (ответ monitor-all, count каталога).
/// Сколько диалогов знает каталог тенанта
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <returns>Размер каталога; 0 — каталог ещё не синхронизирован (нет refresh/sweep).</returns>
@@ -148,7 +134,7 @@ public sealed class DialogCatalog
}
/// <summary>
/// Возвращает копию мониторящихся id тенанта (для логов/тестов).
/// Возвращает копию мониторящихся id тенанта
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <returns>Набор мониторящихся id (пустой, если тенанта нет).</returns>
@@ -168,7 +154,7 @@ public sealed class DialogCatalog
}
/// <summary>
/// Сбрасывает состояние тенанта (Logout/отключение аккаунта — прототип L203).
/// Сбрасывает состояние тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public void Reset(string tenantId)
@@ -185,7 +171,6 @@ public sealed class DialogCatalog
}
}
// Полный каталог тенанта (создаёт пустой при первом обращении). Вызывается под _gate.
// tenantId: Id тенанта.
private HashSet<string> KnownSet(string tenantId)
{
@@ -198,7 +183,6 @@ public sealed class DialogCatalog
return known;
}
// Monitored-набор тенанта (создаёт пустой при первом обращении). Вызывается под _gate.
// tenantId: Id тенанта.
private HashSet<string> MonitoredSet(string tenantId)
{
@@ -3,13 +3,10 @@ using System.Text;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Детерминированный цвет диалога из палитры DIALOG_HUES (1:1 с dialog_hue python-прототипа
/// telegram.py L876880 + backend/app/constants.py DIALOG_HUES; Ruling 7: «hue считает сервис»).
/// Палитра и хэш идентичны прототипу, чтобы цвета источников совпадали между реализациями.
/// Детерминированный цвет диалога из палитры DIALOG_HUES.
/// </summary>
public static class DialogHue
{
// Палитра диалоговых цветов (hex) — 1:1 constants.py DIALOG_HUES (8 цветов).
private static readonly string[] Palette =
[
"#3b82f6",
@@ -23,12 +20,10 @@ public static class DialogHue
];
/// <summary>
/// Цвет источника по id диалога и имени: хэш по кодовым точкам (dialog_id + name) по модулю длины
/// палитры — 1:1 dialog_hue прототипа (порядок символов и множитель 31 сохранены; EnumerateRunes
/// повторяет итерацию по Unicode-кодовым точкам python `for ch in ...`).
/// Цвет источника по id диалога и имени
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="name">Отображаемое имя (title/first_name); пустое — как в прототипе.</param>
/// <param name="name">Отображаемое имя (title/first_name); пустое — как в.</param>
/// <returns>Цвет палитры, hex "#rrggbb".</returns>
public static string Compute(string dialogId, string name)
{
@@ -5,15 +5,12 @@ 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).
/// Маппер нейтральных результатов сессии в protobuf-контракт.
/// </summary>
public static class DialogProtoMapper
{
/// <summary>
/// Нейтральный диалог → DialogEntry контракта (id/name/username/kind/hue).
/// Нейтральный диалог → DialogEntry контракта
/// </summary>
/// <param name="dialog">Диалог из списка сессии.</param>
/// <returns>Запись каталога контракта с цветом палитры.</returns>
@@ -28,7 +25,7 @@ public static class DialogProtoMapper
};
/// <summary>
/// Нейтральное сообщение → PreviewMessage превью (id строкой, время epoch-ms).
/// Нейтральное сообщение → PreviewMessage превью
/// </summary>
/// <param name="message">Сообщение диалога (непустой текст).</param>
/// <returns>Сообщение превью ReadRecentReply (от новых к старым).</returns>
@@ -41,7 +38,7 @@ public static class DialogProtoMapper
};
/// <summary>
/// Нейтральное сообщение → PushMessageRequest ингресса (1:1 QueuedMessage, Ruling 7).
/// Нейтральное сообщение → PushMessageRequest ингресса.
/// </summary>
/// <param name="message">Сообщение диалога (непустой текст).</param>
/// <returns>Запрос Ingress.PushMessage с канальными полями и дубль-гвардом msg_id.</returns>
@@ -1,17 +1,12 @@
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 L3536);
/// тесты подставляют фейк-пейсер, записывающий запрошенные диапазоны (fake clock, план Task 10).
/// Seam анти-бан-пауз между сетевыми операциями каталога
/// </summary>
public interface IBackfillPacer
{
/// <summary>
/// Ждёт случайное время в диапазоне [minSeconds, maxSeconds] (анти-бан).
/// Ждёт случайное время в диапазоне [minSeconds, maxSeconds]
/// </summary>
/// <param name="minSeconds">Нижняя граница паузы, секунды (≥ 0).</param>
/// <param name="maxSeconds">Верхняя граница паузы, секунды (≥ minSeconds).</param>
@@ -1,8 +1,7 @@
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Реальная реализация <see cref="IBackfillPacer"/>: случайная пауза в диапазоне (Random.Shared) —
/// эквивалент `await asyncio.sleep(random.uniform(min, max))` прототипа (telegram.py L381/L347).
/// Реальная реализация <see cref="IBackfillPacer"/>
/// </summary>
public sealed class RandomBackfillPacer : IBackfillPacer
{
@@ -6,13 +6,7 @@ using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Realtime-listener мониторящихся диалогов одного тенанта (план Task 10, Dialogs/RealtimeListener.cs;
/// прототип _on_message L255283). Подписывается на события <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).
/// Realtime-listener мониторящихся диалогов одного тенанта.
/// </summary>
public sealed class RealtimeListener
{
@@ -25,7 +19,7 @@ public sealed class RealtimeListener
/// Создаёт listener сессии.
/// </summary>
/// <param name="session">Ready-сессия тенанта (события сообщений её клиента).</param>
/// <param name="catalog">Зеркало мониторинга (фильтр «мониторится ли диалог», Ruling 7).</param>
/// <param name="catalog">Зеркало мониторинга.</param>
/// <param name="ingress">Канал в ядро (PushMessage).</param>
/// <param name="logger">Логгер.</param>
public RealtimeListener(
@@ -41,7 +35,7 @@ public sealed class RealtimeListener
}
/// <summary>
/// Подписывает listener на события сессии (вызывается при готовности сессии).
/// Подписывает listener на события сессии
/// </summary>
public void Start()
{
@@ -50,7 +44,7 @@ public sealed class RealtimeListener
}
/// <summary>
/// Отписывает listener (сессия ушла из ready/остановка хоста).
/// Отписывает listener
/// </summary>
public void Stop()
{
@@ -87,7 +81,6 @@ public sealed class RealtimeListener
}
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",
@@ -6,32 +6,27 @@ using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Страховочная догонялка realtime (план Task 10, Dialogs/RealtimeSweep.cs; прототип realtime_sweep
/// L392456, цикл 30 с). Для каждой ready-сессии: список диалогов → синхронизация каталога с ядром
/// (SyncDialogs: ядро применяет entries с autoMonitorNew и отвечает актуальным monitored — зеркало
/// восстанавливается после рестарта сервиса) → по мониторящимся диалогам с unread_count > 0 читает
/// до 10 непрочитанных (от старых к новым), отправляет в ядро PushMessage и снимает «новое»
/// (read-ack). Потерянные realtime-события (рестарт/разрыв/сбой PushMessage) догоняются здесь.
/// Страховочная догонялка realtime.
/// </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)).
/// Запас сообщений сверх unread_count при чтении
/// </summary>
public const int UnreadSlackMessages = 2;
/// <summary>
/// Потолок сообщений одного диалога за цикл (прототип L435: 10).
/// Потолок сообщений одного диалога за цикл.
/// </summary>
public const int MaxMessagesPerDialog = 10;
@@ -60,7 +55,7 @@ public sealed class RealtimeSweep
}
/// <summary>
/// Один цикл догона: все ready-сессии по очереди (сбой тенанта не останавливает остальных).
/// Один цикл догона
/// </summary>
/// <param name="cancellationToken">Отмена цикла.</param>
public async Task SweepAllAsync(CancellationToken cancellationToken)
@@ -105,7 +100,6 @@ public sealed class RealtimeSweep
return;
}
// Синхронизация каталога: зеркало мониторинга восстанавливается ответом ядра (L399–426).
List<string> dialogIds = dialogs.Select(dialog => dialog.Id).ToList();
_catalog.ReplaceKnown(tenantId, dialogIds);
List<DialogEntry> entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList();
@@ -136,7 +130,6 @@ public sealed class RealtimeSweep
}
}
// Догон одного диалога: чтение непрочитанных → PushMessage → read-ack (L427456).
// tenantId: Id тенанта.
// dialog: Диалог с unread_count &gt; 0.
// cancellationToken: Отмена операции.
@@ -173,7 +166,6 @@ public sealed class RealtimeSweep
added);
}
// Снимаем «новое» в Telegram (прототип L454). При ошибке выше — без read-ack: следующий
// цикл увидит unread снова и дочитает то, что не ушло в ядро.
await _sessionFarm.MarkReadAsync(tenantId, dialog.Id, cancellationToken).ConfigureAwait(false);
}
@@ -6,35 +6,27 @@ using Grpc.Core;
namespace Deal.Telegram.Discovery;
/// <summary>
/// Discovery-операции telegram-service поверх пула сессий тенантов (план Task 11; 1:1 discovery_* методов
/// python-прототипа telegram.py L622873). Каждая операция исполняется на сессии своего тенанта
/// (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") — базовый флуд-гард сессии.
/// Discovery-операции telegram-service поверх пула сессий тенантов.
/// </summary>
public sealed class DiscoveryOps
{
/// <summary>
/// Верхняя граница поиска по умолчанию, если core не передал (прототип: 30, L624).
/// Верхняя граница поиска по умолчанию, если core не передал.
/// </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).
/// Размер выборки чтения по умолчанию, если core не передал
/// </summary>
public const int ReadForEvalDefaultLimit = 30;
@@ -59,13 +51,11 @@ public sealed class DiscoveryOps
}
/// <summary>
/// Глобальный поиск источников по ключу (discovery_search L624664). Выполняет поиск на сессии тенанта,
/// затем держит анти-бан-паузу 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="limit">Верхняя граница результата.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Найденные источники (каналы/группы/личные) без дублей, не длиннее limit.</returns>
public async Task<IReadOnlyList<TelegramDialog>> SearchAsync(
@@ -79,13 +69,12 @@ public sealed class DiscoveryOps
.SearchAsync(tenantId, query, effectiveLimit, cancellationToken)
.ConfigureAwait(false);
// Пауза между поисковыми запросами (анти-бан; Ruling 3, ban_guard.search_pause L7880).
await _pacer.WaitAsync(SearchPauseMinSeconds, SearchPauseMaxSeconds, cancellationToken).ConfigureAwait(false);
return DedupeAndCap(found, effectiveLimit);
}
/// <summary>
/// Инфо об источнике для оценки кандидата (discovery_info L666716).
/// Инфо об источнике для оценки кандидата.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id источника («-100…»/«-…»/«+…»).</param>
@@ -98,7 +87,7 @@ public sealed class DiscoveryOps
=> _sessionFarm.GetInfoAsync(tenantId, dialogId, cancellationToken);
/// <summary>
/// Выборка последних сообщений источника для оценки (discovery_read L718800).
/// Выборка последних сообщений источника для оценки.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id источника.</param>
@@ -113,9 +102,7 @@ public sealed class DiscoveryOps
=> _sessionFarm.ReadForEvalAsync(tenantId, dialogId, limit > 0 ? limit : ReadForEvalDefaultLimit, cancellationToken);
/// <summary>
/// Вступить в канал/группу по username (discovery_join L818839; ручной join из API — вне квот, паузу
/// перед авто-join делает воркер ядра, Ruling 10). Пустой username → INVALID_ARGUMENT; FloodWait →
/// SessionException RESOURCE_EXHAUSTED с префиксом "flood" (флуд-гард, контракт telegram.proto).
/// Вступить в канал/группу по username.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="username">Username источника («@»/пробелы нормализуются).</param>
@@ -136,7 +123,7 @@ public sealed class DiscoveryOps
}
/// <summary>
/// Выйти из канала/группы (discovery_leave L841848).
/// Выйти из канала/группы.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
@@ -148,14 +135,13 @@ public sealed class DiscoveryOps
=> _sessionFarm.LeaveAsync(tenantId, dialogId, cancellationToken);
/// <summary>
/// Нормализует username вступления 1:1 прототипа L826 (strip + lstrip "@"): срезает пробелы и ведущие «@».
/// Нормализует username вступления
/// </summary>
/// <param name="username">Username из запроса (может быть пуст/null).</param>
/// <returns>Нормализованный username (пустой — вступать не по чему).</returns>
public static string NormalizeUsername(string? username)
=> (username ?? string.Empty).Trim().TrimStart('@');
// Убирает дубли id и обрезает результат до лимита, сохраняя порядок (1:1 L643663).
// found: Результат поиска сессии (chats затем users).
// limit: Верхняя граница числа записей.
private static IReadOnlyList<TelegramDialog> DedupeAndCap(IReadOnlyList<TelegramDialog> found, int limit)
@@ -5,16 +5,12 @@ 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-слой.
/// Маппер нейтральных результатов discovery в protobuf-контракт.
/// </summary>
public static class DiscoveryProtoMapper
{
/// <summary>
/// Нейтральное инфо источника → ChannelInfo контракта (participants/is_forum, hue).
/// Нейтральное инфо источника → ChannelInfo контракта
/// </summary>
/// <param name="info">Инфо из сессии (по умолчанию — только id/name).</param>
public static ChannelInfo ToChannelInfo(TelegramSourceInfo info)
@@ -38,7 +34,7 @@ public static class DiscoveryProtoMapper
}
/// <summary>
/// Сообщение выборки → EvalMessage контракта (темы форума — optional-поля).
/// Сообщение выборки → EvalMessage контракта
/// </summary>
/// <param name="message">Сообщение discovery-read (непустой текст).</param>
public static EvalMessage ToEvalMessage(DiscoveryMessage message)
@@ -64,7 +60,7 @@ public static class DiscoveryProtoMapper
}
/// <summary>
/// Результат чтения выборки → ReadForEvalReply контракта (ok/error/messages).
/// Результат чтения выборки → ReadForEvalReply контракта
/// </summary>
/// <param name="result">Результат из сессии (ok=false → error="no_history").</param>
public static ReadForEvalReply ToReadForEvalReply(DiscoveryReadResult result)
@@ -8,7 +8,7 @@ namespace Deal.Telegram.Extensions;
internal static class ExceptionExtensions
{
/// <summary>
/// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE (безопасный повтор).
/// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE
/// </summary>
/// <param name="exception">Исключение для классификации.</param>
/// <returns>True — исключение транспортного/сетевого характера.</returns>
@@ -5,17 +5,12 @@ 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).
/// Фоновый reconcile realtime-listener'ов.
/// </summary>
public sealed class RealtimeMonitorService : BackgroundService
{
/// <summary>
/// Период reconcile подписок (сек).
/// Период reconcile подписок
/// </summary>
public const int ReconcileIntervalSeconds = 2;
@@ -3,10 +3,7 @@ using Deal.Telegram.Dialogs;
namespace Deal.Telegram.Hosting;
/// <summary>
/// Фоновый цикл догона realtime (план Task 10; эталон SessionHeartbeatService). Каждые 30 секунд
/// вызывает <see cref="RealtimeSweep.SweepAllAsync"/> по ready-сессиям; первый проход — сразу после
/// старта (зеркало мониторинга восстанавливается после рестарта, упущенное догоняется — Ruling 7).
/// Сбои отдельного цикла не роняют хост.
/// Фоновый цикл догона realtime.
/// </summary>
public sealed class RealtimeSweepService : BackgroundService
{
@@ -3,16 +3,12 @@ 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.
/// Фоновый цикл сессий telegram-service.
/// </summary>
public sealed class SessionHeartbeatService : BackgroundService
{
/// <summary>
/// Период цикла сердцебиения (сек; прототип heartbeat вызывается планировщиком).
/// Период цикла сердцебиения.
/// </summary>
public const int HeartbeatIntervalSeconds = 30;
@@ -31,8 +27,7 @@ public sealed class SessionHeartbeatService : BackgroundService
}
/// <summary>
/// Первый проход — auto_resume файлов сессий (создаёт ready-сессии после рестарта контейнера);
/// затем периодический heartbeat оборвавшихся соединений.
/// Первый проход — auto_resume файлов сессий
/// </summary>
/// <param name="stoppingToken">Токен остановки хоста.</param>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -65,7 +60,7 @@ public sealed class SessionHeartbeatService : BackgroundService
}
/// <summary>
/// При остановке хоста сохраняет живые сессии (перешифровка при остановке, Ruling 3).
/// При остановке хоста сохраняет живые сессии.
/// </summary>
/// <param name="cancellationToken">Токен остановки.</param>
public override async Task StopAsync(CancellationToken cancellationToken)
@@ -1,15 +1,9 @@
// telegram-service — точка входа gRPC-хоста (план Task 2, L227238; 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.Interceptors;
using Deal.Grpc.Hosting.Models;
@@ -17,17 +11,13 @@ using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
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().
@@ -39,10 +29,8 @@ WebApplication app = TelegramServiceHost.Create(
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 не должны давать
@@ -1,38 +1,37 @@
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).
/// SMS-код отправлен, ожидается код
/// </summary>
Code,
/// <summary>
/// Включена двухфакторная аутентификация — нужен облачный пароль (submit_password).
/// Включена двухфакторная аутентификация — нужен облачный пароль
/// </summary>
Password,
/// <summary>
/// QR-вход запущен: ожидается сканирование, qr_url актуален.
/// QR-вход запущен
/// </summary>
Qr,
/// <summary>
/// Аккаунт авторизован, сессия сохранена (клиент готов исполнять команды).
/// Аккаунт авторизован, сессия сохранена
/// </summary>
Ready,
}
@@ -1,60 +1,57 @@
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).
/// Неверный SMS-код входа
/// </summary>
public const string WrongCode = "Неверный код";
/// <summary>
/// SMS-код истёк, нужен новый (INVALID_ARGUMENT).
/// SMS-код истёк, нужен новый
/// </summary>
public const string CodeExpired = "Код истёк — запросите новый";
/// <summary>
/// Неверный облачный пароль 2FA (INVALID_ARGUMENT).
/// Неверный облачный пароль 2FA
/// </summary>
public const string WrongPassword = "Неверный облачный пароль";
/// <summary>
/// SendCode вызван вне фазы "code" (FAILED_PRECONDITION).
/// SendCode вызван вне фазы "code"
/// </summary>
public const string CodeNotRequested = "Код не запрашивался — начните вход по номеру телефона";
/// <summary>
/// SendPassword вызван вне фазы "password" (FAILED_PRECONDITION).
/// SendPassword вызван вне фазы "password"
/// </summary>
public const string PasswordNotRequested = "2FA-пароль не запрашивался — сначала отправьте код";
/// <summary>
/// Metadata tenant-id отсутствует или пуст (UNAUTHENTICATED).
/// Metadata tenant-id отсутствует или пуст
/// </summary>
public const string TenantIdMissing = "tenant-id отсутствует в metadata";
/// <summary>
/// Некорректный tenant-id (INVALID_ARGUMENT).
/// Некорректный tenant-id
/// </summary>
public const string InvalidTenantId = "Некорректный tenant-id в metadata";
/// <summary>
/// Telegram/сеть недоступны (UNAVAILABLE).
/// Telegram/сеть недоступны
/// </summary>
public const string TelegramUnavailable = "Telegram недоступен — повторите попытку позже";
@@ -64,47 +61,47 @@ public static class SessionErrorMessages
public const string IngressUnavailable = "Ядро недоступно — повторите попытку позже";
/// <summary>
/// Диалог не найден в аккаунте/кэше сущностей сессии (INVALID_ARGUMENT).
/// Диалог не найден в аккаунте/кэше сущностей сессии
/// </summary>
public const string UnknownDialog = "Источник не найден в аккаунте — обновите список каналов";
/// <summary>
/// Пустой username вступления (INVALID_ARGUMENT; 1:1 ValueError discovery_join L828).
/// Пустой username вступления.
/// </summary>
public const string JoinUsernameMissing = "Не указан username для вступления";
/// <summary>
/// Поисковый запрос длиннее верхней границы (INVALID_ARGUMENT; защита границы сервиса).
/// Поисковый запрос длиннее верхней границы
/// </summary>
public const string SearchQueryTooLong = "Слишком длинный поисковый запрос";
/// <summary>
/// Username вступления длиннее лимита Telegram (INVALID_ARGUMENT).
/// Username вступления длиннее лимита Telegram
/// </summary>
public const string UsernameTooLong = "Слишком длинный username";
/// <summary>
/// Внутренняя ошибка сервиса (INTERNAL; сбой реализации, а не транспорт/сеть).
/// Внутренняя ошибка сервиса
/// </summary>
public const string InternalError = "Внутренняя ошибка сервиса — повторите попытку позже";
/// <summary>
/// По username найден не канал/группа (личный чат/бот) — вступить нельзя (INVALID_ARGUMENT).
/// По username найден не канал/группа
/// </summary>
public const string JoinTargetNotChannel = "По этому username найден не канал/группа — вступить нельзя";
/// <summary>
/// Некорректный (неподписанный) id диалога в запросе (INVALID_ARGUMENT).
/// Некорректный
/// </summary>
public const string InvalidDialogId = "Некорректный id источника";
/// <summary>
/// Телефон не зарегистрирован в Telegram (регистрация из сервиса не выполняется).
/// Телефон не зарегистрирован в Telegram
/// </summary>
public const string SignUpRequired = "Номер не зарегистрирован в Telegram — зарегистрируйте его в приложении Telegram";
/// <summary>
/// Ключ шифрования сессий не задан в env (служебная ошибка конфигурации).
/// Ключ шифрования сессий не задан в env
/// </summary>
public const string SessionKeyNotConfigured = "Ключ шифрования сессий не задан (DEAL_TELEGRAM_SESSION_KEY)";
}
@@ -3,12 +3,7 @@ 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).
/// Доменная ошибка сессий telegram-service.
/// </summary>
public sealed class SessionException : Exception
{
@@ -16,7 +11,7 @@ public sealed class SessionException : Exception
/// Создаёт ошибку сессии с gRPC-кодом, в который она должна превратиться на границе.
/// </summary>
/// <param name="code">gRPC-статус ошибки (контракт telegram.proto, шапка файла).</param>
/// <param name="message">Текст причины — detail RPC (1:1 с текстами прототипа).</param>
/// <param name="message">Текст причины — detail RPC.</param>
/// <param name="innerException">Внутренняя причина (исключение Telegram/адаптера), если есть.</param>
public SessionException(
StatusCode code,
@@ -5,10 +5,7 @@ using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Пул сессий тенантов «1 аккаунт на тенанта» (план Task 9, Sessions/SessionFarm.cs; Ruling 3,
/// архитектура §7.1). Сессия создаётся на первый вход/возобновление и переиспользуется (Logout
/// сбрасывает её в состояние «отключено» — из карты объект не удаляется, гонок вызова нет).
/// Все сетевые команды исполняются на сессии своего тенанта (Ruling 1); операций по чужим сессиям нет.
/// Пул сессий тенантов «1 аккаунт на тенанта».
/// </summary>
public sealed class SessionFarm
{
@@ -38,20 +35,20 @@ public sealed class SessionFarm
}
/// <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>
@@ -63,7 +60,7 @@ public sealed class SessionFarm
=> RequireSession(tenantId).ListDialogsAsync(limit, cancellationToken);
/// <summary>
/// Последние сообщения диалога тенанта (только ready-сессия; план Task 10).
/// Последние сообщения диалога тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
@@ -77,7 +74,7 @@ public sealed class SessionFarm
=> RequireSession(tenantId).GetMessagesAsync(dialogId, limit, cancellationToken);
/// <summary>
/// Помечает диалог тенанта прочитанным (только ready-сессия; план Task 10).
/// Помечает диалог тенанта прочитанным.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
@@ -89,7 +86,7 @@ public sealed class SessionFarm
=> RequireSession(tenantId).MarkReadAsync(dialogId, cancellationToken);
/// <summary>
/// Глобальный поиск каналов/групп по ключу (только ready-сессия; план Task 11).
/// Глобальный поиск каналов/групп по ключу.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
@@ -103,7 +100,7 @@ public sealed class SessionFarm
=> RequireSession(tenantId).SearchAsync(query, limit, cancellationToken);
/// <summary>
/// Инфо об источнике для оценки кандидата (только ready-сессия; план Task 11).
/// Инфо об источнике для оценки кандидата.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id источника.</param>
@@ -115,7 +112,7 @@ public sealed class SessionFarm
=> RequireSession(tenantId).GetInfoAsync(dialogId, cancellationToken);
/// <summary>
/// Выборка сообщений источника для оценки (только ready-сессия; план Task 11).
/// Выборка сообщений источника для оценки.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id источника.</param>
@@ -129,7 +126,7 @@ public sealed class SessionFarm
=> RequireSession(tenantId).ReadForEvalAsync(dialogId, limit, cancellationToken);
/// <summary>
/// Вступить в канал/группу по username (только ready-сессия; план Task 11).
/// Вступить в канал/группу по username.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="username">Username (без «@»; нормализует DiscoveryOps).</param>
@@ -141,7 +138,7 @@ public sealed class SessionFarm
=> RequireSession(tenantId).JoinAsync(username, cancellationToken);
/// <summary>
/// Выйти из канала/группы (только ready-сессия; план Task 11).
/// Выйти из канала/группы.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
@@ -153,7 +150,7 @@ public sealed class SessionFarm
=> RequireSession(tenantId).LeaveAsync(dialogId, cancellationToken);
/// <summary>
/// Вход по телефону: сессия создаётся при первом обращении.
/// Вход по телефону
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="apiId">api_id приложения.</param>
@@ -207,7 +204,7 @@ public sealed class SessionFarm
=> RequireSession(tenantId).SendPasswordAsync(password, cancellationToken);
/// <summary>
/// Отключение аккаунта: Auth_LogOut + удаление файла сессии тенанта.
/// Отключение аккаунта
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -224,7 +221,7 @@ public sealed class SessionFarm
}
/// <summary>
/// Статус сессии тенанта; null — аккаунт не подключён (сессии нет).
/// Статус сессии тенанта; null — аккаунт не подключён
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -235,8 +232,7 @@ public sealed class SessionFarm
}
/// <summary>
/// Авто-возобновление на старте (auto_resume L209222): для каждого файла сессии на диске создаёт
/// клиент и при авторизации переводит тенанта в "ready". Сбои не роняют старт (внутри TryResumeAsync).
/// Авто-возобновление на старте
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task ResumeAllAsync(CancellationToken cancellationToken)
@@ -270,7 +266,7 @@ public sealed class SessionFarm
}
/// <summary>
/// Сердцебиение (30 с): повторное подключение оборвавшихся "ready"-сессий (heartbeat L318327).
/// Сердцебиение
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task HeartbeatTickAsync(CancellationToken cancellationToken)
@@ -287,8 +283,7 @@ public sealed class SessionFarm
}
/// <summary>
/// Остановка хоста: сохраняет живые сессии (перешифровка при остановке, Ruling 3) и освобождает
/// клиенты. Ошибки отдельных сессий не останавливают остальные.
/// Остановка хоста
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task ShutdownAsync(CancellationToken cancellationToken)
@@ -3,18 +3,12 @@ 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 создаётся на операцию — разделяемого криптографического состояния нет.
/// AES-256-GCM-обёртка файла сессии тенанта.
/// </summary>
public sealed class SessionFileCipher
{
/// <summary>
/// Префикс зашифрованного значения (маркер формата в файле сессии).
/// Префикс зашифрованного значения
/// </summary>
public const string EncryptedPrefix = "enc:";
@@ -27,7 +21,7 @@ public sealed class SessionFileCipher
private readonly byte[] _key;
/// <summary>
/// Создаёт шифр с ключом из опций (env DEAL_TELEGRAM_SESSION_KEY).
/// Создаёт шифр с ключом из опций
/// </summary>
/// <param name="options">Опции хранения сессий.</param>
public SessionFileCipher(TgOptions options)
@@ -36,8 +30,7 @@ public sealed class SessionFileCipher
}
/// <summary>
/// Шифрует содержимое файла сессии: случайный nonce + AES-GCM, возвращает значение
/// <c>enc:</c>+Base64(nonce ‖ шифротекст ‖ tag) — готовый текст файла data/sessions/&lt;tenant&gt;.session.
/// Шифрует содержимое файла сессии
/// </summary>
/// <param name="plainBytes">Открытое содержимое сессии (расшифрованная копия в памяти процесса).</param>
/// <returns>Зашифрованное значение для записи в файл.</returns>
@@ -63,9 +56,7 @@ public sealed class SessionFileCipher
}
/// <summary>
/// Расшифровывает значение файла сессии. null — значение не в формате сервиса (не enc: или
/// не base64): файл чужой/повреждён и трактуется как отсутствующий. Несовпадение тега/чужой
/// ключ — <see cref="CryptographicException"/> (файл повреждён или ключ сменился).
/// Расшифровывает значение файла сессии.
/// </summary>
/// <param name="encryptedValue">Содержимое файла сессии (enc: + base64).</param>
/// <returns>Открытые байты сессии либо null (не наш формат).</returns>
@@ -5,19 +5,12 @@ using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Файловое хранилище сессий тенантов (план Task 9; Ruling 3) — аналог SessionManager/SessionStore
/// задачи: файлы data/sessions/&lt;tenantId&gt;.session, содержимое — AES-GCM-обёртка
/// (<see cref="SessionFileCipher"/>) сериализованного <see cref="StoredSession"/>.
///
/// Запись — атомарная (временный файл в том же каталоге + File.Move), чтобы рестарт/сбой
/// не оставил битый файл сессии; все записи сериализованы одним семафором (файлы маленькие,
/// запись редкая). Нечитаемый/повреждённый файл трактуется как отсутствие сессии (лог-warning),
/// файл не удаляется — диагностика сохраняется.
/// Файловое хранилище сессий тенантов SessionManager/SessionStore задачи
/// </summary>
public sealed class SessionStore
{
/// <summary>
/// Расширение файла сессии (data/sessions/&lt;tenantId&gt;.session).
/// Расширение файла сессии
/// </summary>
public const string SessionFileExtension = ".session";
@@ -43,10 +36,9 @@ public sealed class SessionStore
}
/// <summary>
/// Читает и расшифровывает сессию тенанта. Возвращает null, если файла нет или он нечитаем
/// (чужой формат/повреждён/другой ключ — лог-warning; файл сохраняется для диагностики).
/// Читает и расшифровывает сессию тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта (принадлежность сессии; Ruling 3 — 1 аккаунт на тенанта).</param>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task<StoredSession?> LoadAsync(string tenantId, CancellationToken cancellationToken = default)
{
@@ -99,7 +91,7 @@ public sealed class SessionStore
}
/// <summary>
/// Шифрует и атомарно сохраняет сессию тенанта (создаёт каталог при первом сохранении).
/// Шифрует и атомарно сохраняет сессию тенанта
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="session">Открытое содержимое сессии для шифрования at-rest.</param>
@@ -129,7 +121,7 @@ public sealed class SessionStore
}
/// <summary>
/// Удаляет файл сессии тенанта (Logout). Отсутствие файла не считается ошибкой.
/// Удаляет файл сессии тенанта
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -145,8 +137,7 @@ public sealed class SessionStore
}
/// <summary>
/// Перечисляет id тенантов, для которых на диске есть файл сессии (auto_resume на старте).
/// Каталога нет — пустой список (каталог создаётся лениво, при первом сохранении).
/// Перечисляет id тенантов, для которых на диске есть файл сессии
/// </summary>
public IEnumerable<string> ListTenantIds()
{
@@ -1,18 +1,12 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Открытое содержимое файла сессии тенанта (план Task 9; Ruling 3).
///
/// Файл data/sessions/&lt;tenant&gt;.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;
@@ -32,7 +26,7 @@ public sealed record StoredSession
public string ApiHash { get; init; } = string.Empty;
/// <summary>
/// Байты файла сессии WTelegramClient (внутренне уже зашифрованы библиотекой).
/// Байты файла сессии WTelegramClient
/// </summary>
public byte[] SessionBytes { get; init; } = [];
}
@@ -4,15 +4,7 @@ using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Сессия тенанта: id тенанта, клиент Telegram и состояние входа (план Task 9, Sessions/TenantSession.cs).
///
/// Соответствует TelegramManager прототипа (telegram.py L82222) для одного тенанта: 1 аккаунт на
/// тенанта (Ruling 3/архитектура §7.1), фазы idle|phone|code|password|qr|ready, error/qrUrl/account.
/// Все операции сериализованы per-tenant семафором <see cref="_gate"/> (команды исполняются только
/// на сессии своего тенанта; Ruling 1). Клиент создаётся фабрикой под ключи приложения из запроса;
/// авторизованная сессия сохраняется в файл data/sessions/&lt;tenant&gt;.session (AES-GCM-обёртка).
/// QR-вход выполняется фоновой задачей: RPC возвращается после первого URL, сканирование/ошибки
/// обновляют состояние в фоне (как _wait_qr прототипа L302312).
/// Сессия тенанта: id тенанта, клиент Telegram и состояние входа.
/// </summary>
public sealed class TenantSession : IAsyncDisposable
{
@@ -44,18 +36,17 @@ public sealed class TenantSession : IAsyncDisposable
private volatile bool _listenerActive;
/// <summary>
/// Realtime-listener сессии жив (включает RealtimeMonitorService при фазе Ready).
/// Realtime-listener сессии жив
/// </summary>
public AuthPhase Phase => _phase;
/// <summary>
/// Событие входящего сообщения аккаунта (план Task 10; поднимается для всех текстовых сообщений
/// клиента). RealtimeListener службы подписывается на сессию и фильтрует по зеркалу мониторинга.
/// Событие входящего сообщения аккаунта.
/// </summary>
public event Func<TelegramMessage, Task>? MessageReceived;
/// <summary>
/// Создаёт сессию тенанта (объект переиспользуется между входами/выходами).
/// Создаёт сессию тенанта
/// </summary>
/// <param name="tenantId">Id тенанта (принадлежность сессии).</param>
/// <param name="clientFactory">Фабрика клиентов Telegram (реальная или фейк в тестах).</param>
@@ -77,12 +68,12 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Id тенанта, которому принадлежит сессия (команды только своей сессии).
/// Id тенанта, которому принадлежит сессия
/// </summary>
public string TenantId { get; }
/// <summary>
/// Вход по номеру телефона: запросить SMS-код (start_phone L134147).
/// Вход по номеру телефона
/// </summary>
/// <param name="apiId">api_id приложения (из тела запроса ядра).</param>
/// <param name="apiHash">api_hash приложения.</param>
@@ -122,7 +113,6 @@ public sealed class TenantSession : IAsyncDisposable
}
catch (SessionException exception)
{
// 1:1 start_phone L144147: фаза idle + текст ошибки.
_phase = AuthPhase.Idle;
_error = exception.Message;
throw;
@@ -145,7 +135,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Начать QR-вход (qr_start L286300): фаза "qr" + первый URL либо "ready", если уже вошли.
/// Начать QR-вход: фаза "qr" + первый URL либо "ready", если уже вошли.
/// </summary>
/// <param name="apiId">api_id приложения.</param>
/// <param name="apiHash">api_hash приложения.</param>
@@ -168,14 +158,12 @@ public sealed class TenantSession : IAsyncDisposable
await EnsureClientAsync(apiId, apiHash, cancellationToken).ConfigureAwait(false);
if (_client!.IsAuthorized)
{
// 1:1 qr_start L291293: уже авторизованы — финализация, url пуст.
await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false);
return Snapshot();
}
if (_phase == AuthPhase.Qr && _qrWaitTask is { IsCompleted: false })
{
// Повторный вызов во время активного QR — вернуть текущий URL (python L294295).
return Snapshot();
}
@@ -196,7 +184,6 @@ public sealed class TenantSession : IAsyncDisposable
}
catch (SessionException exception)
{
// Ошибка до первого URL (сеть/Telegram): сброс к idle + текст ошибки, как _wait_qr L306309.
_phase = AuthPhase.Idle;
_qrUrl = null;
_error = exception.Message;
@@ -210,7 +197,6 @@ public sealed class TenantSession : IAsyncDisposable
_qrUrl = null;
}
// Отмена ожидания до первого URL отменяет и фоновый QR-вход (_qrCts): иначе задача
// LoginWithQRCode продолжала бы авторизацию «скрыто» после отмены RPC (замечание code-review).
CancelQrFlow();
@@ -233,7 +219,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Отправить SMS-код (submit_code L149166). Фазы вне "code" — FAILED_PRECONDITION.
/// Отправить SMS-код. Фазы вне "code" — FAILED_PRECONDITION.
/// </summary>
/// <param name="code">Код из SMS/Telegram-сообщения.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -258,7 +244,6 @@ public sealed class TenantSession : IAsyncDisposable
}
catch (SessionException exception)
{
// Неверный/истёкший код — фаза остаётся "code" (повтор ввода, как в прототипе).
_error = exception.Message;
throw;
}
@@ -279,7 +264,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Отправить облачный пароль 2FA (submit_password L168176). Фазы вне "password" — FAILED_PRECONDITION.
/// Отправить облачный пароль 2FA.
/// </summary>
/// <param name="password">Облачный пароль.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -303,7 +288,6 @@ public sealed class TenantSession : IAsyncDisposable
}
catch (SessionException exception)
{
// Неверный пароль — фаза остаётся "password" (повтор ввода, как в прототипе).
_error = exception.Message;
throw;
}
@@ -318,7 +302,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Отключить аккаунт и удалить сессию тенанта (disconnect L189207). Возвращает null — сессии больше нет.
/// Отключить аккаунт и удалить сессию тенанта.
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task<TenantSessionSnapshot?> LogoutAsync(CancellationToken cancellationToken)
@@ -372,7 +356,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Снимок состояния для GetStatus; null — сессии тенанта нет (аккаунт не подключён).
/// Снимок состояния для GetStatus; null — сессии тенанта нет
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task<TenantSessionSnapshot?> GetSnapshotAsync(CancellationToken cancellationToken)
@@ -388,12 +372,11 @@ public sealed class TenantSession : IAsyncDisposable
}
}
// --- Каталог и сообщения (план Task 10; исполняются на ready-сессии своего тенанта) ---
/// <summary>
/// Список диалогов аккаунта (refresh_dialogs L505519); фаза обязана быть ready.
/// Список диалогов аккаунта; фаза обязана быть ready.
/// </summary>
/// <param name="limit">Верхняя граница числа диалогов (прототип: 500).</param>
/// <param name="limit">Верхняя граница числа диалогов.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Диалоги аккаунта (нейтральный вид).</returns>
public async Task<IReadOnlyList<TelegramDialog>> ListDialogsAsync(int limit, CancellationToken cancellationToken)
@@ -411,7 +394,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Последние сообщения диалога (get_messages); фаза обязана быть ready.
/// Последние сообщения диалога
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="limit">Сколько последних сообщений запросить.</param>
@@ -435,7 +418,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Помечает диалог прочитанным (send_read_acknowledge); фаза обязана быть ready.
/// Помечает диалог прочитанным
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -453,15 +436,14 @@ public sealed class TenantSession : IAsyncDisposable
}
}
// --- Discovery (план Task 11; discovery_search/info/read/join/leave L622873) ---
/// <summary>
/// Глобальный поиск каналов/групп по ключу (discovery_search L624664); фаза ready.
/// Глобальный поиск каналов/групп по ключу; фаза ready.
/// </summary>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
/// <param name="limit">Верхняя граница результата.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Найденные источники (нейтральный вид; личные чаты отсеивает ядро, Ruling 10).</returns>
/// <returns>Найденные источники.</returns>
public async Task<IReadOnlyList<TelegramDialog>> SearchAsync(
string query,
int limit,
@@ -480,7 +462,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Инфо об источнике для оценки кандидата (discovery_info L666716); фаза ready.
/// Инфо об источнике для оценки кандидата; фаза ready.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -500,7 +482,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Выборка сообщений источника для оценки (discovery_read L718800); фаза ready.
/// Выборка сообщений источника для оценки; фаза ready.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="limit">Размер выборки (limit ≤ 0 — пусто без сети).</param>
@@ -524,7 +506,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Вступить в канал/группу по username (discovery_join L818839); фаза ready.
/// Вступить в канал/группу по username; фаза ready.
/// </summary>
/// <param name="username">Username (без «@»; нормализует DiscoveryOps).</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -543,7 +525,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Выйти из канала/группы (discovery_leave L841848); фаза ready.
/// Выйти из канала/группы; фаза ready.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -562,7 +544,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Ставит признак живого realtime-listener (для GetStatus.listener, L109).
/// Ставит признак живого realtime-listener.
/// </summary>
/// <param name="active">True — listener сессии подписан на события сообщений.</param>
public void SetListenerActive(bool active)
@@ -578,7 +560,6 @@ public sealed class TenantSession : IAsyncDisposable
return client;
}
// Как refresh_dialogs L507508: разорванное соединение ready-сессии поднимаем перед операцией.
try
{
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
@@ -642,9 +623,7 @@ public sealed class TenantSession : IAsyncDisposable
=> client.MessageReceived -= ForwardClientMessageAsync;
/// <summary>
/// Авто-возобновление на старте (auto_resume L209222)...
/// Авто-возобновление на старте (auto_resume L209222): поднять клиент из сохранённой сессии;
/// авторизованная сессия → фаза "ready". Не бросает — сбои сети/сессии оставляют фазу idle.
/// Авто-возобновление на старте...
/// </summary>
/// <param name="stored">Содержимое файла сессии тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -691,7 +670,6 @@ public sealed class TenantSession : IAsyncDisposable
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// 1:1 auto_resume L219221: сбой не роняет старт — фаза idle, ошибка для статуса.
_logger.LogWarning(exception, "auto_resume {TenantId} пропущен", TenantId);
_phase = AuthPhase.Idle;
_error = exception is SessionException sessionException ? sessionException.Message : null;
@@ -705,8 +683,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Сердцебиение (heartbeat L318327): для фазы "ready" при обрыве соединения — повторный connect.
/// Ошибки только логируются; статус-error не меняется (как в прототипе).
/// Сердцебиение: для фазы "ready" при обрыве соединения — повторный connect.
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task TryReconnectAsync(CancellationToken cancellationToken)
@@ -722,7 +699,6 @@ public sealed class TenantSession : IAsyncDisposable
try
{
// Собственный лимит попытки: linked-токен с CancelAfter на время попытки переподключения.
// Зависший connect не держит _gate (heartbeat остальных тенантов и shutdown не блокируются).
using CancellationTokenSource attemptTimeout =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
attemptTimeout.CancelAfter(_reconnectAttemptTimeout);
@@ -748,8 +724,7 @@ public sealed class TenantSession : IAsyncDisposable
}
/// <summary>
/// Остановка (хост гасится): сохраняет текущие байты сессии (перешифровка при остановке, Ruling 3),
/// отменяет QR и освобождает клиент. Ошибки не бросаются (фоновая остановка).
/// Остановка (хост гасится)
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task FlushAndDisposeAsync(CancellationToken cancellationToken)
@@ -802,7 +777,6 @@ public sealed class TenantSession : IAsyncDisposable
}
}
// --- внутренние помощники (вызываются под _gate) ---
// Проверяет, что сессия тенанта существует и не закрыта (иначе «Telegram не подключён»).
private void EnsureLoginStarted()
@@ -813,7 +787,6 @@ public sealed class TenantSession : IAsyncDisposable
}
}
// Ключи приложения обязательны (ядро передаёт их в теле; Ruling 3).
private static void ValidateApiKeys(int apiId, string apiHash)
{
if (apiId <= 0 || string.IsNullOrWhiteSpace(apiHash))
@@ -865,7 +838,6 @@ public sealed class TenantSession : IAsyncDisposable
AttachClientMessages(_client);
}
// Финализация авторизации (_finalize L178187): аккаунт в статус, фаза "ready", сессия сохранена.
// Сбой get_me не отменяет готовность — сохраняем сессию без account (готовность важнее имени).
// cancellationToken: Отмена операции.
private async Task CompleteAuthorizationAsync(CancellationToken cancellationToken)
@@ -1044,7 +1016,6 @@ public sealed class TenantSession : IAsyncDisposable
}
// Обработка ошибки QR после того, как URL уже был выдан (RPC вернулся): фаза idle + текст ошибки
// (1:1 _wait_qr L306309). Ошибку до первого URL сбрасывает StartQrAsync (проброс через задачу).
// exception: Ошибка QR-входа.
private async Task FailQrUnderGateAsync(SessionException exception)
{
@@ -1064,7 +1035,6 @@ public sealed class TenantSession : IAsyncDisposable
}
}
// Строит снимок текущего состояния (без проверки _registered — вызывается под замком).
private TenantSessionSnapshot Snapshot()
=> new(
_phase,
@@ -1,9 +1,7 @@
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 ядро считает само.
/// Снимок состояния сессии тенанта для GetStatus/ответов RPC подключения.
/// </summary>
public sealed record TenantSessionSnapshot
{
@@ -12,7 +10,7 @@ public sealed record TenantSessionSnapshot
/// </summary>
/// <param name="phase">Текущая фаза входа.</param>
/// <param name="connected">Клиент Telegram соединён.</param>
/// <param name="listener">Жив ли realtime-listener (включается задачами каталога, Task 10).</param>
/// <param name="listener">Жив ли realtime-listener.</param>
/// <param name="account">Аккаунт "@username" авторизованного пользователя (иначе null).</param>
/// <param name="error">Текст последней ошибки (иначе null).</param>
/// <param name="qrUrl">URL QR-входа (только при phase == Qr; иначе null).</param>
@@ -38,27 +36,27 @@ public sealed record TenantSessionSnapshot
public AuthPhase Phase { get; }
/// <summary>
/// Клиент Telegram соединён (bool connected статуса прототипа).
/// Клиент Telegram соединён.
/// </summary>
public bool Connected { get; }
/// <summary>
/// Realtime-listener жив (заполняется с Task 10; в задаче сессий — false).
/// Realtime-listener жив.
/// </summary>
public bool Listener { get; }
/// <summary>
/// Аккаунт "@username" (для справки; источник истины — KV tgAccount ядра).
/// Аккаунт "@username"
/// </summary>
public string? Account { get; }
/// <summary>
/// Текст последней ошибки входа/соединения (null — ошибки нет).
/// Текст последней ошибки входа/соединения
/// </summary>
public string? Error { get; }
/// <summary>
/// URL QR-входа (заполнен только при phase == Qr).
/// URL QR-входа
/// </summary>
public string? QrUrl { get; }
}
@@ -1,33 +1,27 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Конфигурация хранения сессий telegram-service (план Task 9 L316318; 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).
/// Конфигурация хранения сессий telegram-service.
/// </summary>
public sealed class TgOptions
{
/// <summary>
/// Env-ключ ключа шифрования сессий (32 байта base64; Ruling 3).
/// Env-ключ ключа шифрования сессий.
/// </summary>
public const string SessionKeyEnvVarName = "DEAL_TELEGRAM_SESSION_KEY";
/// <summary>
/// Env-ключ каталога сессий (опционально; по умолчанию data/sessions под ContentRoot).
/// Env-ключ каталога сессий
/// </summary>
public const string SessionDirEnvVarName = "DEAL_TELEGRAM_SESSION_DIR";
/// <summary>
/// Относительный каталог сессий по умолчанию (под ContentRoot хоста).
/// Относительный каталог сессий по умолчанию
/// </summary>
public const string DefaultSessionDirRelative = "data/sessions";
/// <summary>
/// Размер ключа AES-256 (байт).
/// Размер ключа AES-256
/// </summary>
public const int KeySizeBytes = 32;
@@ -38,18 +32,17 @@ public sealed class TgOptions
}
/// <summary>
/// Ключ AES-256-GCM обёртки файлов сессий (из env DEAL_TELEGRAM_SESSION_KEY).
/// Ключ AES-256-GCM обёртки файлов сессий
/// </summary>
public byte[] SessionKey { get; }
/// <summary>
/// Абсолютный путь к каталогу файлов сессий data/sessions/&lt;tenant&gt;.session.
/// Абсолютный путь к каталогу файлов сессий data/sessions/&lt;tenant&gt.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>
@@ -69,8 +62,7 @@ public sealed class TgOptions
}
/// <summary>
/// Читает конфигурацию из env. Отсутствующий/некорректный DEAL_TELEGRAM_SESSION_KEY —
/// ошибка конфигурации (fail-closed: файлы сессий не могут храниться в открытом виде).
/// Читает конфигурацию из env.
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
/// <param name="environment">Окружение хоста (ContentRootPath для каталога по умолчанию).</param>
@@ -1,13 +1,7 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Фабрика реальных клиентов WTelegramClient (план Task 9; Ruling 3).
///
/// Каждый вызов создаёт изолированный клиент для сессии тенанта: api_id/api_hash — из запроса
/// (их передаёт ядро из настроек tgKeys, Ruling 3), байты сессии — расшифрованная копия файла
/// data/sessions/&lt;tenant&gt;.session. Анти-бан-паузы между сетевыми операциями (Ruling 3:
/// backfill 1.53 с/сообщение, 3–6 с/диалог, поиск 2–4 с) добавляются на операции задач каталога
/// (Task 1011), вход/QR пауз не требуют.
/// Фабрика реальных клиентов WTelegramClient.
/// </summary>
public sealed class ClientFactory : ITelegramClientFactory
{
@@ -1,29 +1,27 @@
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".
/// Канал (broadcast)
/// </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";
}
@@ -1,12 +1,7 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Сообщение выборки discovery-read для оценки кандидата в нейтральном для TL-слоя виде (план
/// Task 11; 1:1 _discovery_message_item python-прототипа telegram.py L803816).
///
/// В отличие от <see cref="TelegramMessage"/> (поток каталога) здесь не нужны канальные поля — выборка
/// идёт «внутри» уже известного диалога, а темы форума помечаются topic_id/topic_title (для обычных
/// источников оба пусты). Только непустые тексты: пустые/media/service отбрасывает TL-слой, как python.
/// Сообщение выборки discovery-read для оценки кандидата в нейтральном для TL-слоя виде.
/// </summary>
public sealed record DiscoveryMessage
{
@@ -38,7 +33,7 @@ public sealed record DiscoveryMessage
public long Id { get; }
/// <summary>
/// Текст сообщения (непустой).
/// Текст сообщения
/// </summary>
public string Text { get; }
@@ -48,12 +43,12 @@ public sealed record DiscoveryMessage
public long DateMs { get; }
/// <summary>
/// Id темы форума (для обычных источников пуст).
/// Id темы форума
/// </summary>
public long? TopicId { get; }
/// <summary>
/// Название темы форума (для обычных источников пусто).
/// Название темы форума
/// </summary>
public string? TopicTitle { get; }
}
@@ -1,21 +1,17 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Результат чтения выборки источника для оценки кандидата (план Task 11; 1:1 discovery_read
/// python-прототипа telegram.py L718760: {ok, error, messages}).
///
/// ok=false — история недоступна (приватный/закрытый источник без членства), error="no_history";
/// это НЕ ошибка сессии/RPC, а нормальный ответ контракта (ReadForEvalReply.ok=false).
/// Результат чтения выборки источника для оценки кандидата.
/// </summary>
public sealed record DiscoveryReadResult
{
/// <summary>
/// Код причины ok=false: история недоступна без членства (1:1 прототип L726).
/// Код причины ok=false
/// </summary>
public const string NoHistoryError = "no_history";
/// <summary>
/// Пустой успешный результат (limit ≤ 0 — выборка не запрашивалась, прототип L730–732).
/// Пустой успешный результат.
/// </summary>
public static DiscoveryReadResult Empty { get; } = new(true, null, []);
@@ -36,7 +32,7 @@ public sealed record DiscoveryReadResult
}
/// <summary>
/// Создаёт результат «история недоступна» (ok=false, error=no_history, сообщений нет).
/// Создаёт результат «история недоступна»
/// </summary>
public static DiscoveryReadResult NoHistory()
=> new(false, NoHistoryError, []);
@@ -47,12 +43,12 @@ public sealed record DiscoveryReadResult
public bool Ok { get; }
/// <summary>
/// Код причины при ok=false: "no_history"; иначе null.
/// Код причины при ok=false
/// </summary>
public string? Error { get; }
/// <summary>
/// Сообщения выборки (для обычных источников topic_id/topic_title пусты).
/// Сообщения выборки
/// </summary>
public IReadOnlyList<DiscoveryMessage> Messages { get; }
}
@@ -1,20 +1,12 @@
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 L134312): код запрашивается по номеру, код/пароль отправляются отдельными вызовами,
/// QR-вход выполняется в фоне до авторизации с обновлением URL через колбэк.
/// Сетевые ошибки и ошибки домена реализация переводит в <see cref="Sessions.SessionException"/>.
/// Абстракция клиента Telegram для сессии тенанта.
/// </summary>
public interface ISessionClient : IAsyncDisposable
{
/// <summary>
/// Авторизован ли клиент (в сессии есть пользователь Telegram).
/// Авторизован ли клиент
/// </summary>
public bool IsAuthorized { get; }
@@ -24,7 +16,7 @@ public interface ISessionClient : IAsyncDisposable
public bool IsConnected { get; }
/// <summary>
/// api_id приложения, под которым создан клиент (для сохранения в файл сессии).
/// api_id приложения, под которым создан клиент
/// </summary>
public int ApiId { get; }
@@ -34,29 +26,25 @@ public interface ISessionClient : IAsyncDisposable
public string ApiHash { get; }
/// <summary>
/// Последние байты сессии WTelegramClient (обновляются библиотекой в момент сохранения сессии).
/// Хранилище шифрует их в файл data/sessions/&lt;tenant&gt;.session (Ruling 3).
/// Последние байты сессии WTelegramClient
/// </summary>
public byte[]? SessionBytes { get; }
/// <summary>
/// Устанавливает соединение с Telegram (идемпотентно для уже соединённого клиента).
/// Устанавливает соединение с Telegram
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public Task ConnectAsync(CancellationToken cancellationToken);
/// <summary>
/// Запрашивает SMS-код для номера (start_phone L134147). После успеха клиент готов принять код.
/// Ошибки: нет соединения/недоступен Telegram, некорректный номер.
/// Запрашивает SMS-код для номера.
/// </summary>
/// <param name="phone">Номер в международном формате (как ввёл пользователь).</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task RequestCodeAsync(string phone, CancellationToken cancellationToken);
/// <summary>
/// Отправляет SMS-код (submit_code L149166). Возвращает следующий запрашиваемый шаг:
/// "password" — включён 2FA, нужен облачный пароль; null — авторизация завершена (готово).
/// Ошибки: «Неверный код», «Код истёк — запросите новый» (INVALID_ARGUMENT).
/// Отправляет SMS-код. Возвращает следующий запрашиваемый шаг
/// </summary>
/// <param name="code">Код из SMS/Telegram-сообщения.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -64,51 +52,42 @@ public interface ISessionClient : IAsyncDisposable
public Task<string?> SubmitCodeAsync(string code, CancellationToken cancellationToken);
/// <summary>
/// Отправляет облачный пароль 2FA (submit_password L168176). Возвращается после успешной
/// авторизации. Ошибка: «Неверный облачный пароль» (INVALID_ARGUMENT).
/// Отправляет облачный пароль 2FA.
/// </summary>
/// <param name="password">Облачный пароль.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken);
/// <summary>
/// Выполняет QR-вход (qr_start L286300): метод возвращается после авторизации; новые URL
/// (в т.ч. после истечения токена) приходят через <paramref name="onQrUrl"/> до завершения.
/// Отмена токена прерывает ожидание сканирования.
/// Выполняет QR-вход
/// </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).
/// Полный выход: отзывает авторизацию на стороне Telegram
/// </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 L505519 / iter_dialogs). Возвращает диалоги от
/// свежих к старым (как список Telegram), верхняя граница <paramref name="limit"/>.
/// Ошибки сети/Telegram — <see cref="Sessions.SessionException"/>.
/// Список диалогов аккаунта.
/// </summary>
/// <param name="limit">Верхняя граница числа диалогов (прототип: 500).</param>
/// <param name="limit">Верхняя граница числа диалогов.</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>
@@ -120,8 +99,7 @@ public interface ISessionClient : IAsyncDisposable
CancellationToken cancellationToken);
/// <summary>
/// Помечает весь диалог прочитанным (send_read_acknowledge прототипа L277/L383/L454/L609).
/// Ошибки сети/неизвестный источник — <see cref="Sessions.SessionException"/>.
/// Помечает весь диалог прочитанным.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -129,23 +107,16 @@ public interface ISessionClient : IAsyncDisposable
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 L622873; 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="limit">Верхняя граница результата.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Найденные источники в нейтральном виде (каналы/группы/личные).</returns>
public Task<IReadOnlyList<TelegramDialog>> SearchAsync(
@@ -154,10 +125,7 @@ public interface ISessionClient : IAsyncDisposable
CancellationToken cancellationToken);
/// <summary>
/// Инфо об источнике для оценки кандидата (discovery_info L666716): имя/username/kind + участники
/// из полного чата (GetFullChannel/GetFullChat) и признак форума. Сбои определения не бросаются —
/// возвращается <see cref="TelegramSourceInfo"/> с тем, что удалось получить (прототип наружу
/// исключения не выпускает: participants пуст, недоступная сущность — поля по умолчанию).
/// Инфо об источнике для оценки кандидата
/// </summary>
/// <param name="dialogId">Подписанный id источника («-100…»/«-…»/«+…»).</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -165,14 +133,10 @@ public interface ISessionClient : IAsyncDisposable
public Task<TelegramSourceInfo> GetInfoAsync(string dialogId, CancellationToken cancellationToken);
/// <summary>
/// Последние сообщения источника для оценки кандидата (discovery_read L718800): форумы читаются
/// по активным темам (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="limit">Размер выборки.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Результат чтения выборки (ok + сообщения либо no_history).</returns>
public Task<DiscoveryReadResult> ReadForEvalAsync(
@@ -181,10 +145,7 @@ public interface ISessionClient : IAsyncDisposable
CancellationToken cancellationToken);
/// <summary>
/// Вступить в канал/группу по username (discovery_join L818839; ручной join вне квот — паузу перед
/// авто-join делает воркер ядра, Ruling 10). Username нормализует уровень службы (DiscoveryOps).
/// FloodWait Telegram → SessionException RESOURCE_EXHAUSTED (detail с префиксом "flood", контракт
/// telegram.proto; базовый флуд-гард — здесь, суточный стоп — в ядре).
/// Вступить в канал/группу по username.
/// </summary>
/// <param name="username">Username канала/группы (без «@»).</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -192,8 +153,7 @@ public interface ISessionClient : IAsyncDisposable
public Task JoinAsync(string username, CancellationToken cancellationToken);
/// <summary>
/// Выйти из канала/группы (discovery_leave L841848). Неизвестный/недоступный источник —
/// SessionException (уровень контракта Leave: нет диалога/членства).
/// Выйти из канала/группы.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
@@ -1,11 +1,7 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Фабрика клиентов Telegram для сессий тенантов (план Task 9, Telegram/ClientFactory.cs).
///
/// Создаёт <see cref="ISessionClient"/> для сессии тенанта по ключам приложения и байтам сохранённой
/// сессии (или пустой — новый вход). Интерфейс — seam для тестов: тесты регистрируют фейковую
/// фабрику, реальный <see cref="ClientFactory"/> сеть Telegram не трогает до первого вызова.
/// Фабрика клиентов Telegram для сессий тенантов.
/// </summary>
public interface ITelegramClientFactory
{
@@ -14,10 +10,7 @@ public interface ITelegramClientFactory
/// </summary>
/// <param name="apiId">api_id приложения Telegram (настройка tgKeys тенанта, из тела запроса).</param>
/// <param name="apiHash">api_hash приложения Telegram.</param>
/// <param name="storedSession">
/// Байты сохранённой сессии WTelegramClient (расшифрованная копия файла data/sessions/&lt;tenant&gt;.session)
/// либо null — новая сессия (нет файла или ключи приложения сменились).
/// </param>
/// <param name="storedSession">Байты сохранённой сессии WTelegramClient (расшифрованная копия файла data/sessions/&lt;tenant&gt.session) либо null — новая сессия (нет файла или ключи приложения сменились).</param>
/// <returns>Клиент, готовый к ConnectAsync/логину.</returns>
public ISessionClient Create(
int apiId,
@@ -1,13 +1,7 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Диалог (источник) аккаунта в нейтральном для TL-слоя виде (план Task 10; Ruling 3/7).
///
/// Это результат «списка диалогов» сессии (refresh_dialogs прототипа L505–519): id в подписанном
/// каноне контракта («-100…» каналы, «-…» группы, «+…» личные), отображаемое имя, username, тип
/// канона channel|group|forum|chat и счётчики для догонялки непрочитанных (realtime_sweep L427456).
/// TL-реализация (<see cref="WTelegramSessionClient"/>) и фейки тестов возвращают именно этот тип;
/// службы каталога не зависят от библиотеки WTelegramClient.
/// Диалог (источник) аккаунта в нейтральном для TL-слоя виде.
/// </summary>
public sealed record TelegramDialog
{
@@ -37,12 +31,12 @@ public sealed record TelegramDialog
}
/// <summary>
/// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).
/// Подписанный id диалога
/// </summary>
public string Id { get; }
/// <summary>
/// Отображаемое имя (title/first_name) или id, если имени нет.
/// Отображаемое имя
/// </summary>
public string Name { get; }
@@ -52,12 +46,12 @@ public sealed record TelegramDialog
public string Username { get; }
/// <summary>
/// Тип канона контракта: channel|group|forum|chat.
/// Тип канона контракта
/// </summary>
public string Kind { get; }
/// <summary>
/// Число непрочитанных сообщений диалога (для догона realtime_sweep).
/// Число непрочитанных сообщений диалога
/// </summary>
public int UnreadCount { get; }
@@ -1,12 +1,7 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Текстовое сообщение диалога в нейтральном для TL-слоя виде (план Task 10; Ruling 3/7).
///
/// Используется тремя путями сообщений: backfill («Перечитать» L349390), realtime-listener
/// (L255283) и realtime_sweep (L392456). Пустые тексты и служебные сообщения (media/service)
/// TL-слой не отдаёт — только непустой текст. Канальные поля (имя/username) нужны для PushMessage
/// в ядро (PushMessageRequest.channel_name/channel_handle, Ruling 7): hue считает служба каталога.
/// Текстовое сообщение диалога в нейтральном для TL-слоя виде.
/// </summary>
public sealed record TelegramMessage
{
@@ -41,12 +36,12 @@ public sealed record TelegramMessage
public string DialogId { get; }
/// <summary>
/// Id сообщения в Telegram (дубль-гвард диалога ядра).
/// Id сообщения в Telegram
/// </summary>
public int Id { get; }
/// <summary>
/// Текст сообщения (непустой).
/// Текст сообщения
/// </summary>
public string Text { get; }
@@ -61,7 +56,7 @@ public sealed record TelegramMessage
public string DialogName { get; }
/// <summary>
/// Username диалога (пуст, если нет) — для PushMessage.channel_handle.
/// Username диалога
/// </summary>
public string DialogHandle { get; }
}
@@ -1,12 +1,7 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Инфо об источнике для оценки кандидата discovery в нейтральном для TL-слоя виде (план Task 11;
/// 1:1 результат discovery_info python-прототипа telegram.py L666716).
///
/// Поля повторяют словарь прототипа {id, name, username, kind, participants, is_forum}; hue прототип
/// не хранит — его считает маппер ответа (Ruling 7: цвет считает сервис). kind — EN-канон контракта
/// channel|group|forum|chat; пустая строка — тип определить не удалось (прототип L678: kind "").
/// Инфо об источнике для оценки кандидата discovery в нейтральном для TL-слоя виде.
/// </summary>
public sealed record TelegramSourceInfo
{
@@ -36,12 +31,12 @@ public sealed record TelegramSourceInfo
}
/// <summary>
/// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).
/// Подписанный id диалога
/// </summary>
public string Id { get; }
/// <summary>
/// Отображаемое имя (title/first_name) или id, если имени нет.
/// Отображаемое имя
/// </summary>
public string Name { get; }
@@ -51,17 +46,17 @@ public sealed record TelegramSourceInfo
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).
/// True — мегагруппа с темами.
/// </summary>
public bool IsForum { get; }
}
@@ -6,20 +6,12 @@ using TL;
namespace Deal.Telegram.Telegram;
/// <summary>
/// Чистый маппер TL-объектов каталога/сообщений в нейтральные типы сервиса (план Task 10; Ruling 3/7).
///
/// Статический и без зависимостей от клиента — единое место разбора, используемое WTelegramSessionClient
/// (список диалогов, история, realtime-события) и unit-тестами на фейковых TL-объектах (без сети):
/// ветки типов обновлений, подписанные id, фильтр пустых/служебных/исходящих текстов.
/// Чистый маппер TL-объектов каталога/сообщений в нейтральные типы сервиса.
/// </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>
@@ -27,7 +19,7 @@ public static class TlMessageMapper
=> update is UpdateNewMessage { message: MessageBase message } ? message : null;
/// <summary>
/// Превращает диалог списка в нейтральный <see cref="TelegramDialog"/> (null — нет сущности).
/// Превращает диалог списка в нейтральный <see cref="TelegramDialog"/>
/// </summary>
/// <param name="dialog">Диалог из ответа getDialogs.</param>
/// <param name="chats">Сущности чатов контейнера (по raw id).</param>
@@ -49,9 +41,7 @@ public static class TlMessageMapper
}
/// <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>
@@ -74,12 +64,9 @@ public static class TlMessageMapper
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
/// L644661: id подписанный, name/username из сущности, kind EN-канона; счётчики пустые — у результата
/// поиска их нет). Используется TL-слоем поиска (Search) для chats результата.
/// Сущность чата/канала результата contacts.SearchRequest → запись поиска.
/// </summary>
/// <param name="chat">Сущность канала/группы из chats результата поиска.</param>
public static TelegramDialog ToFoundChat(ChatBase chat)
@@ -90,8 +77,7 @@ public static class TlMessageMapper
}
/// <summary>
/// Сущность пользователя результата contacts.SearchRequest → запись поиска (личный чат/бот; core
/// отсеивает kind=chat сам — Ruling 10). Поля и id — как у <see cref="ToFoundChat"/>.
/// Сущность пользователя результата contacts.SearchRequest → запись поиска.
/// </summary>
/// <param name="user">Сущность пользователя из users результата поиска.</param>
public static TelegramDialog ToFoundUser(User user)
@@ -102,13 +88,12 @@ public static class TlMessageMapper
}
/// <summary>
/// Сообщение выборки discovery-read → нейтральное (1:1 _discovery_message_item L803816): только
/// непустые тексты (пустые/media/service отбрасываются); темы форума помечены topicId/topicTitle.
/// Сообщение выборки discovery-read → нейтральное
/// </summary>
/// <param name="message">Сообщение ленты/темы форума (MessageBase).</param>
/// <param name="topicId">Id темы форума (для обычных источников null).</param>
/// <param name="topicTitle">Название темы форума (для обычных источников null).</param>
/// <param name="now">Текущее время (фолбэк даты сообщения без времени, как python `now`).</param>
/// <param name="now">Текущее время.</param>
public static DiscoveryMessage? ToEvalMessage(
MessageBase message,
long? topicId,
@@ -173,7 +158,7 @@ public static class TlMessageMapper
}
/// <summary>
/// Тип диалога канона контракта по сущности чата/канала (Ruling 3, kind-маппинг task-1).
/// Тип диалога канона контракта по сущности чата/канала.
/// </summary>
/// <param name="chat">Сущность канала/группы.</param>
public static string KindOf(ChatBase chat)
@@ -194,7 +179,6 @@ public static class TlMessageMapper
_ => throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId),
};
// Дата сообщения → epoch-ms; без даты (default) — текущее время (python `date or now`).
// date: Дата сообщения.
// now: Текущее время (фолбэк).
private static long ToEpochMsOrNow(DateTime date, DateTimeOffset now)
@@ -217,7 +201,6 @@ public static class TlMessageMapper
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();
@@ -9,25 +9,10 @@ using RpcException = TL.RpcException;
namespace Deal.Telegram.Telegram;
#pragma warning disable CS0618 // Auth_SendCode/Auth_SignIn используются осознанно: ручной веб-вход 1:1 с прототипом
// (start_phone/submit_code/submit_password L134176); Obsolete-метки библиотеки ведут на
// LoginUserIfNeeded, который умеет только интерактивный конфиг-ввод, а не наш пошаговый API.
/// <summary>
/// Реальная реализация <see cref="ISessionClient"/> поверх WTelegramClient (план Task 9; Ruling 3).
///
/// Сессия библиотеки живёт в памяти процесса: байты сессии (внутренне зашифрованы WTelegramClient
/// ключом api_hash) подаются в конструктор и обновляются колбэком при каждом сохранении библиотекой;
/// at-rest файл data/sessions/&lt;tenant&gt;.session (AES-GCM-обёртка, Ruling 3) пишет SessionStore —
/// расшифрованного файла на диске нет ни в какой момент (Ruling 3: только в памяти процесса).
/// Шаги входа повторяют python-прототип на уровне TL-методов:
/// Auth_SendCode → (код) Auth_SignIn → 2FA: Account_GetPassword + Auth_CheckPassword; QR —
/// LoginWithQRCode с колбэком новых URL. Ошибки переводятся в <see cref="Sessions.SessionException"/>.
/// Операции каталога (план Task 10) ходят TL-методами messages.getDialogs/getHistory и readHistory
/// (ReadHistory клиента — generic-хелпер channels/messages); discovery (план Task 11) — contacts.search,
/// getFullChannel/getFullChat (участники/forum), getForumTopics+getReplies (чтение форумов по темам) и
/// channels.joinChannel/leaveChannel; realtime-сообщения нормализует штатный
/// <see cref="UpdateManager"/> библиотеки (UpdateNewMessage для всех типов, включая каналы и короткие
/// UpdateShort*) и поднимаются событием <see cref="MessageReceived"/>.
/// Реальная реализация <see cref="ISessionClient"/> поверх WTelegramClient.
/// </summary>
public sealed class WTelegramSessionClient : ISessionClient
{
@@ -52,10 +37,8 @@ public sealed class WTelegramSessionClient : ISessionClient
// LRU-кэш access_hash сущностей (ключ — подписанный id диалога; заполняется из ответов).
private readonly LruCache<string, long> _entityAccessHashes = new(AccessHashCacheCapacity);
// LRU-кэш сущностей чатов/каналов (ключ — raw id; для имён и forum-флага discovery, Task 11).
private readonly LruCache<long, ChatBase> _chatsById = new(ChatEntityCacheCapacity);
// LRU-кэш сущностей пользователей (ключ — raw id; для имён discovery, Task 11).
private readonly LruCache<long, User> _usersById = new(UserEntityCacheCapacity);
// Защита кэшей сущностей (обновляются из потоков reactor/вызовов).
@@ -234,7 +217,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return _client.DisposeAsync();
}
// --- Каталог и сообщения (план Task 10; Ruling 3/7; TL-методы getDialogs/getHistory/readHistory) ---
/// <inheritdoc />
public event Func<TelegramMessage, Task>? MessageReceived;
@@ -288,11 +270,9 @@ public sealed class WTelegramSessionClient : ISessionClient
{
InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false);
// Generic-хелпер библиотеки: для канала — channels.readHistory, иначе — messages.readHistory;
// max_id=0 (default) — «снять новое» по всему диалогу (1:1 send_read_acknowledge прототипа).
await RunTlCallAsync(() => _client.ReadHistory(peer), cancellationToken).ConfigureAwait(false);
}
// --- Discovery (план Task 11; discovery_search/info/read/join/leave L622873; Ruling 3/7) ---
/// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialog>> SearchAsync(
@@ -321,9 +301,7 @@ public sealed class WTelegramSessionClient : ISessionClient
/// <inheritdoc />
public async Task<TelegramSourceInfo> GetInfoAsync(string dialogId, CancellationToken cancellationToken)
{
// discovery_info L666716: определение никогда не бросает наружу — недоступная сущность/полный
// чат дают инфо по умолчанию (name=id, kind пуст, participants пуст), сбой участников не роняет
// остальные поля (прототип: исключение только логируется).
TelegramSourceInfo unknown = DefaultSourceInfo(dialogId);
if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId))
{
@@ -361,7 +339,6 @@ public sealed class WTelegramSessionClient : ISessionClient
int limit,
CancellationToken cancellationToken)
{
// discovery_read L718760: limit ≤ 0 — пустой ok без сетевых вызовов (L730–732).
if (limit <= 0)
{
return DiscoveryReadResult.Empty;
@@ -379,7 +356,6 @@ public sealed class WTelegramSessionClient : ISessionClient
}
catch (SessionException)
{
// Сущность не разрешилась (приватный/закрытый источник без членства) → no_history (L736739).
return DiscoveryReadResult.NoHistory();
}
@@ -388,7 +364,6 @@ public sealed class WTelegramSessionClient : ISessionClient
IReadOnlyList<DiscoveryMessage> forumMessages = await ReadForumTopicsAsync(peer, limit, cancellationToken).ConfigureAwait(false);
if (forumMessages.Count > 0)
{
// Форум прочитан по темам (L746–747: непустой результат тем — ответ, без ленты).
return new DiscoveryReadResult(true, null, forumMessages);
}
}
@@ -403,7 +378,6 @@ public sealed class WTelegramSessionClient : ISessionClient
}
catch (SessionException)
{
// История недоступна (приватный/закрытый) → ok=false no_history (L751753), не ошибка RPC.
return DiscoveryReadResult.NoHistory();
}
}
@@ -411,7 +385,6 @@ public sealed class WTelegramSessionClient : ISessionClient
/// <inheritdoc />
public async Task JoinAsync(string username, CancellationToken cancellationToken)
{
// discovery_join L818839: username → сущность → channels.JoinChannel (для мегагрупп/каналов).
Contacts_ResolvedPeer resolved = await RunTlCallAsync(() => _client.Contacts_ResolveUsername(username), cancellationToken).ConfigureAwait(false);
CacheEntities(resolved.chats.Values, resolved.users.Values);
@@ -427,7 +400,6 @@ public sealed class WTelegramSessionClient : ISessionClient
/// <inheritdoc />
public async Task LeaveAsync(string dialogId, CancellationToken cancellationToken)
{
// discovery_leave L841848: channels.LeaveChannel по подписанному id (каналы/супергруппы).
if (!TryParseSignedId(dialogId, out bool isChannel, out _, out _, out long rawId) || !isChannel)
{
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId);
@@ -438,7 +410,6 @@ public sealed class WTelegramSessionClient : ISessionClient
}
// Инфо о канале/супергруппе: entity из кэша/полного чата, участники — GetFullChannel best-effort
// (1:1 L686715: entity недоступен → default; участники недоступны → остальные поля остаются).
// dialogId: Подписанный id (для имени по умолчанию).
// rawId: Raw id канала.
// unknown: Инфо по умолчанию (сущность недоступна).
@@ -457,12 +428,10 @@ public sealed class WTelegramSessionClient : ISessionClient
if (cached is not null)
{
// Сущность известна (поиск/каталог): имя/kind/forum — сразу, участники — best-effort (L700715).
int? participants = await TryFetchChannelParticipantsAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false);
return DescribeChannel(dialogId, cached, participants);
}
// Неизвестная сущность: полный чат принесёт её; недоступен → default (как entity-not-found L686690).
InputChannel input = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false);
Messages_ChatFull full = await RunTlCallAsync(() => _client.Channels_GetFullChannel(input), cancellationToken).ConfigureAwait(false);
CacheEntities(full.chats.Values, full.users.Values);
@@ -477,7 +446,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return DescribeChannel(dialogId, channel, fullParticipants);
}
// Участники канала best-effort: сбой полного чата → null, имя/kind не роняются (L713715).
// dialogId: Подписанный id.
// rawId: Raw id канала.
// cancellationToken: Отмена операции.
@@ -537,7 +505,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return DescribeGroup(dialogId, group, fullParticipants);
}
// Список участников базовой группы best-effort: сбой → null (нет членства/приватная, L713–715).
// rawId: Raw id группы.
// cancellationToken: Отмена операции.
private async Task<int?> TryFetchBasicParticipantsAsync(long rawId, CancellationToken cancellationToken)
@@ -575,7 +542,6 @@ public sealed class WTelegramSessionClient : ISessionClient
// Инфо о личном чате/боте: имя из кэша сущности (полного чата у людей нет).
// dialogId: Подписанный id.
// rawId: Raw id пользователя.
// unknown: Инфо по умолчанию (сущность неизвестна — как entity-not-found прототипа).
private TelegramSourceInfo GetUserInfo(
string dialogId,
long rawId,
@@ -602,7 +568,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return new TelegramSourceInfo(dialogId, name, username, DialogKinds.Chat, participants: null, isForum: false);
}
// Выборка по активным темам форума: GetForumTopics + по каждой теме getReplies (L762800).
// peer: Peer форума.
// limit: Размер выборки (раскладывается по темам).
// cancellationToken: Отмена операции.
@@ -615,7 +580,6 @@ public sealed class WTelegramSessionClient : ISessionClient
var outMessages = new List<DiscoveryMessage>();
try
{
// channels/messages.getForumTopics (L771775): до 5 активных тем, без смещения.
Messages_ForumTopics forum = await RunTlCallAsync(
() => _client.Messages_GetForumTopics(peer, offset_date: default, offset_id: 0, offset_topic: 0, limit: ForumTopicsLimit),
cancellationToken).ConfigureAwait(false);
@@ -627,14 +591,12 @@ public sealed class WTelegramSessionClient : ISessionClient
return outMessages;
}
// На тему минимум 3 сообщения, cap 10 (прототип L784); суммарно выборка может слегка превысить limit.
int perTopic = Math.Min(Math.Max(3, (int)Math.Ceiling(limit / (double)topics.Length)), ForumMessagesPerTopicCap);
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (ForumTopic topic in topics)
{
try
{
// get_messages(reply_to=topic.id) эквивалент: messages.getReplies (L792, Fix round 1).
Messages_MessagesBase result = await RunTlCallAsync(
() => _client.Messages_GetReplies(peer, topic.id, limit: perTopic),
cancellationToken).ConfigureAwait(false);
@@ -651,13 +613,11 @@ public sealed class WTelegramSessionClient : ISessionClient
}
catch (SessionException)
{
// Тема не прочиталась — пропуск (прототип L793795: continue).
}
}
}
catch (SessionException)
{
// getForumTopics недоступен — безопасный фолбэк на обычную ленту (L777–779).
outMessages.Clear();
}
@@ -703,7 +663,6 @@ public sealed class WTelegramSessionClient : ISessionClient
participants,
isForum: (channel.flags & Channel.Flags.forum) != 0);
// Отображаемое имя сущности (title/first_name) или id (как get_display_name прототипа).
// dialogId: Подписанный id (фолбэк имени).
// chat: Сущность чата/канала.
private static string DisplayName(string dialogId, ChatBase chat)
@@ -712,7 +671,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return title.Length > 0 ? title : dialogId;
}
// True — канал из кэша сущностей является форумом (темы; entity.forum прототипа L698).
// rawId: Raw id канала.
private bool IsForumChannel(long rawId)
{
@@ -724,15 +682,11 @@ public sealed class WTelegramSessionClient : ISessionClient
}
}
// Сколько активных тем форума запрашивает чтение выборки (getForumTopics limit=5, L773).
private const int ForumTopicsLimit = 5;
// Потолок сообщений на тему форума (cap 10, прототип L784).
private const int ForumMessagesPerTopicCap = 10;
// --- Realtime-события (план Task 10; прототип _on_message L255283) ---
// Единый колбэк штатного UpdateManager (см. _updateManager): вызывается
// последовательно на каждое обновление в правильном порядке (без пропусков/дублей по pts).
// Все типы новых сообщений библиотека нормализует в TL.UpdateNewMessage:
// * UpdateNewChannelMessage (каналы/супергруппы) — подкласс UpdateNewMessage;
@@ -810,7 +764,6 @@ public sealed class WTelegramSessionClient : ISessionClient
private void OnSessionSaved(byte[] sessionBytes)
=> Volatile.Write(ref _latestSessionBytes, sessionBytes);
// Запрашивает код с одним повтором при AUTH_RESTART (как LoginUserIfNeeded L12021205).
private async Task<Auth_SentCodeBase> SendCodeOnceAsync(string phone, CancellationToken cancellationToken)
{
try
@@ -849,7 +802,6 @@ public sealed class WTelegramSessionClient : ISessionClient
// Переводит RpcException Telegram в SessionException: FloodWait — RESOURCE_EXHAUSTED
// (detail с префиксом "flood", контракт telegram.proto); 400-ошибки входных данных —
// INVALID_ARGUMENT (текст RPC как detail, как у прототипа: ошибка показывается как есть);
// остальные серверные/сетевые сбои — UNAVAILABLE «Telegram недоступен…» (безопасный повтор).
// exception: Исключение RPC Telegram.
private static SessionException MapRpcException(RpcException exception)
@@ -867,7 +819,6 @@ public sealed class WTelegramSessionClient : ISessionClient
return new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception);
}
// --- Приватные помощники каталога/сообщений (план Task 10) ---
// Исполняет TL-вызов с единым переводом ошибок (RpcException Telegram → SessionException;
// прочие сбои — UNAVAILABLE «Telegram недоступен…»).
@@ -12,40 +12,17 @@ using Deal.Telegram.Telegram;
namespace Deal.Telegram;
/// <summary>
/// Собирает WebApplication gRPC-хоста telegram-service (план Task 2/9/10; L227238 + задачи сессий и
/// диалогов/мониторинга).
///
/// Продакшн-точка входа вызывает <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.
/// Собирает WebApplication gRPC-хоста telegram-service.
/// </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>
/// <param name="configureServices">Опциональный хук DI для тестов (подмена зависимостей фейками, напр. ITelegramClientFactory).</param>
/// <param name="configureBuilder">Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно.</param>
/// <returns>Собранный хост; запуск — StartAsync/RunAsync у вызывающего.</returns>
public static WebApplication Create(
int grpcPort,
@@ -57,8 +34,6 @@ public static class TelegramServiceHost
// Общая серверная обвязка (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);
@@ -66,7 +41,6 @@ public static class TelegramServiceHost
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 обязателен —
// иначе хост не стартует (сессии не могут храниться в открытом виде).
@@ -78,13 +52,6 @@ public static class TelegramServiceHost
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(
@@ -10,31 +10,17 @@ 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).
/// Реализация серверной стороны Deal.Grpc.Telegram.TelegramService — команды core → telegram-service.
/// </summary>
public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
{
/// <summary>
/// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1).
/// Ключ gRPC-metadata с id тенанта.
/// </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).
@@ -78,10 +64,9 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
_logger = logger;
}
// --- Подключение аккаунта и статус (Ruling 3, WTelegramClient) ---
/// <summary>
/// GetStatus — статус и фаза входа аккаунта тенанта (status L103119).
/// GetStatus — статус и фаза входа аккаунта тенанта.
/// </summary>
public override async Task<GetStatusReply> GetStatus(GetStatusRequest request, ServerCallContext context)
{
@@ -115,7 +100,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// StartPhone — запросить код по номеру телефона (start_phone L134147).
/// StartPhone — запросить код по номеру телефона.
/// </summary>
public override async Task<StartPhoneReply> StartPhone(StartPhoneRequest request, ServerCallContext context)
{
@@ -131,7 +116,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// StartQr — начать вход по QR (qr_start L286300): фаза + qrUrl.
/// StartQr — начать вход по QR
/// </summary>
public override async Task<StartQrReply> StartQr(StartQrRequest request, ServerCallContext context)
{
@@ -153,7 +138,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// SendCode — отправить SMS-код входа (submit_code L149166).
/// SendCode — отправить SMS-код входа.
/// </summary>
public override async Task<SendCodeReply> SendCode(SendCodeRequest request, ServerCallContext context)
{
@@ -169,7 +154,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// SendPassword — облачный пароль 2FA (submit_password L168176).
/// SendPassword — облачный пароль 2FA.
/// </summary>
public override async Task<SendPasswordReply> SendPassword(SendPasswordRequest request, ServerCallContext context)
{
@@ -185,7 +170,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// Logout — отключить аккаунт, удалить сессию тенанта (disconnect L189207).
/// Logout — отключить аккаунт, удалить сессию тенанта.
/// </summary>
public override async Task<LogoutReply> Logout(LogoutRequest request, ServerCallContext context)
{
@@ -197,15 +182,13 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
"logout",
context).ConfigureAwait(false);
// Отключение аккаунта: зеркало мониторинга очищается (прототип L203: `_monitored.clear()`).
_catalog.Reset(tenantId);
return new LogoutReply { Ok = true };
}
// --- Каталог и мониторинг (задача Task 10; Ruling 7) ---
/// <summary>
/// RefreshDialogs — актуальный каталог диалогов аккаунта (refresh_dialogs L505519).
/// RefreshDialogs — актуальный каталог диалогов аккаунта.
/// </summary>
public override async Task<RefreshDialogsReply> RefreshDialogs(RefreshDialogsRequest request, ServerCallContext context)
{
@@ -222,7 +205,6 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
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);
@@ -236,7 +218,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// SetMonitor — включить/выключить мониторинг диалога (set_monitor L536546).
/// SetMonitor — включить/выключить мониторинг диалога.
/// </summary>
public override Task<SetMonitorReply> SetMonitor(SetMonitorRequest request, ServerCallContext context)
{
@@ -253,7 +235,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// SetMonitorAll — мониторинг всех диалогов каталога (set_monitor_all L548567).
/// SetMonitorAll — мониторинг всех диалогов каталога.
/// </summary>
public override Task<SetMonitorAllReply> SetMonitorAll(SetMonitorAllRequest request, ServerCallContext context)
{
@@ -269,7 +251,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// Backfill — перечитать последние сообщения диалога в ядро (backfill L568582/«Перечитать»).
/// Backfill — перечитать последние сообщения диалога в ядро.
/// </summary>
public override async Task<BackfillReply> Backfill(BackfillRequest request, ServerCallContext context)
{
@@ -285,7 +267,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// ReadRecent — последние сообщения диалога для превью (dialog_messages L583620).
/// ReadRecent — последние сообщения диалога для превью.
/// </summary>
public override async Task<ReadRecentReply> ReadRecent(ReadRecentRequest request, ServerCallContext context)
{
@@ -304,7 +286,6 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
var result = new ReadRecentReply();
result.Messages.AddRange(messages.Select(DialogProtoMapper.ToPreview));
// Вручную вытащили сообщения — снимаем «новое» в Telegram (прототип L607611).
await _sessionFarm.MarkReadAsync(tenantId, request.DialogId, ct).ConfigureAwait(false);
return result;
},
@@ -313,10 +294,9 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
return reply;
}
// --- Discovery-операции (задача Task 11; Ruling 3/7/10) ---
/// <summary>
/// Search — глобальный поиск каналов/групп по ключу (discovery_search L624664).
/// Search — глобальный поиск каналов/групп по ключу.
/// </summary>
public override async Task<SearchReply> Search(SearchRequest request, ServerCallContext context)
{
@@ -341,7 +321,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// GetInfo — инфо об источнике для оценки кандидата (discovery_info L666716).
/// GetInfo — инфо об источнике для оценки кандидата.
/// </summary>
public override async Task<GetInfoReply> GetInfo(GetInfoRequest request, ServerCallContext context)
{
@@ -363,7 +343,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// ReadForEval — выборка сообщений источника для оценки (discovery_read L718800).
/// ReadForEval — выборка сообщений источника для оценки.
/// </summary>
public override async Task<ReadForEvalReply> ReadForEval(ReadForEvalRequest request, ServerCallContext context)
{
@@ -385,7 +365,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// Join — вступить в канал/группу по @username (discovery_join L818839; вне квот, Ruling 10).
/// Join — вступить в канал/группу по @username.
/// </summary>
public override async Task<JoinReply> Join(JoinRequest request, ServerCallContext context)
{
@@ -406,7 +386,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
/// <summary>
/// Leave — выйти из канала/группы (discovery_leave L841848).
/// Leave — выйти из канала/группы.
/// </summary>
public override async Task<LeaveReply> Leave(LeaveRequest request, ServerCallContext context)
{
@@ -425,7 +405,6 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
return new LeaveReply { Ok = true };
}
// Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, Ruling 1).
// context: Контекст вызова.
private static string RequireTenantId(ServerCallContext context)
{
@@ -469,7 +448,6 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
// Best-effort-синхронизация зеркала мониторинга с ядром (SyncDialogs): сбой (ядро недоступно)
// логируется и не роняет команду — упущенное догоняет realtime_sweep (Ruling 7/план Task 10).
// tenantId: Id тенанта.
// entries: Актуальный каталог диалогов.
// cancellationToken: Отмена операции.
@@ -490,11 +468,9 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
}
// Исполняет операцию сессии с единым переводом ошибок: SessionException → RPC-статус контракта,
// прочие — UNAVAILABLE «Telegram недоступен…» + структурированный лог (аудит команд, Ruling 13).
// TResult: Тип результата операции.
// tenantId: Id тенанта (для лога аудита).
// operation: Операция сессии.
// action: Действие (имя метода прототипа, для лога).
// context: Контекст вызова gRPC.
private async Task<TResult> ExecuteAsync<TResult>(
string tenantId,