Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,6 @@
using Xunit;
// Интеграционные тесты telegram-service поднимают реальные Kestrel-хосты и меняют процесс-глобальные
// env-переменные (DEAL_SERVICE_TOKEN/DEAL_TELEGRAM_SESSION_KEY/DEAL_TELEGRAM_SESSION_DIR) на время
// сценария (TelegramTestHost). Параллельный прогон классов дал бы гонки на env — тесты сериализованы.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
@@ -0,0 +1,189 @@
using Deal.Telegram.Dialogs;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Unit-тесты backfill диалога (план Task 10 Acceptance: паузы fake-пейсером, processed, mark-as-read
/// вызван, PushMessage отправлен; без сети — фейк-клиент и фейк-канал в ядро). Сценарии 1:1
/// backfill_dialog прототипа L349–390: от старых к новым, пауза 1.5–3 с/сообщение, 3–6 с между
/// диалогами тенанта, read-ack в конце, сбой отправки — без read-ack.
/// </summary>
public sealed class BackfillServiceTests
{
private const string TenantId = "tenant-backfill";
private const string DialogId = "-1001234567890";
private const string ChannelName = "IT Канал";
private const string ChannelHandle = "it_channel";
/// <summary>
/// Backfill: сообщения уходят от старых к новым с паузами; processed; read-ack вызван.
/// </summary>
[Fact]
public async Task Backfill_PushesOldestFirst_WithPauses_AndMarksRead()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
harness.Client.Messages[DialogId] = NewestFirstMessages();
var ingress = new FakeIngress();
var pacer = new RecordingPacer();
BackfillService backfill = CreateService(harness.Farm, ingress, pacer);
int processed = await backfill.ExecuteAsync(TenantId, DialogId, force: false, CancellationToken.None);
Assert.Equal(3, processed);
// От старых к новым (в fake список хранится от новых к старым, как get_messages).
Assert.Equal([28, 29, 30], ingress.Pushes.Select(push => push.Message.MsgId));
Assert.Equal(TenantId, ingress.Pushes[0].TenantId);
Assert.Equal(DialogId, ingress.Pushes[0].Message.DialogId);
Assert.Equal(ChannelName, ingress.Pushes[0].Message.ChannelName);
Assert.Equal(ChannelHandle, ingress.Pushes[0].Message.ChannelHandle);
Assert.Equal(DialogHue.Compute(DialogId, ChannelName), ingress.Pushes[0].Message.ChannelHue);
Assert.Equal("новое сообщение", ingress.Pushes[2].Message.Text);
// Паузы анти-бана: по одной на сообщение, диапазон 1.5–3 с (Ruling 3).
Assert.Equal(3, pacer.Waits.Count);
Assert.All(pacer.Waits, wait =>
{
Assert.InRange(wait.MinSeconds, 1.5, 1.5 + 0.001);
Assert.InRange(wait.MaxSeconds, 3.0, 3.0 + 0.001);
});
// «Перечитали» — сняли «новое» в Telegram.
Assert.Equal([DialogId], harness.Client.MarkedReadDialogs);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Backfill пустого диалога: 0 сообщений, без пауз; read-ack всё равно выполняется.
/// </summary>
[Fact]
public async Task Backfill_NoMessages_ProcessedZero_StillMarksRead()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
var ingress = new FakeIngress();
var pacer = new RecordingPacer();
BackfillService backfill = CreateService(harness.Farm, ingress, pacer);
int processed = await backfill.ExecuteAsync(TenantId, DialogId, force: true, CancellationToken.None);
Assert.Equal(0, processed);
Assert.Empty(ingress.Pushes);
Assert.Empty(pacer.Waits);
Assert.Equal([DialogId], harness.Client.MarkedReadDialogs);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Второй backfill тенанта (другой диалог) ждёт 3–6 с между диалогами (python L347).
/// </summary>
[Fact]
public async Task Backfill_SecondDialog_WaitsDialogSpacing()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
harness.Client.Messages[DialogId] = NewestFirstMessages();
harness.Client.Messages["-10042"] = [Message("-10042", 7, "ещё одно")];
var ingress = new FakeIngress();
var pacer = new RecordingPacer();
BackfillService backfill = CreateService(harness.Farm, ingress, pacer);
await backfill.ExecuteAsync(TenantId, DialogId, force: false, CancellationToken.None);
pacer.Waits.Clear();
await backfill.ExecuteAsync(TenantId, "-10042", force: false, CancellationToken.None);
// Первая пауза второго backfill — меж-диалоговая (3–6 с), затем 1.5–3 с за сообщение.
Assert.Equal(2, pacer.Waits.Count);
Assert.InRange(pacer.Waits[0].MinSeconds, 2.9, 3.1);
Assert.InRange(pacer.Waits[0].MaxSeconds, 5.9, 6.1);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Сбой PushMessage прерывает backfill без read-ack (непрочитанное догонит sweep).
/// </summary>
[Fact]
public async Task Backfill_PushFails_Throws_WithoutMarkRead()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
harness.Client.Messages[DialogId] = NewestFirstMessages();
var ingress = new FakeIngress { PushError = new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable) };
BackfillService backfill = CreateService(harness.Farm, ingress, new RecordingPacer());
await Assert.ThrowsAsync<SessionException>(
() => backfill.ExecuteAsync(TenantId, DialogId, force: false, CancellationToken.None));
Assert.Empty(harness.Client.MarkedReadDialogs);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Backfill без готовой сессии тенанта → «Telegram не подключён» (FAILED_PRECONDITION).
/// </summary>
[Fact]
public async Task Backfill_NoReadySession_FailedPrecondition()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
BackfillService backfill = CreateService(harness.Farm, new FakeIngress(), new RecordingPacer());
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => backfill.ExecuteAsync("tenant-without-session", DialogId, force: false, CancellationToken.None));
Assert.Equal(StatusCode.FailedPrecondition, exception.Code);
Assert.Equal(SessionErrorMessages.NotConnected, exception.Message);
}
finally
{
await harness.DisposeAsync();
}
}
// Сообщения диалога от новых к старым (как get_messages) для сценариев backfill.
private static List<TelegramMessage> NewestFirstMessages()
=>
[
Message(DialogId, id: 30, text: "новое сообщение"),
Message(DialogId, id: 29, text: "среднее сообщение"),
Message(DialogId, id: 28, text: "старое сообщение"),
];
// Собирает нейтральное сообщение канала сценария.
// dialogId: Id диалога.
// id: Id сообщения.
// text: Текст.
private static TelegramMessage Message(string dialogId, int id, string text)
=> new(dialogId, id, text, dateMs: 1_700_000_000_000 + id, ChannelName, ChannelHandle);
// Служба backfill на пуле, фейк-канале в ядро и фейк-пейсере.
// farm: Пул сессий.
// ingress: Фейк ингресса.
// pacer: Фейк-пейсер (без реальных задержек).
private static BackfillService CreateService(SessionFarm farm, FakeIngress ingress, IBackfillPacer pacer)
=> new(farm, ingress, pacer, NullLogger<BackfillService>.Instance);
}
@@ -0,0 +1,106 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Grpc.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Тесты исходящего gRPC-канала в ядро (план Task 10 Acceptance: «PushMessage-клиент к in-proc
/// fake-серверу ингресса»). Клиент CoreIngressClient ходит по реальному gRPC (HTTP/2) на фейк-сервер
/// IngressService в процессе теста; проверяются metadata tenant-id/service-token (Ruling 1), тело
/// запроса/ответ и перевод недоступности ядра в SessionException UNAVAILABLE.
/// </summary>
public sealed class CoreIngressClientTests
{
private const string TenantId = "tenant-ingress";
private const string ServiceToken = "deal-test-token";
/// <summary>
/// PushMessage: тело уходит, metadata tenant-id/service-token на месте; ответ accepted.
/// </summary>
[Fact]
public async Task PushMessage_SendsMessageWithMetadata_AndReturnsReply()
{
(string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync();
try
{
CoreIngressClient client = CreateClient(endpoint);
PushMessageReply reply = await client.PushMessageAsync(
TenantId,
new PushMessageRequest
{
DialogId = "-1001234567890",
ChannelName = "IT Канал",
Text = "сообщение",
MsgId = 42,
},
CancellationToken.None);
Assert.True(reply.Accepted);
PushMessageRequest sent = Assert.Single(server.Pushes);
Assert.Equal("-1001234567890", sent.DialogId);
Assert.Equal("сообщение", sent.Text);
Assert.Equal(TenantId, Assert.Single(server.Tenants));
Assert.Equal(ServiceToken, Assert.Single(server.Tokens));
}
finally
{
await app.DisposeAsync();
}
}
/// <summary>
/// SyncDialogs: entries уходят; ответ ядра (monitored ids) возвращается списком.
/// </summary>
[Fact]
public async Task SyncDialogs_SendsEntries_AndReturnsMonitoredIds()
{
(string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync();
try
{
server.MonitoredIds.Add("-1001234567890");
CoreIngressClient client = CreateClient(endpoint);
var entries = new List<DialogEntry>
{
new() { Id = "-1001234567890", Name = "IT Канал", Kind = "channel" },
};
IReadOnlyList<string> monitored = await client.SyncDialogsAsync(TenantId, entries, CancellationToken.None);
Assert.Equal(["-1001234567890"], monitored);
SyncDialogsRequest sent = Assert.Single(server.Syncs);
Assert.Equal("-1001234567890", Assert.Single(sent.Entries).Id);
Assert.Equal(TenantId, Assert.Single(server.Tenants));
}
finally
{
await app.DisposeAsync();
}
}
/// <summary>
/// Ядро недоступно → SessionException UNAVAILABLE «Ядро недоступно…» (лог + повторный sweep).
/// </summary>
[Fact]
public async Task PushMessage_UnreachableCore_ThrowsSessionException()
{
// Порт без сервера: соединение отклоняется — вызов падает до deadline (RpcTimeout 15 с).
CoreIngressClient client = CreateClient("http://127.0.0.1:1");
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => client.PushMessageAsync(TenantId, new PushMessageRequest { DialogId = "-1001", Text = "x" }, CancellationToken.None));
Assert.Equal(StatusCode.Unavailable, exception.Code);
Assert.Equal(SessionErrorMessages.IngressUnavailable, exception.Message);
}
// Клиент с endpoint из опций (чтение env/config проверяет FromConfiguration).
// endpoint: Адрес фейк-сервера.
private static CoreIngressClient CreateClient(string endpoint)
=> new(CoreIngressOptions.Create(endpoint, ServiceToken), NullLogger<CoreIngressClient>.Instance);
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Deal.Telegram.Tests — интеграционные тесты каркаса telegram-service (план Task 2, Acceptance):
хост (Kestrel HTTP/2) поднимается в процессе теста на эфемерном порту через TelegramServiceHost.Create
(та же сборка, что и Program.cs), проверяются gRPC-health → SERVING и ServiceTokenInterceptor
(пустой/неверный токен → UNAUTHENTICATED). Стек тестов — как в src/core/tests/Deal.Tests.Unit.
-->
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<!-- Клиент gRPC в тестах (транспорт HTTP/2 + типы grpc.health.v1.Health). -->
<PackageReference Include="Grpc.Net.Client" Version="2.83.0" />
<PackageReference Include="Grpc.HealthCheck" Version="2.83.0" />
</ItemGroup>
<ItemGroup>
<!-- WebApplication/Kestrel-типы приходят из общего фреймворка ASP.NET Core (хосты тестов). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Deal.Telegram\Deal.Telegram.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,131 @@
using Deal.Telegram.Dialogs;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Unit-тесты зеркала каталога/мониторинга диалогов (план Task 10: DialogCatalog; Ruling 7 — ядро
/// владеет списком мониторинга, сервис держит зеркало в памяти). Проверяются SetMonitor/SetMonitorAll/
/// актуализация ответом SyncDialogs (ReplaceMonitored), изоляция тенантов и очистка после Logout.
/// </summary>
public sealed class DialogCatalogTests
{
private const string TenantA = "tenant-a";
private const string TenantB = "tenant-b";
private const string ChannelId = "-1001234567890";
private const string GroupId = "-456789";
private const string UserId = "+12345";
/// <summary>
/// SetMonitored включает/выключает диалог; IsMonitored повторяет решение.
/// </summary>
[Fact]
public void SetMonitored_TogglesDialog()
{
var catalog = new DialogCatalog();
Assert.False(catalog.IsMonitored(TenantA, ChannelId));
catalog.SetMonitored(TenantA, ChannelId, enabled: true);
Assert.True(catalog.IsMonitored(TenantA, ChannelId));
catalog.SetMonitored(TenantA, ChannelId, enabled: false);
Assert.False(catalog.IsMonitored(TenantA, ChannelId));
}
/// <summary>
/// Мониторинг изолирован по тенантам (1 аккаунт/тенант — чужие решения не видны).
/// </summary>
[Fact]
public void Monitored_IsIsolatedPerTenant()
{
var catalog = new DialogCatalog();
catalog.SetMonitored(TenantA, ChannelId, enabled: true);
Assert.True(catalog.IsMonitored(TenantA, ChannelId));
Assert.False(catalog.IsMonitored(TenantB, ChannelId));
}
/// <summary>
/// SetMonitorAll(true) мониторит каталог и возвращает его размер; false — снимает всё.
/// </summary>
[Fact]
public void SetAllMonitored_EnablesKnownDialogs_AndReturnsCount()
{
var catalog = new DialogCatalog();
catalog.ReplaceKnown(TenantA, [ChannelId, GroupId, UserId]);
int count = catalog.SetAllMonitored(TenantA, enabled: true);
Assert.Equal(3, count);
Assert.True(catalog.IsMonitored(TenantA, ChannelId));
Assert.True(catalog.IsMonitored(TenantA, GroupId));
Assert.True(catalog.IsMonitored(TenantA, UserId));
catalog.SetAllMonitored(TenantA, enabled: false);
Assert.False(catalog.IsMonitored(TenantA, ChannelId));
Assert.False(catalog.IsMonitored(TenantA, GroupId));
Assert.Equal(3, catalog.KnownCount(TenantA));
}
/// <summary>
/// SetMonitorAll(true) сохраняет мониторинг записей вне каталога (каталог может отставать).
/// </summary>
[Fact]
public void SetAllMonitored_TrueKeepsExistingMonitoredOutsideKnown()
{
var catalog = new DialogCatalog();
catalog.SetMonitored(TenantA, UserId, enabled: true);
catalog.SetAllMonitored(TenantA, enabled: true);
Assert.True(catalog.IsMonitored(TenantA, UserId));
}
/// <summary>
/// Актуализация ответом SyncDialogs (ReplaceMonitored) — авторитетный monitored-набор.
/// </summary>
[Fact]
public void ReplaceMonitored_OverridesMirror_WithCoreReply()
{
var catalog = new DialogCatalog();
catalog.SetMonitored(TenantA, GroupId, enabled: true);
catalog.SetMonitored(TenantA, UserId, enabled: true);
catalog.ReplaceKnown(TenantA, [ChannelId, GroupId]);
catalog.ReplaceMonitored(TenantA, [GroupId]);
Assert.False(catalog.IsMonitored(TenantA, UserId)); // ядро сняло мониторинг (нет в каталоге)
Assert.True(catalog.IsMonitored(TenantA, GroupId));
Assert.False(catalog.IsMonitored(TenantA, ChannelId));
}
/// <summary>
/// Reset (Logout) очищает каталог и мониторинг тенанта (прототип L203: `_monitored.clear()`).
/// </summary>
[Fact]
public void Reset_ClearsTenantState()
{
var catalog = new DialogCatalog();
catalog.ReplaceKnown(TenantA, [ChannelId]);
catalog.SetMonitored(TenantA, ChannelId, enabled: true);
catalog.Reset(TenantA);
Assert.Equal(0, catalog.KnownCount(TenantA));
Assert.False(catalog.IsMonitored(TenantA, ChannelId));
}
/// <summary>
/// Каталог пустого тенанта: счётчик 0 и «не мониторится» без создания состояния.
/// </summary>
[Fact]
public void UnknownTenant_ReportsEmptyState()
{
var catalog = new DialogCatalog();
Assert.Equal(0, catalog.KnownCount(TenantB));
Assert.False(catalog.IsMonitored(TenantB, ChannelId));
Assert.Empty(catalog.SnapshotMonitored(TenantB));
}
}
@@ -0,0 +1,54 @@
using Deal.Telegram.Dialogs;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Unit-тесты цвета диалога (план Task 10: DialogHue — 1:1 dialog_hue прототипа L876–880 и палитры
/// constants.py DIALOG_HUES). Ожидаемые значения посчитаны python-прототипом (те же алгоритм/палитра) —
/// паритет цветов источников между реализациями.
/// </summary>
public sealed class DialogHueTests
{
/// <summary>
/// Цвет по паре (id, имя) совпадает с python-прототипом.
/// </summary>
[Theory]
[InlineData("-1001234567890", "IT Канал", "#a78bfa")]
[InlineData("-123456", "Уютный чат", "#34d399")]
[InlineData("+79990001122", "Иван Петров", "#fb7185")]
[InlineData("-100999", "", "#34d399")]
public void Compute_MatchesPythonPrototype(string dialogId, string name, string expectedHue)
{
Assert.Equal(expectedHue, DialogHue.Compute(dialogId, name));
}
/// <summary>
/// Цвет детерминирован: повторный вызов возвращает то же значение.
/// </summary>
[Fact]
public void Compute_IsDeterministic()
{
string first = DialogHue.Compute("-1001234567890", "IT Канал");
Assert.Equal(first, DialogHue.Compute("-1001234567890", "IT Канал"));
}
/// <summary>
/// Разные источники распределяются по палитре (цвета коллизируют не все вместе).
/// </summary>
[Fact]
public void Compute_DifferentSources_DistributeAcrossPalette()
{
var hues = new[]
{
DialogHue.Compute("-100111", "Канал А"),
DialogHue.Compute("-100222", "Канал Б"),
DialogHue.Compute("+79990001122", "Иван Петров"),
DialogHue.Compute("-5", "Работа"),
};
// Ожидание посчитано python-прототипом: 4 источника дают 3 разных цвета палитры.
Assert.Equal(3, hues.Distinct().Count());
}
}
@@ -0,0 +1,288 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Telegram;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// RPC-тесты каталога/мониторинга поверх реального gRPC-хоста (план Task 10: RefreshDialogs/
/// SetMonitor/SetMonitorAll/Backfill/ReadRecent). Сеть Telegram не используется: ready-сессия —
/// телефонный вход на фейк-клиенте (как Task 9), канал в ядро — in-proc фейк-сервер IngressService
/// (проводка реального gRPC, metadata tenant/service-token), анти-бан-паузы — фейк-пейсер.
/// </summary>
public sealed class DialogRpcTests
{
private const string TenantId = TelegramTestHost.DefaultTenantId;
private const string Phone = "+79990001122";
private const string Code = "11111";
private const string DialogA = "-100111";
private const string DialogB = "-100222";
private const int PollTimeoutMilliseconds = 5000;
/// <summary>
/// RefreshDialogs ready-сессии: актуальный каталог уходит в ответ и синком в ядро.
/// </summary>
[Fact]
public async Task RefreshDialogs_ReturnsEntries_AndSyncsToCore()
{
await RunScenarioAsync(
async (channel, factory, ingressServer) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().Dialogs.AddRange(TestDialogs());
RefreshDialogsReply reply = await client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options());
Assert.Equal(2, reply.Entries.Count);
Assert.Equal(DialogA, reply.Entries[0].Id);
Assert.Equal("Канал А", reply.Entries[0].Name);
Assert.Equal("channel", reply.Entries[0].Kind);
SyncDialogsRequest sync = Assert.Single(ingressServer.Syncs);
Assert.Equal(2, sync.Entries.Count);
Assert.Equal(TenantId, Assert.Single(ingressServer.Tenants));
Assert.Equal(TelegramTestHost.DefaultToken, Assert.Single(ingressServer.Tokens));
});
}
/// <summary>
/// RefreshDialogs без сессии → FAILED_PRECONDITION «Telegram не подключён».
/// </summary>
[Fact]
public async Task RefreshDialogs_NoSession_FailedPrecondition()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options()).ResponseAsync);
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail);
});
}
/// <summary>
/// SetMonitor включает мониторинг диалога (ответ ok/enabled) после refresh каталога.
/// </summary>
[Fact]
public async Task SetMonitor_EnablesAndDisables()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().Dialogs.AddRange(TestDialogs());
await client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options());
SetMonitorReply enabled = await client.SetMonitorAsync(
new SetMonitorRequest { DialogId = DialogA, Enabled = true }, Options());
SetMonitorReply disabled = await client.SetMonitorAsync(
new SetMonitorRequest { DialogId = DialogA, Enabled = false }, Options());
Assert.True(enabled.Ok);
Assert.True(enabled.Enabled);
Assert.True(disabled.Ok);
Assert.False(disabled.Enabled);
});
}
/// <summary>
/// SetMonitorAll: count = размер каталога; повторный off снимает мониторинг (ok=true).
/// </summary>
[Fact]
public async Task SetMonitorAll_ReturnsCatalogCount()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().Dialogs.AddRange(TestDialogs());
await client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options());
SetMonitorAllReply reply = await client.SetMonitorAllAsync(
new SetMonitorAllRequest { Enabled = true }, Options());
Assert.True(reply.Ok);
Assert.Equal(2, reply.Count);
Assert.True(reply.Enabled);
SetMonitorAllReply off = await client.SetMonitorAllAsync(
new SetMonitorAllRequest { Enabled = false }, Options());
Assert.True(off.Ok);
Assert.Equal(2, off.Count);
Assert.False(off.Enabled);
});
}
/// <summary>
/// Backfill: последние сообщения уходят в ядро (in-proc ингресс) + read-ack на сессии.
/// </summary>
[Fact]
public async Task Backfill_PushesRecentMessages_AndMarksRead()
{
await RunScenarioAsync(
async (channel, factory, ingressServer) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().Messages[DialogA] =
[
new TelegramMessage(DialogA, 10, "свежее", 1_700_000_000_010, "Канал А", "ch_a"),
new TelegramMessage(DialogA, 9, "старое", 1_700_000_000_009, "Канал А", "ch_a"),
];
BackfillReply reply = await client.BackfillAsync(
new BackfillRequest { DialogId = DialogA, Force = true }, Options());
Assert.Equal(2, reply.Processed);
Assert.Equal(2, ingressServer.Pushes.Count);
Assert.Equal([9, 10], ingressServer.Pushes.Select(push => push.MsgId));
Assert.All(ingressServer.Tenants, tenant => Assert.Equal(TenantId, tenant));
Assert.Equal([DialogA], factory.CreatedClients.Single().MarkedReadDialogs);
});
}
/// <summary>
/// Backfill без готовой сессии → «Telegram не подключён» (FAILED_PRECONDITION).
/// </summary>
[Fact]
public async Task Backfill_NoSession_FailedPrecondition()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.BackfillAsync(new BackfillRequest { DialogId = DialogA }, Options()).ResponseAsync);
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
});
}
/// <summary>
/// ReadRecent: свежие сообщения превью (от новых к старым) + read-ack после просмотра.
/// </summary>
[Fact]
public async Task ReadRecent_ReturnsFreshPreview_NewestFirst()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().Messages[DialogA] =
[
new TelegramMessage(DialogA, 12, "свежее", 1_700_000_000_012, "Канал А", "ch_a"),
new TelegramMessage(DialogA, 11, "среднее", 1_700_000_000_011, "Канал А", "ch_a"),
new TelegramMessage(DialogA, 10, "старое", 1_700_000_000_010, "Канал А", "ch_a"),
];
ReadRecentReply reply = await client.ReadRecentAsync(
new ReadRecentRequest { DialogId = DialogA, Limit = 2 }, Options());
Assert.Equal(2, reply.Messages.Count);
Assert.Equal("12", reply.Messages[0].Id);
Assert.Equal("свежее", reply.Messages[0].Text);
Assert.Equal(1_700_000_000_012, reply.Messages[0].Time);
Assert.Equal("11", reply.Messages[1].Id);
Assert.Equal([DialogA], factory.CreatedClients.Single().MarkedReadDialogs);
});
}
/// <summary>
/// ReadRecent пустого диалога: пустой превью без падения (фолбэк на БД делает ядро).
/// </summary>
[Fact]
public async Task ReadRecent_EmptyDialog_ReturnsEmptyPreview()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
ReadRecentReply reply = await client.ReadRecentAsync(
new ReadRecentRequest { DialogId = DialogB, Limit = 10 }, Options());
Assert.Empty(reply.Messages);
});
}
// Диалоги сценария (как get_dialogs фейк-клиента, от свежих к старым).
private static List<TelegramDialog> TestDialogs()
=>
[
new TelegramDialog(DialogA, "Канал А", "ch_a", DialogKinds.Channel, unreadCount: 0, topMessageId: 1),
new TelegramDialog(DialogB, "Канал Б", "ch_b", DialogKinds.Group, unreadCount: 0, topMessageId: 1),
];
// Вход по телефону на фейк-клиенте до фазы ready (StartPhone → SendCode).
// client: Клиент TelegramService.
private static async Task LoginAsync(TelegramService.TelegramServiceClient client)
{
await client.StartPhoneAsync(
new StartPhoneRequest { Phone = Phone, ApiId = SessionHarness.ApiId, ApiHash = SessionHarness.ApiHash },
Options());
await client.SendCodeAsync(new SendCodeRequest { Code = Code }, Options());
await WaitForPhaseAsync(client, "ready");
}
// Поллинг GetStatus до ожидаемой фазы.
// client: Клиент TelegramService.
// phase: Ожидаемая фаза ("ready").
private static async Task WaitForPhaseAsync(TelegramService.TelegramServiceClient client, string phase)
{
var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds);
while (DateTime.UtcNow < deadline)
{
GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options());
if (status.Phase == phase)
{
return;
}
await Task.Delay(25);
}
}
// Прогоняет сценарий на хосте с фейковой фабрикой клиентов, фейк-пейсером и in-proc ингрессом.
// scenario: Сценарий (канал, фабрика, фейк-сервер ингресса).
private static async Task RunScenarioAsync(Func<GrpcChannel, FakeClientFactory, RecordingIngressService, Task> scenario)
{
var factory = new FakeClientFactory();
var pacer = new RecordingPacer();
(string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync();
try
{
var extraEnv = new Dictionary<string, string?> { [TelegramTestHost.IngressEndpointEnvKey] = endpoint };
await TelegramTestHost.RunAsync(
TelegramTestHost.DefaultToken,
channel => scenario(channel, factory, server),
configureServices: services =>
{
services.AddSingleton<ITelegramClientFactory>(factory);
services.RemoveAll<IBackfillPacer>();
services.AddSingleton<IBackfillPacer>(pacer);
},
extraEnv: extraEnv);
}
finally
{
await app.DisposeAsync();
}
}
// Опции вызова с токеном и tenant-id.
private static CallOptions Options()
=> new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, TenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds));
}
@@ -0,0 +1,291 @@
using Deal.Telegram.Discovery;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Unit-тесты discovery-операций (план Task 11): поиск с паузой 2–4 с и дедупом/обрезкой, join по
/// username (нормализация; пустой → INVALID_ARGUMENT; FloodWait → RESOURCE_EXHAUSTED с префиксом "flood"),
/// паузы в join нет (внешний анти-бан — воркер ядра, Ruling 10), изоляция тенантов (операции только на
/// сессии своего тенанта; нет сессии → «Telegram не подключён»). Без сети: фейк-клиент и фейк-пейсер.
/// </summary>
public sealed class DiscoveryOpsTests
{
private const string TenantId = "tenant-discovery";
private const string OtherTenantId = "tenant-other";
private const string ChannelA = "-100111";
private const string ChannelB = "-100222";
private const string UserC = "+777333";
/// <summary>
/// Поиск: дедуп по id + обрезка до лимита (порядок сохранён) и пауза анти-бана 2–4 с.
/// </summary>
[Fact]
public async Task Search_DedupesAndCapsResults_PausesTwoToFourSeconds()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
harness.Client.SearchResults.AddRange(
[
Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel),
Entry(ChannelB, "Канал Б", "ch_b", DialogKinds.Group),
Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel), // дубль id
Entry(UserC, "Чат В", "user_c", DialogKinds.Chat),
]);
var pacer = new RecordingPacer();
DiscoveryOps ops = CreateOps(harness, pacer);
IReadOnlyList<TelegramDialog> result = await ops.SearchAsync(TenantId, "python", limit: 3, CancellationToken.None);
Assert.Equal([ChannelA, ChannelB, UserC], result.Select(item => item.Id).ToArray());
Assert.Equal(("python", 3), Assert.Single(harness.Client.SearchQueries));
// Пауза между поисковыми запросами: 2–4 с (Ruling 3, ban_guard.search_pause).
Assert.Equal((DiscoveryOps.SearchPauseMinSeconds, DiscoveryOps.SearchPauseMaxSeconds), Assert.Single(pacer.Waits));
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Поиск без лимита (0) → дефолт прототипа 30 (SearchRequest limit=30, L624).
/// </summary>
[Fact]
public async Task Search_LimitZero_UsesDefaultThirty()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
harness.Client.SearchResults.AddRange([Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel)]);
DiscoveryOps ops = CreateOps(harness, new RecordingPacer());
await ops.SearchAsync(TenantId, "jobs", limit: 0, CancellationToken.None);
Assert.Equal(("jobs", DiscoveryOps.SearchDefaultLimit), Assert.Single(harness.Client.SearchQueries));
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Поиск без готовой сессии → «Telegram не подключён» (FAILED_PRECONDITION).
/// </summary>
[Fact]
public async Task Search_NoSession_FailedPrecondition()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
DiscoveryOps ops = CreateOps(harness, new RecordingPacer());
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => ops.SearchAsync(OtherTenantId, "jobs", limit: 10, CancellationToken.None));
Assert.Equal(StatusCode.FailedPrecondition, exception.Code);
Assert.Equal(SessionErrorMessages.NotConnected, exception.Message);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// GetInfo: возвращает инфо сессии как есть (без пауз и сетевых обёрток).
/// </summary>
[Fact]
public async Task GetInfo_ReturnsSourceInfo()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
var info = new TelegramSourceInfo(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, participants: 1234, isForum: true);
harness.Client.SourceInfos[ChannelA] = info;
DiscoveryOps ops = CreateOps(harness, new RecordingPacer());
TelegramSourceInfo actual = await ops.GetInfoAsync(TenantId, ChannelA, CancellationToken.None);
Assert.Equal(info, actual);
Assert.Equal([ChannelA], harness.Client.InfoRequests);
Assert.Empty(harness.Client.ReadRequests);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// ReadForEval: passthrough результата чтения сессии (ok:false no_history — не ошибка).
/// </summary>
[Fact]
public async Task ReadForEval_ReturnsSessionResult()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
DiscoveryReadResult expected = DiscoveryReadResult.NoHistory();
harness.Client.ReadForEvalResults[ChannelB] = expected;
DiscoveryOps ops = CreateOps(harness, new RecordingPacer());
DiscoveryReadResult actual = await ops.ReadForEvalAsync(TenantId, ChannelB, limit: 0, CancellationToken.None);
Assert.False(actual.Ok);
Assert.Equal(DiscoveryReadResult.NoHistoryError, actual.Error);
Assert.Equal([ChannelB], harness.Client.ReadRequests);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Join: нормализация username («@»/пробелы) и отсутствие пауз (внешний анти-бан — ядро).
/// </summary>
[Fact]
public async Task Join_NormalizesUsername_WithoutPause()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
var pacer = new RecordingPacer();
DiscoveryOps ops = CreateOps(harness, pacer);
await ops.JoinAsync(TenantId, " @it_channel ", CancellationToken.None);
Assert.Equal(["it_channel"], harness.Client.JoinedUsernames);
Assert.Empty(pacer.Waits);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Join пустого username → INVALID_ARGUMENT «Не указан username для вступления» (L828).
/// </summary>
[Fact]
public async Task Join_EmptyUsername_InvalidArgument()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
DiscoveryOps ops = CreateOps(harness, new RecordingPacer());
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => ops.JoinAsync(TenantId, "@", CancellationToken.None));
Assert.Equal(StatusCode.InvalidArgument, exception.Code);
Assert.Equal(SessionErrorMessages.JoinUsernameMissing, exception.Message);
Assert.Empty(harness.Client.JoinedUsernames);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" (флуд-гард, контракт).
/// </summary>
[Fact]
public async Task Join_FloodWait_ResourceExhaustedFlood()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
harness.Client.JoinError = new SessionException(StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120");
DiscoveryOps ops = CreateOps(harness, new RecordingPacer());
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => ops.JoinAsync(TenantId, "it_channel", CancellationToken.None));
Assert.Equal(StatusCode.ResourceExhausted, exception.Code);
Assert.StartsWith("flood", exception.Message, StringComparison.Ordinal);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Leave: passthrough id на сессию (без пауз).
/// </summary>
[Fact]
public async Task Leave_PassesDialogId()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
DiscoveryOps ops = CreateOps(harness, new RecordingPacer());
await ops.LeaveAsync(TenantId, ChannelA, CancellationToken.None);
Assert.Equal([ChannelA], harness.Client.LeaveCalls);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Изоляция тенантов: у каждого тенанта свои результаты (сессия 1 акк/тенант, Ruling 1).
/// </summary>
[Fact]
public async Task TenantIsolation_SearchUsesOnlyOwnSession()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
await harness.Farm.StartPhoneAsync(
OtherTenantId,
SessionHarness.ApiId,
SessionHarness.ApiHash,
"+79990001133",
CancellationToken.None);
await harness.Farm.SendCodeAsync(OtherTenantId, "11111", CancellationToken.None);
FakeSessionClient tenantA = harness.Factory.CreatedClients[0];
FakeSessionClient tenantB = harness.Factory.CreatedClients[1];
tenantA.SearchResults.Add(Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel));
tenantB.SearchResults.Add(Entry(ChannelB, "Канал Б", "ch_b", DialogKinds.Channel));
DiscoveryOps ops = CreateOps(harness, new RecordingPacer());
IReadOnlyList<TelegramDialog> forA = await ops.SearchAsync(TenantId, "a", limit: 10, CancellationToken.None);
IReadOnlyList<TelegramDialog> forB = await ops.SearchAsync(OtherTenantId, "b", limit: 10, CancellationToken.None);
Assert.Equal([ChannelA], forA.Select(item => item.Id).ToArray());
Assert.Equal([ChannelB], forB.Select(item => item.Id).ToArray());
}
finally
{
await harness.DisposeAsync();
}
}
// Запись поиска сценария (kind EN-канона).
// id: Подписанный id.
// name: Имя.
// username: Username.
// kind: Тип канона.
private static TelegramDialog Entry(string id, string name, string username, string kind)
=> new(id, name, username, kind, unreadCount: 0, topMessageId: 0);
// Служба discovery на пуле с фейк-клиентом и фейк-пейсером.
// harness: Харнесс ready-сессии.
// pacer: Фейк-пейсер (без реальных задержек).
private static DiscoveryOps CreateOps(FarmHarness harness, RecordingPacer pacer)
=> new(harness.Farm, pacer, NullLogger<DiscoveryOps>.Instance);
}
@@ -0,0 +1,133 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Discovery;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Telegram;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Unit-тесты маппера ответов discovery (план Task 11 Acceptance: «формат ответов — тесты на чистых
/// мапперах»): нейтральные результаты → ChannelInfo/EvalMessage/ReadForEvalReply контракта. TL-слой не
/// участвует (без сети и фейков сессии) — только чистые мапперы нейтральных типов.
/// </summary>
public sealed class DiscoveryProtoMapperTests
{
/// <summary>
/// Инфо источника → ChannelInfo: все поля, hue сервиса, optional participants/is_forum.
/// </summary>
[Fact]
public void ToChannelInfo_MapsAllFields()
{
var info = new TelegramSourceInfo("-1005", "IT Канал", "it_channel", DialogKinds.Channel, participants: 12_345, isForum: false);
ChannelInfo result = DiscoveryProtoMapper.ToChannelInfo(info);
Assert.Equal("-1005", result.Id);
Assert.Equal("IT Канал", result.Name);
Assert.Equal("it_channel", result.Username);
Assert.Equal(DialogKinds.Channel, result.Kind);
Assert.Equal(DialogHue.Compute("-1005", "IT Канал"), result.Hue);
Assert.Equal(12_345, result.Participants);
Assert.False(result.IsForum);
}
/// <summary>
/// Инфо по умолчанию (сущность недоступна): name=id, kind пуст, participants не выставлен.
/// </summary>
[Fact]
public void ToChannelInfo_UnknownInfo_LeavesParticipantsUnset()
{
var info = new TelegramSourceInfo("-1005", "-1005", string.Empty, string.Empty, participants: null, isForum: false);
ChannelInfo result = DiscoveryProtoMapper.ToChannelInfo(info);
Assert.Equal("-1005", result.Name);
Assert.Empty(result.Kind);
Assert.False(result.HasParticipants);
}
/// <summary>
/// Форум: kind канона + is_forum=true (ядро трактует kind как forum, Ruling 10).
/// </summary>
[Fact]
public void ToChannelInfo_Forum_SetsIsForum()
{
var info = new TelegramSourceInfo("-1009", "Форум", "forum_hub", DialogKinds.Group, participants: 900, isForum: true);
ChannelInfo result = DiscoveryProtoMapper.ToChannelInfo(info);
Assert.True(result.IsForum);
Assert.Equal(DialogKinds.Group, result.Kind);
}
/// <summary>
/// Сообщение темы форума → EvalMessage: поля и topic_id/topic_title.
/// </summary>
[Fact]
public void ToEvalMessage_MapsTopicFields()
{
var message = new DiscoveryMessage(42, "Текст сообщения", 1_700_000_000_123, topicId: 7, topicTitle: "Вакансии");
EvalMessage result = DiscoveryProtoMapper.ToEvalMessage(message);
Assert.Equal(42, result.Id);
Assert.Equal("Текст сообщения", result.Text);
Assert.Equal(1_700_000_000_123, result.DateMs);
Assert.Equal(7, result.TopicId);
Assert.Equal("Вакансии", result.TopicTitle);
}
/// <summary>
/// Сообщение обычной ленты → EvalMessage: topic-поля не выставлены (optional пуст).
/// </summary>
[Fact]
public void ToEvalMessage_PlainFeed_LeavesTopicUnset()
{
var message = new DiscoveryMessage(1, "Просто текст", 1_700_000_000_000, topicId: null, topicTitle: null);
EvalMessage result = DiscoveryProtoMapper.ToEvalMessage(message);
Assert.False(result.HasTopicId);
Assert.False(result.HasTopicTitle);
}
/// <summary>
/// Успешное чтение → ReadForEvalReply ok:true + сообщения в порядке выборки.
/// </summary>
[Fact]
public void ToReadForEvalReply_OkTrue_AddsMessages()
{
var result = new DiscoveryReadResult(
ok: true,
error: null,
messages:
[
new DiscoveryMessage(10, "Первое", 1_700_000_000_010, null, null),
new DiscoveryMessage(11, "Второе", 1_700_000_000_011, 3, "Тема"),
]);
ReadForEvalReply reply = DiscoveryProtoMapper.ToReadForEvalReply(result);
Assert.True(reply.Ok);
Assert.False(reply.HasError);
Assert.Equal(2, reply.Messages.Count);
Assert.Equal("Первое", reply.Messages[0].Text);
Assert.Equal("Второе", reply.Messages[1].Text);
Assert.Equal(3, reply.Messages[1].TopicId);
Assert.Equal("Тема", reply.Messages[1].TopicTitle);
}
/// <summary>
/// История недоступна → ReadForEvalReply ok:false + error="no_history" (не ошибка RPC).
/// </summary>
[Fact]
public void ToReadForEvalReply_NoHistory_OkFalseWithError()
{
ReadForEvalReply reply = DiscoveryProtoMapper.ToReadForEvalReply(DiscoveryReadResult.NoHistory());
Assert.False(reply.Ok);
Assert.Equal(DiscoveryReadResult.NoHistoryError, reply.Error);
Assert.Empty(reply.Messages);
}
}
@@ -0,0 +1,328 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Telegram;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// RPC-тесты discovery-операций поверх реального gRPC-хоста (план Task 11: Search/GetInfo/ReadForEval/
/// Join/Leave). Сеть Telegram не используется: ready-сессия — телефонный вход на фейк-клиенте, данные
/// discovery задаёт тест на фейке; анти-бан-пауза поиска — фейк-пейсер (без реальных задержек).
/// </summary>
public sealed class DiscoveryRpcTests
{
private const string TenantId = TelegramTestHost.DefaultTenantId;
private const string TenantB = "tenant-discovery-b";
private const string Phone = "+79990001122";
private const string Code = "11111";
private const string ChannelA = "-100111";
private const string ChannelB = "-100222";
private const int PollTimeoutMilliseconds = 5000;
/// <summary>
/// Search ready-сессии: найденные источники в entries ответа + анти-бан-пауза 2–4 с.
/// </summary>
[Fact]
public async Task Search_ReturnsFoundEntries_AndWaits()
{
await RunScenarioAsync(
async (channel, factory, pacer) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().SearchResults.AddRange(
[
new TelegramDialog(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, 0, 0),
new TelegramDialog(ChannelB, "Канал Б", "ch_b", DialogKinds.Group, 0, 0),
]);
SearchReply reply = await client.SearchAsync(new SearchRequest { Query = "python", Limit = 10 }, Options());
Assert.Equal(2, reply.Results.Count);
Assert.Equal(ChannelA, reply.Results[0].Id);
Assert.Equal("Канал А", reply.Results[0].Name);
Assert.Equal("ch_a", reply.Results[0].Username);
Assert.Equal(DialogKinds.Channel, reply.Results[0].Kind);
Assert.Equal(DialogHue.Compute(ChannelA, "Канал А"), reply.Results[0].Hue);
Assert.Equal((2.0, 4.0), Assert.Single(pacer.Waits));
});
}
/// <summary>
/// Search без готовой сессии → FAILED_PRECONDITION «Telegram не подключён».
/// </summary>
[Fact]
public async Task Search_NoSession_FailedPrecondition()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.SearchAsync(new SearchRequest { Query = "python" }, Options()).ResponseAsync);
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail);
});
}
/// <summary>
/// GetInfo: имя/username/kind/participants/is_forum из фейка (маппинг ChannelInfo).
/// </summary>
[Fact]
public async Task GetInfo_ReturnsChannelInfo()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().SourceInfos[ChannelA] = new TelegramSourceInfo(
ChannelA, "IT Канал", "it_channel", DialogKinds.Channel, participants: 42_000, isForum: true);
GetInfoReply reply = await client.GetInfoAsync(new GetInfoRequest { DialogId = ChannelA }, Options());
Assert.Equal(ChannelA, reply.Info.Id);
Assert.Equal("IT Канал", reply.Info.Name);
Assert.Equal("it_channel", reply.Info.Username);
Assert.Equal(DialogKinds.Channel, reply.Info.Kind);
Assert.Equal(42_000, reply.Info.Participants);
Assert.True(reply.Info.IsForum);
Assert.Equal(DialogHue.Compute(ChannelA, "IT Канал"), reply.Info.Hue);
});
}
/// <summary>
/// ReadForEval: выборка ok:true с сообщениями (в т.ч. темами форума).
/// </summary>
[Fact]
public async Task ReadForEval_ReturnsMessages_Ok()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().ReadForEvalResults[ChannelA] = new DiscoveryReadResult(
ok: true,
error: null,
messages:
[
new DiscoveryMessage(10, "Сообщение темы", 1_700_000_000_010, topicId: 3, topicTitle: "Вакансии"),
new DiscoveryMessage(9, "Обычное", 1_700_000_000_009, null, null),
]);
ReadForEvalReply reply = await client.ReadForEvalAsync(
new ReadForEvalRequest { DialogId = ChannelA, Limit = 30 }, Options());
Assert.True(reply.Ok);
Assert.False(reply.HasError);
Assert.Equal(2, reply.Messages.Count);
Assert.Equal(3, reply.Messages[0].TopicId);
Assert.Equal("Вакансии", reply.Messages[0].TopicTitle);
Assert.False(reply.Messages[1].HasTopicId);
Assert.Equal(9, reply.Messages[1].Id);
});
}
/// <summary>
/// ReadForEval недоступной истории: ok:false + error=no_history (это НЕ ошибка RPC).
/// </summary>
[Fact]
public async Task ReadForEval_HistoryUnavailable_OkFalseNoHistory()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().ReadForEvalResults[ChannelB] = DiscoveryReadResult.NoHistory();
ReadForEvalReply reply = await client.ReadForEvalAsync(
new ReadForEvalRequest { DialogId = ChannelB, Limit = 30 }, Options());
Assert.False(reply.Ok);
Assert.Equal(DiscoveryReadResult.NoHistoryError, reply.Error);
Assert.Empty(reply.Messages);
});
}
/// <summary>
/// Join: вступление по username (нормализованному) → ok:true; пауз нет (внешний анти-бан — ядро).
/// </summary>
[Fact]
public async Task Join_JoinsByNormalizedUsername_Ok()
{
await RunScenarioAsync(
async (channel, factory, pacer) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
JoinReply reply = await client.JoinAsync(new JoinRequest { Username = "@it_channel" }, Options());
Assert.True(reply.Ok);
Assert.Equal(["it_channel"], factory.CreatedClients.Single().JoinedUsernames);
Assert.Empty(pacer.Waits);
});
}
/// <summary>
/// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" (контракт).
/// </summary>
[Fact]
public async Task Join_FloodWait_ResourceExhausted()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
factory.CreatedClients.Single().JoinError = new Sessions.SessionException(
StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120");
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.JoinAsync(new JoinRequest { Username = "it_channel" }, Options()).ResponseAsync);
Assert.Equal(StatusCode.ResourceExhausted, exception.StatusCode);
Assert.StartsWith("flood", exception.Status.Detail, StringComparison.Ordinal);
});
}
/// <summary>
/// Join без username → INVALID_ARGUMENT (текст 1:1 прототипа).
/// </summary>
[Fact]
public async Task Join_EmptyUsername_InvalidArgument()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.JoinAsync(new JoinRequest { Username = " " }, Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.JoinUsernameMissing, exception.Status.Detail);
});
}
/// <summary>
/// Серверная граница длины Search.query: длиннее лимита → INVALID_ARGUMENT без сессии/поиска.
/// </summary>
[Fact]
public async Task Search_TooLongQuery_InvalidArgument()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.SearchAsync(new SearchRequest { Query = new string('a', 201) }, Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.SearchQueryTooLong, exception.Status.Detail);
});
}
/// <summary>
/// Серверная граница длины Join.username (лимит Telegram 32): длиннее → INVALID_ARGUMENT.
/// </summary>
[Fact]
public async Task Join_TooLongUsername_InvalidArgument()
{
await RunScenarioAsync(
async (channel, _, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.JoinAsync(new JoinRequest { Username = new string('u', 33) }, Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.UsernameTooLong, exception.Status.Detail);
});
}
/// <summary>
/// Изоляция тенантов: поиск идёт по сессии своего тенанта (metadata tenant-id, Ruling 1).
/// </summary>
[Fact]
public async Task TenantIsolation_SearchOnOwnTenantSession()
{
await RunScenarioAsync(
async (channel, factory, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await LoginAsync(client);
await LoginAsync(client, TenantB, "+79990001133");
factory.CreatedClients[0].SearchResults.Add(new TelegramDialog(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, 0, 0));
factory.CreatedClients[1].SearchResults.Add(new TelegramDialog(ChannelB, "Канал Б", "ch_b", DialogKinds.Channel, 0, 0));
SearchReply forA = await client.SearchAsync(new SearchRequest { Query = "a" }, Options(TenantId));
SearchReply forB = await client.SearchAsync(new SearchRequest { Query = "b" }, Options(TenantB));
Assert.Equal([ChannelA], forA.Results.Select(entry => entry.Id).ToArray());
Assert.Equal([ChannelB], forB.Results.Select(entry => entry.Id).ToArray());
});
}
// Вход по телефону на фейк-клиенте до фазы ready (StartPhone → SendCode).
// client: Клиент TelegramService.
// tenantId: Id тенанта сценария.
// phone: Номер телефона.
private static async Task LoginAsync(TelegramService.TelegramServiceClient client, string tenantId = TenantId, string phone = Phone)
{
await client.StartPhoneAsync(
new StartPhoneRequest { Phone = phone, ApiId = SessionHarness.ApiId, ApiHash = SessionHarness.ApiHash },
Options(tenantId));
await client.SendCodeAsync(new SendCodeRequest { Code = Code }, Options(tenantId));
await WaitForPhaseAsync(client, tenantId, "ready");
}
// Поллинг GetStatus до ожидаемой фазы.
// client: Клиент TelegramService.
// tenantId: Id тенанта.
// phase: Ожидаемая фаза ("ready").
private static async Task WaitForPhaseAsync(TelegramService.TelegramServiceClient client, string tenantId, string phase)
{
var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds);
while (DateTime.UtcNow < deadline)
{
GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options(tenantId));
if (status.Phase == phase)
{
return;
}
await Task.Delay(25);
}
}
// Прогоняет сценарий на хосте с фейковой фабрикой клиентов и фейк-пейсером.
// scenario: Сценарий (канал, фабрика, фейк-пейсер).
private static async Task RunScenarioAsync(Func<GrpcChannel, FakeClientFactory, RecordingPacer, Task> scenario)
{
var factory = new FakeClientFactory();
var pacer = new RecordingPacer();
await TelegramTestHost.RunAsync(
TelegramTestHost.DefaultToken,
channel => scenario(channel, factory, pacer),
configureServices: services =>
{
services.AddSingleton<ITelegramClientFactory>(factory);
services.RemoveAll<IBackfillPacer>();
services.AddSingleton<IBackfillPacer>(pacer);
});
}
// Опции вызова с токеном и tenant-id.
// tenantId: Id тенанта.
private static CallOptions Options(string tenantId = TenantId)
=> new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, tenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds));
}
@@ -0,0 +1,101 @@
using Deal.Grpc.Telegram;
using Grpc.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Sockets;
namespace Deal.Telegram.Tests;
// Фейковая реализация IngressService (сервер ядра) для in-proc проверки клиента ингресса
// (план Task 10: «PushMessage-клиент к in-proc fake-серверу ингресса»). Записывает вызовы и metadata
// (tenant-id/service-token), отвечает по протоколу без логики ядра.
internal sealed class RecordingIngressService : IngressService.IngressServiceBase
{
/// <summary>
/// Полученные PushMessage, в порядке вызовов.
/// </summary>
public List<PushMessageRequest> Pushes { get; } = [];
/// <summary>
/// Полученные SyncDialogs, в порядке вызовов.
/// </summary>
public List<SyncDialogsRequest> Syncs { get; } = [];
/// <summary>
/// Значения tenant-id полученных вызовов (в порядке вызовов).
/// </summary>
public List<string> Tenants { get; } = [];
/// <summary>
/// Значения service-token полученных вызовов (в порядке вызовов).
/// </summary>
public List<string> Tokens { get; } = [];
/// <summary>
/// Monitored-набор ответа SyncDialogs (по умолчанию пуст).
/// </summary>
public List<string> MonitoredIds { get; set; } = [];
/// <inheritdoc />
public override Task<PushMessageReply> PushMessage(PushMessageRequest request, ServerCallContext context)
{
Record(context);
Pushes.Add(request);
return Task.FromResult(new PushMessageReply { Accepted = true, Duplicate = false });
}
/// <inheritdoc />
public override Task<SyncDialogsReply> SyncDialogs(SyncDialogsRequest request, ServerCallContext context)
{
Record(context);
Syncs.Add(request);
var reply = new SyncDialogsReply();
reply.MonitoredIds.AddRange(MonitoredIds);
return Task.FromResult(reply);
}
// Записывает metadata вызова (tenant-id/service-token, Ruling 1).
// context: Контекст вызова gRPC.
private void Record(ServerCallContext context)
{
Tenants.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty);
Tokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty);
}
}
// Поднимает in-proc gRPC-сервер IngressService на эфемерном порту (без service-token-интерцептора:
// тест проверяет, что клиент шлёт токен, а не что сервер его принимает).
internal static class FakeIngressServer
{
/// <summary>
/// Создаёт сервер (остановку — через возвращённый App) и возвращает endpoint + реализацию.
/// </summary>
public static async Task<(string Endpoint, RecordingIngressService Server, WebApplication App)> StartAsync()
{
var server = new RecordingIngressService();
int port = FreeTcpPort();
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(kestrel =>
kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2));
builder.Services.AddSingleton(server);
builder.Services.AddGrpc();
WebApplication app = builder.Build();
app.MapGrpcService<RecordingIngressService>();
await app.StartAsync();
return ($"http://127.0.0.1:{port}", server, app);
}
// Свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом).
private static int FreeTcpPort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
}
@@ -0,0 +1,459 @@
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Tests;
// Фейковый клиент Telegram для unit/in-proc тестов (план Task 9: фазовые переходы на fake-клиенте
// абстракции ISessionClient; без сети). Поведение задаётся скриптом: ошибки/результаты кода/пароля,
// QR-вход ждёт явного «сканирования» (CompleteQrScanAsync) — тест управляет моментом авторизации.
internal sealed class FakeSessionClient : ISessionClient
{
/// <summary>
/// URL, который фейк выдаёт первым колбэком QR-входа.
/// </summary>
public const string DefaultQrUrl = "tg://login?token=fake_token";
private readonly TaskCompletionSource<bool> _qrScanTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private bool _authorized;
/// <summary>
/// Создаёт фейк под ключи приложения (как реальный клиент).
/// </summary>
/// <param name="apiId">api_id приложения.</param>
/// <param name="apiHash">api_hash приложения.</param>
/// <param name="storedSession">Байты «сохранённой сессии» (для проверки передачи в фабрику).</param>
public FakeSessionClient(int apiId, string apiHash, byte[]? storedSession)
{
ApiId = apiId;
ApiHash = apiHash;
SessionBytes = storedSession ?? SessionMarker;
_authorized = storedSession is not null;
}
/// <summary>
/// Сколько раз вызван ConnectAsync (проверка попыток переподключения в тестах).
/// </summary>
public int ConnectAttempts { get; private set; }
/// <summary>
/// Непустой — ConnectAsync ждёт завершения этой задачи (эмуляция «зависшего» connect;
/// отмена токена прерывает ожидание). null — мгновенный успех.
/// </summary>
public TaskCompletionSource<bool>? ConnectGate { get; set; }
/// <summary>
/// True — первый URL QR не выдаётся сразу (эмуляция ожидания QR до сканирования/отмены).
/// </summary>
public bool DelayFirstQrUrl { get; set; }
/// <summary>
/// True — фоновый QR-вход (StartQrAsync) остановлен отменой до сканирования.
/// </summary>
public bool QrLoginCancelled { get; private set; }
/// <summary>
/// Маркерные байты сессии (детект сохранения файла в тестах).
/// </summary>
public static byte[] SessionMarker { get; } = [1, 3, 3, 7, 42];
/// <summary>
/// Исключение, которое бросит RequestCodeAsync (null — успех).
/// </summary>
public Exception? RequestCodeError { get; set; }
/// <summary>
/// Исключение SubmitCodeAsync (null — успех).
/// </summary>
public Exception? CodeError { get; set; }
/// <summary>
/// Исключение SubmitPasswordAsync (null — успех).
/// </summary>
public Exception? PasswordError { get; set; }
/// <summary>
/// Результат SubmitCodeAsync: "password" — нужен 2FA; null — авторизация завершена.
/// </summary>
public string? CodeResult { get; set; }
/// <summary>
/// Аккаунт, возвращаемый GetAccountAsync.
/// </summary>
public string Account { get; set; } = "@fake_user";
/// <summary>
/// Был ли вызван LogOutAsync.
/// </summary>
public bool LoggedOut { get; private set; }
/// <summary>
/// Запрошенные номера (RequestCodeAsync).
/// </summary>
public List<string> RequestedPhones { get; } = [];
/// <summary>
/// Отправленные коды (SubmitCodeAsync).
/// </summary>
public List<string> SubmittedCodes { get; } = [];
/// <summary>
/// Отправленные пароли (SubmitPasswordAsync).
/// </summary>
public List<string> SubmittedPasswords { get; } = [];
/// <summary>
/// Был ли вызван StartQrAsync.
/// </summary>
public bool QrStarted { get; private set; }
/// <summary>
/// Завершает «сканирование» QR — авторизует фейк (как подтверждение на телефоне).
/// </summary>
public void CompleteQrScan()
=> _qrScanTcs.TrySetResult(true);
/// <inheritdoc />
public bool IsAuthorized => _authorized;
/// <inheritdoc />
public bool IsConnected { get; private set; }
/// <inheritdoc />
public int ApiId { get; }
/// <inheritdoc />
public string ApiHash { get; }
/// <inheritdoc />
public byte[] SessionBytes { get; private set; }
/// <inheritdoc />
public async Task ConnectAsync(CancellationToken cancellationToken)
{
ConnectAttempts++;
if (ConnectGate is not null)
{
// «Зависший» connect: ждём gate (или отмену токена — реальный клиент прерывается по ct).
await ConnectGate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
IsConnected = true;
}
/// <inheritdoc />
public Task RequestCodeAsync(string phone, CancellationToken cancellationToken)
{
RequestedPhones.Add(phone);
if (RequestCodeError is not null)
{
throw RequestCodeError;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<string?> SubmitCodeAsync(string code, CancellationToken cancellationToken)
{
SubmittedCodes.Add(code);
if (CodeError is not null)
{
throw CodeError;
}
if (CodeResult == "password")
{
return Task.FromResult<string?>(CodeResult);
}
_authorized = true;
SessionBytes = SessionMarker;
return Task.FromResult<string?>(null);
}
/// <inheritdoc />
public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken)
{
SubmittedPasswords.Add(password);
if (PasswordError is not null)
{
throw PasswordError;
}
_authorized = true;
SessionBytes = SessionMarker;
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken)
{
QrStarted = true;
// Реальный WTelegramClient при QR-входе сам устанавливает соединение (LoginWithQRCode → ConnectAsync).
IsConnected = true;
if (!DelayFirstQrUrl)
{
onQrUrl(DefaultQrUrl);
}
try
{
await _qrScanTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Отмена до сканирования: фоновый вход остановлен (не «скрытая» авторизация).
QrLoginCancelled = true;
throw;
}
_authorized = true;
SessionBytes = SessionMarker;
}
/// <inheritdoc />
public Task LogOutAsync(CancellationToken cancellationToken)
{
LoggedOut = true;
_authorized = false;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<string> GetAccountAsync(CancellationToken cancellationToken)
=> Task.FromResult(Account);
/// <inheritdoc />
public event Func<TelegramMessage, Task>? MessageReceived;
/// <summary>
/// Список диалогов, который фейк возвращает из GetDialogsAsync (по умолчанию пуст).
/// </summary>
public List<TelegramDialog> Dialogs { get; } = [];
/// <summary>
/// Сообщения диалогов (ключ — подписанный id), которые возвращает GetMessagesAsync.
/// </summary>
public Dictionary<string, List<TelegramMessage>> Messages { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Сколько раз вызван GetDialogsAsync.
/// </summary>
public int DialogListCallCount { get; private set; }
/// <summary>
/// Диалоги, которые GetMessagesAsync/MarkReadAsync получали (в порядке вызовов).
/// </summary>
public List<string> ReadDialogs { get; } = [];
/// <summary>
/// Диалоги, помеченные прочитанными (MarkReadAsync), в порядке вызовов.
/// </summary>
public List<string> MarkedReadDialogs { get; } = [];
/// <summary>
/// Исключение GetDialogsAsync (null — успех).
/// </summary>
public Exception? DialogsError { get; set; }
/// <summary>
/// Исключение GetMessagesAsync (null — успех).
/// </summary>
public Exception? MessagesError { get; set; }
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialog>> GetDialogsAsync(int limit, CancellationToken cancellationToken)
{
DialogListCallCount++;
if (DialogsError is not null)
{
throw DialogsError;
}
return Task.FromResult<IReadOnlyList<TelegramDialog>>(Dialogs.ToArray());
}
/// <inheritdoc />
public Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(string dialogId, int limit, CancellationToken cancellationToken)
{
ReadDialogs.Add(dialogId);
if (MessagesError is not null)
{
throw MessagesError;
}
return Task.FromResult<IReadOnlyList<TelegramMessage>>(
Messages.TryGetValue(dialogId, out List<TelegramMessage>? messages)
? messages.Take(limit).ToArray()
: []);
}
/// <inheritdoc />
public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken)
{
MarkedReadDialogs.Add(dialogId);
return Task.CompletedTask;
}
// --- Discovery (план Task 11): данные/ошибки операций задаёт тест, сети нет ---
/// <summary>
/// Результат поиска, который фейк возвращает из SearchAsync (chats затем users).
/// </summary>
public List<TelegramDialog> SearchResults { get; } = [];
/// <summary>
/// Поисковые запросы (query/limit), в порядке вызовов.
/// </summary>
public List<(string Query, int Limit)> SearchQueries { get; } = [];
/// <summary>
/// Исключение SearchAsync (null — успех).
/// </summary>
public Exception? SearchError { get; set; }
/// <summary>
/// Инфо источников (ключ — подписанный id), возвращаемое GetInfoAsync.
/// </summary>
public Dictionary<string, TelegramSourceInfo> SourceInfos { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Id источников, по которым запрошено инфо, в порядке вызовов.
/// </summary>
public List<string> InfoRequests { get; } = [];
/// <summary>
/// Результаты чтения выборки (ключ — подписанный id), возвращаемые ReadForEvalAsync.
/// </summary>
public Dictionary<string, DiscoveryReadResult> ReadForEvalResults { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Id источников, по которым запрошено чтение, в порядке вызовов.
/// </summary>
public List<string> ReadRequests { get; } = [];
/// <summary>
/// Username вступлений (после нормализации), в порядке вызовов.
/// </summary>
public List<string> JoinedUsernames { get; } = [];
/// <summary>
/// Исключение JoinAsync (null — успех; flood эмулируется SessionException RESOURCE_EXHAUSTED).
/// </summary>
public Exception? JoinError { get; set; }
/// <summary>
/// Id диалогов выхода, в порядке вызовов.
/// </summary>
public List<string> LeaveCalls { get; } = [];
/// <summary>
/// Исключение LeaveAsync (null — успех).
/// </summary>
public Exception? LeaveError { get; set; }
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialog>> SearchAsync(string query, int limit, CancellationToken cancellationToken)
{
SearchQueries.Add((query, limit));
if (SearchError is not null)
{
throw SearchError;
}
return Task.FromResult<IReadOnlyList<TelegramDialog>>(SearchResults.ToArray());
}
/// <inheritdoc />
public Task<TelegramSourceInfo> GetInfoAsync(string dialogId, CancellationToken cancellationToken)
{
InfoRequests.Add(dialogId);
return Task.FromResult(
SourceInfos.TryGetValue(dialogId, out TelegramSourceInfo? info)
? info
: new TelegramSourceInfo(dialogId, dialogId, string.Empty, string.Empty, participants: null, isForum: false));
}
/// <inheritdoc />
public Task<DiscoveryReadResult> ReadForEvalAsync(string dialogId, int limit, CancellationToken cancellationToken)
{
ReadRequests.Add(dialogId);
return Task.FromResult(
ReadForEvalResults.TryGetValue(dialogId, out DiscoveryReadResult? result)
? result
: DiscoveryReadResult.Empty);
}
/// <inheritdoc />
public Task JoinAsync(string username, CancellationToken cancellationToken)
{
JoinedUsernames.Add(username);
if (JoinError is not null)
{
throw JoinError;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task LeaveAsync(string dialogId, CancellationToken cancellationToken)
{
LeaveCalls.Add(dialogId);
if (LeaveError is not null)
{
throw LeaveError;
}
return Task.CompletedTask;
}
/// <summary>
/// Поднимает событие нового сообщения (эмуляция realtime-события Telegram).
/// </summary>
/// <param name="message">Входящее сообщение диалога.</param>
public async Task RaiseMessageAsync(TelegramMessage message)
{
Func<TelegramMessage, Task>? handler = MessageReceived;
if (handler is null)
{
return;
}
foreach (Delegate subscriber in handler.GetInvocationList())
{
await ((Func<TelegramMessage, Task>)subscriber)(message).ConfigureAwait(false);
}
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
IsConnected = false;
return ValueTask.CompletedTask;
}
}
// Фейковая фабрика клиентов: создаёт FakeSessionClient и запоминает экземпляры
// (тест управляет «сканированием» QR и проверяет передачи ключей/байт сессии).
internal sealed class FakeClientFactory : ITelegramClientFactory
{
/// <summary>
/// Все созданные фабрикой клиенты (в порядке создания).
/// </summary>
public List<FakeSessionClient> CreatedClients { get; } = [];
/// <summary>
/// Колбэк настройки клиента перед возвратом (null — клиент по умолчанию).
/// </summary>
public Action<FakeSessionClient>? OnClientCreated { get; set; }
/// <inheritdoc />
public ISessionClient Create(int apiId, string apiHash, byte[]? storedSession)
{
var client = new FakeSessionClient(apiId, apiHash, storedSession);
OnClientCreated?.Invoke(client);
CreatedClients.Add(client);
return client;
}
}
@@ -0,0 +1,75 @@
using Deal.Telegram.Sessions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Telegram.Tests;
// Харнесс пула сессий (SessionFarm) с готовой ready-сессией тенанта для unit-тестов служб каталога
// (BackfillService/RealtimeSweep/RealtimeListener ходят в сессию через SessionFarm, как в проде).
// Вход — телефонный флоу на фейк-клиенте: StartPhone (код запрошен) → SendCode (код верный) → ready.
internal sealed class FarmHarness : IAsyncDisposable
{
private const string Phone = "+79990001122";
private FarmHarness(string directory, SessionStore store, FakeClientFactory factory, SessionFarm farm)
{
Directory = directory;
Store = store;
Factory = factory;
Farm = farm;
}
/// <summary>
/// Temp-каталог хранилища сессий.
/// </summary>
public string Directory { get; }
/// <summary>
/// Файловое хранилище сессий.
/// </summary>
public SessionStore Store { get; }
/// <summary>
/// Фейковая фабрика клиентов (CreatedClients — ready-клиент тенанта).
/// </summary>
public FakeClientFactory Factory { get; }
/// <summary>
/// Пул сессий с ready-сессией тенанта.
/// </summary>
public SessionFarm Farm { get; }
/// <summary>
/// Ready-фейк-клиент тенанта (настройка диалогов/сообщений тестом).
/// </summary>
public FakeSessionClient Client => Factory.CreatedClients.Single();
/// <summary>
/// Создаёт пул с ready-сессией тенанта (телефонный вход без сети).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public static async Task<FarmHarness> CreateWithReadySessionAsync(string tenantId)
{
string directory = TestSessionFactory.NewSessionsDirectory();
var factory = new FakeClientFactory();
SessionStore store = TestSessionFactory.Store(directory);
var farm = new SessionFarm(
factory,
store,
NullLoggerFactory.Instance,
NullLogger<SessionFarm>.Instance);
await farm.StartPhoneAsync(tenantId, SessionHarness.ApiId, SessionHarness.ApiHash, Phone, CancellationToken.None).ConfigureAwait(false);
await farm.SendCodeAsync(tenantId, "11111", CancellationToken.None).ConfigureAwait(false);
return new FarmHarness(directory, store, factory, farm);
}
/// <summary>
/// Останавливает пул (сохранение/освобождение сессий) и удаляет temp-каталог.
/// </summary>
public async ValueTask DisposeAsync()
{
await Farm.ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
TestSessionFactory.Cleanup(Directory);
}
}
@@ -0,0 +1,115 @@
using Deal.Telegram.Caching;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Unit-тесты LRU-кэша (этап 12, пакет C): вытеснение least-recently-used при переполнении,
/// обновление недавности при чтении/записи и проверка наличия без вытеснения.
/// </summary>
public sealed class LruCacheTests
{
// Ёмкость сценария (маленькая — вытеснение видно без тысяч элементов).
private const int Capacity = 3;
/// <summary>
/// Переполнение вытесняет самый давний по обращению элемент, а не первый вставленный.
/// </summary>
[Fact]
public void Set_OverCapacity_EvictsLeastRecentlyUsed()
{
var cache = new LruCache<string, int>(Capacity);
cache.Set("a", 1);
cache.Set("b", 2);
cache.Set("c", 3);
// Обращение к "a" делает его недавним: кандидат на вытеснение — "b".
Assert.True(cache.TryGetValue("a", out int _));
cache.Set("d", 4);
Assert.True(cache.TryGetValue("a", out int a));
Assert.True(cache.TryGetValue("c", out int c));
Assert.True(cache.TryGetValue("d", out int d));
Assert.False(cache.TryGetValue("b", out int _));
Assert.Equal(1, a);
Assert.Equal(3, c);
Assert.Equal(4, d);
Assert.Equal(Capacity, cache.Count);
}
/// <summary>
/// Обновление существующего ключа меняет значение и делает элемент самым недавним.
/// </summary>
[Fact]
public void Set_ExistingKey_UpdatesValueAndRecency()
{
var cache = new LruCache<string, int>(Capacity);
cache.Set("a", 1);
cache.Set("b", 2);
cache.Set("c", 3);
cache.Set("a", 10);
cache.Set("d", 4);
Assert.True(cache.TryGetValue("a", out int a));
Assert.Equal(10, a);
Assert.False(cache.TryGetValue("b", out int _));
}
/// <summary>
/// Промах возвращает false и значение по умолчанию (не бросает).
/// </summary>
[Fact]
public void TryGetValue_MissingKey_ReturnsFalseAndDefault()
{
var cache = new LruCache<string, int>(Capacity);
Assert.False(cache.TryGetValue("absent", out int value));
Assert.Equal(0, value);
Assert.Equal(0, cache.Count);
}
/// <summary>
/// ContainsKey не вытесняет: проверка «уже есть» не спасает старый элемент от вытеснения.
/// </summary>
[Fact]
public void ContainsKey_DoesNotRefreshRecency()
{
var cache = new LruCache<string, int>(Capacity);
cache.Set("a", 1);
cache.Set("b", 2);
cache.Set("c", 3);
Assert.True(cache.ContainsKey("a"));
cache.Set("d", 4);
Assert.False(cache.ContainsKey("a"));
Assert.True(cache.ContainsKey("d"));
}
/// <summary>
/// Нулевая/отрицательная ёмкость — ошибка конфигурации кэша (fail-fast, без магических значений).
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Constructor_NonPositiveCapacity_Throws(int capacity)
{
Assert.Throws<ArgumentOutOfRangeException>(() => new LruCache<string, int>(capacity));
}
/// <summary>
/// Ёмкость 1: каждая вставка вытесняет предыдущий элемент.
/// </summary>
[Fact]
public void Set_CapacityOne_KeepsOnlyLatest()
{
var cache = new LruCache<string, int>(1);
cache.Set("a", 1);
cache.Set("b", 2);
Assert.False(cache.TryGetValue("a", out int _));
Assert.True(cache.TryGetValue("b", out int b));
Assert.Equal(2, b);
}
}
@@ -0,0 +1,138 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Unit-тесты realtime-listener'а (план Task 10 Acceptance: фильтр мониторинга; «новые сообщения →
/// mark-as-read → PushMessage в core»). Listener подписывается на события ready-сессии (проброс
/// клиента TenantSession), фильтрует по зеркалу DialogCatalog и отправляет в ядро; read-ack —
/// только после успешного push.
/// </summary>
public sealed class RealtimeListenerTests
{
private const string TenantId = "tenant-listener";
private const string DialogId = "-1001234567890";
private const string OtherDialogId = "+79990001122";
/// <summary>
/// Мониторящееся сообщение: PushMessage в ядро + mark-as-read (ТЗ: «сразу прочитанным»).
/// </summary>
[Fact]
public async Task Listener_MonitoredMessage_PushesAndMarksRead()
{
await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId);
try
{
var catalog = new DialogCatalog();
catalog.SetMonitored(TenantId, DialogId, enabled: true);
var ingress = new FakeIngress();
RealtimeListener listener = CreateListener(harness.Session, catalog, ingress);
listener.Start();
await harness.Client.RaiseMessageAsync(
new TelegramMessage(DialogId, 101, "свежее сообщение", 1_700_000_000_101, "IT Канал", "it_channel"));
PushMessageRequest push = Assert.Single(ingress.Pushes).Message;
Assert.Equal(DialogId, push.DialogId);
Assert.Equal("свежее сообщение", push.Text);
Assert.Equal(DialogId, Assert.Single(harness.Client.MarkedReadDialogs));
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Немониторящийся диалог фильтруется: без push и без read-ack.
/// </summary>
[Fact]
public async Task Listener_UnmonitoredMessage_IsFilteredOut()
{
await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId);
try
{
var catalog = new DialogCatalog(); // зеркало пустое — мониторинга нет
var ingress = new FakeIngress();
RealtimeListener listener = CreateListener(harness.Session, catalog, ingress);
listener.Start();
await harness.Client.RaiseMessageAsync(
new TelegramMessage(OtherDialogId, 7, "личное сообщение", 1_700_000_000_007, "Иван", ""));
Assert.Empty(ingress.Pushes);
Assert.Empty(harness.Client.MarkedReadDialogs);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Сбой PushMessage не роняет realtime: сообщение не помечается прочитанным (догонит sweep).
/// </summary>
[Fact]
public async Task Listener_PushFails_NoMarkRead()
{
await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId);
try
{
var catalog = new DialogCatalog();
catalog.SetMonitored(TenantId, DialogId, enabled: true);
var ingress = new FakeIngress { PushError = new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable) };
RealtimeListener listener = CreateListener(harness.Session, catalog, ingress);
listener.Start();
await harness.Client.RaiseMessageAsync(
new TelegramMessage(DialogId, 1, "не доедет", 1_700_000_000_001, "IT Канал", ""));
Assert.Empty(harness.Client.MarkedReadDialogs);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Stop отписывает listener: события больше не обрабатываются.
/// </summary>
[Fact]
public async Task Listener_Stop_Unsubscribes()
{
await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId);
try
{
var catalog = new DialogCatalog();
catalog.SetMonitored(TenantId, DialogId, enabled: true);
var ingress = new FakeIngress();
RealtimeListener listener = CreateListener(harness.Session, catalog, ingress);
listener.Start();
listener.Stop();
await harness.Client.RaiseMessageAsync(
new TelegramMessage(DialogId, 2, "после отписки", 1_700_000_000_002, "IT Канал", ""));
Assert.Empty(ingress.Pushes);
Assert.Empty(harness.Client.MarkedReadDialogs);
}
finally
{
await harness.DisposeAsync();
}
}
// Listener сессии.
// session: Ready-сессия тенанта.
// catalog: Зеркало мониторинга.
// ingress: Фейк ингресса.
private static RealtimeListener CreateListener(TenantSession session, DialogCatalog catalog, FakeIngress ingress)
=> new(session, catalog, ingress, NullLogger<RealtimeListener>.Instance);
}
@@ -0,0 +1,148 @@
using Deal.Telegram.Dialogs;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Unit-тесты догона realtime (план Task 10 Acceptance: фильтр мониторинга; RealtimeSweep L392–456).
/// Цикл тенанта: список диалогов → SyncDialogs (ядро отвечает monitored) → по мониторящимся диалогам
/// с unread_count &gt; 0 PushMessage от старых к новым → read-ack. Сбой синка — зеркало прежнее.
/// </summary>
public sealed class RealtimeSweepTests
{
private const string TenantId = "tenant-sweep";
private const string DialogId = "-1001234567890";
private const string OtherDialogId = "-10042";
private const string ChannelName = "IT Канал";
/// <summary>
/// Догон: синк каталога + только мониторящиеся непрочитанные; порядок от старых к новым.
/// </summary>
[Fact]
public async Task Sweep_PushesUnreadOfMonitoredDialogs_OldestFirst_AndMarksRead()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
harness.Client.Dialogs.Add(new TelegramDialog(DialogId, ChannelName, "it_channel", DialogKinds.Channel, unreadCount: 3, topMessageId: 30));
harness.Client.Dialogs.Add(new TelegramDialog(OtherDialogId, "Другой", "other", DialogKinds.Channel, unreadCount: 2, topMessageId: 10));
harness.Client.Messages[DialogId] =
[
new TelegramMessage(DialogId, 30, "новое", 1_700_000_000_030, ChannelName, "it_channel"),
new TelegramMessage(DialogId, 29, "среднее", 1_700_000_000_029, ChannelName, "it_channel"),
new TelegramMessage(DialogId, 28, "старое", 1_700_000_000_028, ChannelName, "it_channel"),
];
var catalog = new DialogCatalog();
var ingress = new FakeIngress { MonitoredIdsToReturn = [DialogId] };
RealtimeSweep sweep = CreateSweep(harness.Farm, catalog, ingress);
await sweep.SweepAllAsync(CancellationToken.None);
// Синк каталога выполнен (entries ушли в ядро), зеркало — ответом SyncDialogs.
Assert.Single(ingress.Syncs);
Assert.Equal([DialogId, OtherDialogId], ingress.Syncs[0].Entries.Select(entry => entry.Id));
Assert.True(catalog.IsMonitored(TenantId, DialogId));
Assert.False(catalog.IsMonitored(TenantId, OtherDialogId));
// Дочитаны только сообщения мониторящегося диалога, от старых к новым.
Assert.Equal([28, 29, 30], ingress.Pushes.Select(push => push.Message.MsgId));
Assert.Equal(DialogId, ingress.Pushes[0].Message.DialogId);
Assert.Equal([DialogId], harness.Client.MarkedReadDialogs);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Диалог с unread=0 или не мониторящийся не трогается (нет чтения и push).
/// </summary>
[Fact]
public async Task Sweep_SkipsReadAndUnmonitoredDialogs()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
harness.Client.Dialogs.Add(new TelegramDialog(DialogId, ChannelName, "", DialogKinds.Channel, unreadCount: 0, topMessageId: 5));
harness.Client.Dialogs.Add(new TelegramDialog(OtherDialogId, "Другой", "", DialogKinds.Channel, unreadCount: 4, topMessageId: 9));
var catalog = new DialogCatalog();
catalog.ReplaceKnown(TenantId, [DialogId, OtherDialogId]);
catalog.ReplaceMonitored(TenantId, [DialogId]);
var ingress = new FakeIngress { MonitoredIdsToReturn = [] };
RealtimeSweep sweep = CreateSweep(harness.Farm, catalog, ingress);
await sweep.SweepAllAsync(CancellationToken.None);
Assert.Empty(ingress.Pushes);
Assert.Empty(harness.Client.MarkedReadDialogs);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Сбой SyncDialogs не роняет цикл: зеркало прежнее, догон в следующий цикл.
/// </summary>
[Fact]
public async Task Sweep_SyncFails_KeepsOldMirror_AndSkipsPushes()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
harness.Client.Dialogs.Add(new TelegramDialog(DialogId, ChannelName, "", DialogKinds.Channel, unreadCount: 1, topMessageId: 1));
var catalog = new DialogCatalog();
var ingress = new FakeIngress
{
SyncError = new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable),
};
RealtimeSweep sweep = CreateSweep(harness.Farm, catalog, ingress);
await sweep.SweepAllAsync(CancellationToken.None);
// Синк упал — зеркало мониторинга пустое, мониторящихся диалогов нет → без push и read.
Assert.Empty(ingress.Pushes);
Assert.False(catalog.IsMonitored(TenantId, DialogId));
Assert.Empty(harness.Client.MarkedReadDialogs);
}
finally
{
await harness.DisposeAsync();
}
}
/// <summary>
/// Пустой список диалогов (не ready/нет каталога) — no-op без сетевых вызовов ядра.
/// </summary>
[Fact]
public async Task Sweep_NoDialogs_NoSyncCalls()
{
await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId);
try
{
var ingress = new FakeIngress();
RealtimeSweep sweep = CreateSweep(harness.Farm, new DialogCatalog(), ingress);
await sweep.SweepAllAsync(CancellationToken.None);
Assert.Empty(ingress.Syncs);
}
finally
{
await harness.DisposeAsync();
}
}
// Догонялка на пуле, каталоге и фейк-ингресса.
// farm: Пул сессий.
// catalog: Зеркало каталога.
// ingress: Фейк ингресса.
private static RealtimeSweep CreateSweep(SessionFarm farm, DialogCatalog catalog, FakeIngress ingress)
=> new(farm, catalog, ingress, NullLogger<RealtimeSweep>.Instance);
}
@@ -0,0 +1,93 @@
using Deal.Telegram.Sessions;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Telegram.Tests;
// Харнесс ready-сессии для unit-тестов каталога (план Task 10): реальное хранилище в temp-каталоге,
// фейковая фабрика клиентов и сессия, возобновлённая из «сохранённой авторизованной сессии»
// (TryResumeAsync → фаза ready, без сети и QR-флоу). Фейк-клиент после создания настраивается тестом.
internal sealed class SessionHarness : IAsyncDisposable
{
/// <summary>
/// api_id приложения тестовых сессий.
/// </summary>
public const int ApiId = 12345;
/// <summary>
/// api_hash приложения тестовых сессий.
/// </summary>
public const string ApiHash = "0123456789abcdef0123456789abcdef";
private SessionHarness(string directory, SessionStore store, FakeClientFactory factory, TenantSession session)
{
Directory = directory;
Store = store;
Factory = factory;
Session = session;
}
/// <summary>
/// Temp-каталог хранилища сессий.
/// </summary>
public string Directory { get; }
/// <summary>
/// Файловое хранилище сессий.
/// </summary>
public SessionStore Store { get; }
/// <summary>
/// Фейковая фабрика клиентов (CreatedClients — созданный ready-клиент).
/// </summary>
public FakeClientFactory Factory { get; }
/// <summary>
/// Ready-сессия тенанта.
/// </summary>
public TenantSession Session { get; }
/// <summary>
/// Единственный фейк-клиент сессии (настройка диалогов/сообщений тестом).
/// </summary>
public FakeSessionClient Client => Factory.CreatedClients.Single();
/// <summary>
/// Создаёт ready-сессию (auto_resume сохранённой авторизованной сессии).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public static async Task<SessionHarness> CreateReadyAsync(string tenantId)
{
string directory = TestSessionFactory.NewSessionsDirectory();
var factory = new FakeClientFactory();
SessionStore store = TestSessionFactory.Store(directory);
var session = new TenantSession(tenantId, factory, store, NullLogger<TenantSession>.Instance);
var stored = new StoredSession
{
ApiId = ApiId,
ApiHash = ApiHash,
SessionBytes = FakeSessionClient.SessionMarker,
};
await store.SaveAsync(tenantId, stored, CancellationToken.None).ConfigureAwait(false);
bool resumed = await session.TryResumeAsync(stored, CancellationToken.None).ConfigureAwait(false);
if (!resumed)
{
TestSessionFactory.Cleanup(directory);
throw new InvalidOperationException("Фейковая сессия не возобновилась (нет ready).");
}
return new SessionHarness(directory, store, factory, session);
}
/// <summary>
/// Удаляет temp-каталог сессий.
/// </summary>
public void Cleanup()
=> TestSessionFactory.Cleanup(Directory);
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await Session.DisposeAsync().ConfigureAwait(false);
Cleanup();
}
}
@@ -0,0 +1,355 @@
using System.Security.Cryptography;
using Deal.Telegram.Sessions;
using Grpc.Core;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Тесты файлового хранилища сессий и AES-GCM-обёртки (план Task 9 Acceptance: roundtrip/шифрование/
/// атомарность/изоляция тенантов). Без сети: только шифр и файлы в temp-каталогах.
/// </summary>
public sealed class SessionStorageTests
{
private const int ApiId = 123456;
private const string ApiHash = "0123456789abcdef0123456789abcdef";
private static readonly byte[] SessionBytes = [9, 8, 7, 6, 5, 4, 3];
/// <summary>
/// Шифр: roundtrip произвольных байт сессии.
/// </summary>
[Fact]
public void Cipher_EncryptDecrypt_Roundtrip()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionFileCipher cipher = TestSessionFactory.Cipher(dir);
byte[] payload = new byte[2048];
RandomNumberGenerator.Fill(payload);
string encrypted = cipher.EncryptBytes(payload);
byte[]? decrypted = cipher.DecryptBytes(encrypted);
Assert.NotNull(decrypted);
Assert.Equal(payload, decrypted);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Шифр: значение имеет префикс enc: и не содержит открытых байт сессии.
/// </summary>
[Fact]
public void Cipher_Encrypt_ProducesEncPrefix_AndHidesPlaintext()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionFileCipher cipher = TestSessionFactory.Cipher(dir);
string encrypted = cipher.EncryptBytes(SessionBytes);
Assert.StartsWith(SessionFileCipher.EncryptedPrefix, encrypted, StringComparison.Ordinal);
Assert.DoesNotContain(System.Text.Encoding.UTF8.GetString(SessionBytes), encrypted, StringComparison.Ordinal);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Шифр: значение не в формате enc: — Decrypt возвращает null (не бросает).
/// </summary>
[Theory]
[InlineData("")]
[InlineData("мусор")]
[InlineData("plain:AAAA")]
public void Cipher_Decrypt_NotOurFormat_ReturnsNull(string value)
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionFileCipher cipher = TestSessionFactory.Cipher(dir);
Assert.Null(cipher.DecryptBytes(value));
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Шифр: чужой ключ не расшифровывает (тег не сходится → CryptographicException).
/// </summary>
[Fact]
public void Cipher_Decrypt_WrongKey_ThrowsCryptographic()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionFileCipher encoder = TestSessionFactory.Cipher(dir, TestSessionFactory.TestKey);
SessionFileCipher decoder = TestSessionFactory.Cipher(dir, TestSessionFactory.TestKey.Select(b => (byte)(b ^ 0xFF)).ToArray());
string encrypted = encoder.EncryptBytes(SessionBytes);
Assert.ThrowsAny<CryptographicException>(() => decoder.DecryptBytes(encrypted));
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: Save → Load возвращает тот же StoredSession (круглый roundtrip).
/// </summary>
[Fact]
public async Task Store_SaveLoad_Roundtrip()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
var stored = NewStoredSession();
await store.SaveAsync("tenant-a", stored);
StoredSession? loaded = await store.LoadAsync("tenant-a");
Assert.NotNull(loaded);
Assert.Equal(stored.ApiId, loaded.ApiId);
Assert.Equal(stored.ApiHash, loaded.ApiHash);
Assert.Equal(stored.SessionBytes, loaded.SessionBytes);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: файл создаётся как enc:-обёртка (at-rest шифрование; открытого JSON в файле нет).
/// </summary>
[Fact]
public async Task Store_Save_WritesEncryptedFileWithoutPlaintext()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
string filePath = Path.Combine(dir, "tenant-a.session");
await store.SaveAsync("tenant-a", NewStoredSession());
Assert.True(File.Exists(filePath));
string content = await File.ReadAllTextAsync(filePath);
Assert.StartsWith(SessionFileCipher.EncryptedPrefix, content, StringComparison.Ordinal);
Assert.DoesNotContain(ApiHash, content, StringComparison.Ordinal);
Assert.DoesNotContain(Convert.ToBase64String(SessionBytes), content, StringComparison.Ordinal);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: повторное сохранение атомарно перезаписывает (последнее значение выигрывает).
/// </summary>
[Fact]
public async Task Store_SaveTwice_LastWriteWins()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
var first = NewStoredSession(apiId: 111, marker: [1, 1, 1]);
var second = NewStoredSession(apiId: 222, marker: [2, 2, 2]);
await store.SaveAsync("tenant-a", first);
await store.SaveAsync("tenant-a", second);
StoredSession? loaded = await store.LoadAsync("tenant-a");
Assert.NotNull(loaded);
Assert.Equal(222, loaded.ApiId);
Assert.Equal([2, 2, 2], loaded.SessionBytes);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: файла нет → Load возвращает null (без ошибки).
/// </summary>
[Fact]
public async Task Store_Load_MissingFile_ReturnsNull()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
Assert.Null(await store.LoadAsync("tenant-a"));
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: чужой ключ → файл нечитаем → Load возвращает null (не падает).
/// </summary>
[Fact]
public async Task Store_Load_WrongKey_ReturnsNull()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore storeA = TestSessionFactory.Store(dir, TestSessionFactory.TestKey);
await storeA.SaveAsync("tenant-a", NewStoredSession());
SessionStore storeB = TestSessionFactory.Store(dir, TestSessionFactory.TestKey.Select(b => (byte)(b ^ 0xFF)).ToArray());
Assert.Null(await storeB.LoadAsync("tenant-a"));
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: битый файл (не формат/мусор) → Load возвращает null, файл остаётся на диске.
/// </summary>
[Fact]
public async Task Store_Load_CorruptFile_ReturnsNull_FileKept()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
Directory.CreateDirectory(dir);
string filePath = Path.Combine(dir, "tenant-a.session");
await File.WriteAllTextAsync(filePath, "enc:не-base64-мусор");
SessionStore store = TestSessionFactory.Store(dir);
Assert.Null(await store.LoadAsync("tenant-a"));
Assert.True(File.Exists(filePath));
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: изоляция тенантов — файлы разных тенантов не пересекаются (1:1, Ruling 3).
/// </summary>
[Fact]
public async Task Store_TenantIsolation_SeparateFiles()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
await store.SaveAsync("tenant-a", NewStoredSession(apiId: 111, marker: [1]));
await store.SaveAsync("tenant-b", NewStoredSession(apiId: 222, marker: [2]));
Assert.Equal(111, (await store.LoadAsync("tenant-a"))?.ApiId);
Assert.Equal(222, (await store.LoadAsync("tenant-b"))?.ApiId);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: Delete удаляет файл; повторный Delete не бросает.
/// </summary>
[Fact]
public async Task Store_Delete_RemovesFile()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
await store.SaveAsync("tenant-a", NewStoredSession());
await store.DeleteAsync("tenant-a");
Assert.False(File.Exists(Path.Combine(dir, "tenant-a.session")));
Assert.Null(await store.LoadAsync("tenant-a"));
await store.DeleteAsync("tenant-a");
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: ListTenantIds перечисляет только *.session текущего каталога (для auto_resume).
/// </summary>
[Fact]
public async Task Store_ListTenantIds_ReturnsSessionFilesOnly()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
Directory.CreateDirectory(dir);
SessionStore store = TestSessionFactory.Store(dir);
await store.SaveAsync("tenant-a", NewStoredSession());
await store.SaveAsync("tenant-b", NewStoredSession());
await File.WriteAllTextAsync(Path.Combine(dir, "notes.txt"), "не сессия");
Directory.CreateDirectory(Path.Combine(dir, "sub"));
await File.WriteAllTextAsync(Path.Combine(dir, "sub", "tenant-c.session"), "не наш файл");
string[] tenants = store.ListTenantIds().OrderBy(id => id).ToArray();
Assert.Equal(["tenant-a", "tenant-b"], tenants);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
/// <summary>
/// Store: некорректный tenant-id (путь) отклоняется SessionException INVALID_ARGUMENT.
/// </summary>
[Fact]
public async Task Store_InvalidTenantId_ThrowsSessionException()
{
string dir = TestSessionFactory.NewSessionsDirectory();
try
{
SessionStore store = TestSessionFactory.Store(dir);
SessionException exception = await Assert.ThrowsAsync<SessionException>(() => store.SaveAsync("../escape", NewStoredSession()));
Assert.Equal(StatusCode.InvalidArgument, exception.Code);
Assert.Equal(SessionErrorMessages.InvalidTenantId, exception.Message);
}
finally
{
TestSessionFactory.Cleanup(dir);
}
}
// Образец StoredSession с настраиваемыми apiId/маркером сессии.
// apiId: api_id.
// marker: Байты сессии.
private static StoredSession NewStoredSession(int? apiId = null, byte[]? marker = null)
=> new()
{
ApiId = apiId ?? ApiId,
ApiHash = ApiHash,
SessionBytes = marker ?? SessionBytes,
};
}
@@ -0,0 +1,162 @@
using Deal.Grpc.Telegram;
using Grpc.Core;
using Grpc.Health.V1;
using Grpc.Net.Client;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Интеграционные тесты каркаса telegram-service (план Task 2, Acceptance + задача сессий).
///
/// Хост поднимается в процессе теста (Kestrel HTTP/2, эфемерный порт) через TelegramServiceHost.Create —
/// ту же сборку хоста, что использует Program.cs. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor
/// (Ruling 1): запрос без токена/с неверным токеном → UNAUTHENTICATED; верный токен доходит до метода
/// (GetStatus без сессии тенанта → FAILED_PRECONDITION «Telegram не подключён», контракт README);
/// при незаданном DEAL_SERVICE_TOKEN — fail-closed. Окружение сессий (ключ/каталог) выставляет
/// TelegramTestHost — тот же хост, что и у тестов сессий.
/// </summary>
public sealed class TelegramServiceHostTests
{
// Токен сценариев теста.
private const string ValidToken = TelegramTestHost.DefaultToken;
/// <summary>
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура
/// и health-сервис работают (Ruling 12; health освобождён от service-token).
/// </summary>
[Fact]
public async Task HealthCheck_ReturnsServing()
{
await RunScenarioAsync(
ValidToken,
async channel =>
{
var health = new Health.HealthClient(channel);
HealthCheckResponse response = await health.CheckAsync(
new HealthCheckRequest(),
deadline: Deadline());
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status);
});
}
/// <summary>
/// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1).
/// </summary>
[Fact]
public async Task GetStatus_WithoutToken_IsUnauthenticated()
{
await AssertDealRpcRejectedAsync(ValidToken, tokenHeader: null, expected: StatusCode.Unauthenticated);
}
/// <summary>
/// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1).
/// </summary>
[Fact]
public async Task GetStatus_WithWrongToken_IsUnauthenticated()
{
await AssertDealRpcRejectedAsync(ValidToken, tokenHeader: "wrong-token", expected: StatusCode.Unauthenticated);
}
/// <summary>
/// Верный токен проходит интерцептор к методу: GetStatus для тенанта без сессии отвечает
/// FAILED_PRECONDITION «Telegram не подключён» (контракт telegram.proto/README — нет сессии → отказ),
/// т.е. маппинг сервиса и проверка принадлежности работают (задача сессий).
/// </summary>
[Fact]
public async Task GetStatus_WithValidToken_NoSession_IsFailedPrecondition()
{
await RunScenarioAsync(
ValidToken,
async channel =>
{
var client = new TelegramService.TelegramServiceClient(channel);
AsyncUnaryCall<GetStatusReply> call = client.GetStatusAsync(
new GetStatusRequest(),
new CallOptions(TelegramTestHost.CallMetadata(ValidToken, TelegramTestHost.DefaultTenantId), deadline: Deadline()));
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail);
});
}
/// <summary>
/// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется даже с «каким-то» токеном;
/// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается).
/// </summary>
[Fact]
public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing()
{
await RunScenarioAsync(
serviceToken: null,
async channel =>
{
var health = new Health.HealthClient(channel);
HealthCheckResponse healthResponse = await health.CheckAsync(
new HealthCheckRequest(),
deadline: Deadline());
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status);
await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated);
});
}
/// <summary>
/// Fail-closed-гард: env DEAL_SERVICE_TOKEN не задан, а клиент прислал ПУСТОЙ metadata «service-token» —
/// без гарда «» == «» прошло бы сравнение и запрос дошёл бы до метода. Гард отклоняет запрос
/// UNAUTHENTICATED; health при этом остаётся SERVING.
/// </summary>
[Fact]
public async Task EmptyServiceToken_WithUnsetEnvToken_IsUnauthenticated_HealthServing()
{
await RunScenarioAsync(
serviceToken: null,
async channel =>
{
var health = new Health.HealthClient(channel);
HealthCheckResponse healthResponse = await health.CheckAsync(
new HealthCheckRequest(),
deadline: Deadline());
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status);
await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated);
});
}
// Прогоняет GetStatus с заданным заголовком «service-token» (null — без заголовка).
// serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN).
// tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка).
// expected: Ожидаемый StatusCode ответа.
private static async Task AssertDealRpcRejectedAsync(string? serviceToken, string? tokenHeader, StatusCode expected)
{
await RunScenarioAsync(serviceToken, channel => AssertRejectedAsync(channel, tokenHeader, expected));
}
// Вызывает GetStatus и проверяет, что сервер ответил ожидаемым кодом статуса.
// channel: Канал к хосту telegram-service.
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
// expected: Ожидаемый StatusCode.
private static async Task AssertRejectedAsync(GrpcChannel channel, string? tokenHeader, StatusCode expected)
{
var client = new TelegramService.TelegramServiceClient(channel);
AsyncUnaryCall<GetStatusReply> call = client.GetStatusAsync(
new GetStatusRequest(),
new CallOptions(TelegramTestHost.CallMetadata(tokenHeader), deadline: Deadline()));
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
Assert.Equal(expected, exception.StatusCode);
}
// Прогоняет сценарий через общий харнесс хоста (env токена/сессий, temp-каталог сессий).
// serviceToken: Значение env DEAL_SERVICE_TOKEN (null — убрать).
// scenario: Сценарий с каналом к поднятому хосту.
private static Task RunScenarioAsync(string? serviceToken, Func<GrpcChannel, Task> scenario)
=> TelegramTestHost.RunAsync(serviceToken, scenario);
// Deadline вызовов теста.
private static DateTime Deadline()
=> DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds);
}
@@ -0,0 +1,228 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Telegram;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// RPC-тесты подключения аккаунта поверх реального gRPC-хоста (план Task 9 Acceptance: ветки RPC —
/// нет ключей → отказ; tenant без сессии; состояние QR) с фейковой фабрикой клиентов: сеть не
/// трогается, QR-«сканирование» эмулируется. Хост — TelegramServiceHost.Create (как в проде).
/// </summary>
public sealed class TelegramSessionRpcTests
{
private const string TenantId = TelegramTestHost.DefaultTenantId;
private const int ApiId = 12345;
private const string ApiHash = "0123456789abcdef0123456789abcdef";
private const int PollTimeoutMilliseconds = 5000;
/// <summary>
/// StartPhone без ключей приложения → INVALID_ARGUMENT «Сначала сохраните…».
/// </summary>
[Fact]
public async Task StartPhone_WithoutApiKeys_InvalidArgument()
{
await RunScenarioAsync(
async channel =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.StartPhoneAsync(
new StartPhoneRequest { Phone = "+79990001122", ApiId = 0, ApiHash = string.Empty },
Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.NoApiKeys, exception.Status.Detail);
});
}
/// <summary>
/// Отсутствующий tenant-id в metadata → UNAUTHENTICATED (принадлежность по metadata, Ruling 1).
/// </summary>
[Fact]
public async Task GetStatus_WithoutTenantId_Unauthenticated()
{
await RunScenarioAsync(
async channel =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.GetStatusAsync(new GetStatusRequest(), TokenOnlyOptions()).ResponseAsync);
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.TenantIdMissing, exception.Status.Detail);
});
}
/// <summary>
/// GetStatus без сессии тенанта → FAILED_PRECONDITION «Telegram не подключён».
/// </summary>
[Fact]
public async Task GetStatus_NoSession_FailedPrecondition()
{
await RunScenarioAsync(
async channel =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.GetStatusAsync(new GetStatusRequest(), Options()).ResponseAsync);
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail);
});
}
/// <summary>
/// StartQr → фаза "qr" с URL; GetStatus показывает фазу/URL.
/// </summary>
[Fact]
public async Task StartQr_ReturnsQrPhase_AndStatusShowsQr()
{
await RunScenarioAsync(
async channel =>
{
var client = new TelegramService.TelegramServiceClient(channel);
StartQrReply qr = await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options());
Assert.Equal("qr", qr.Phase);
Assert.Equal(FakeSessionClient.DefaultQrUrl, qr.QrUrl);
GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options());
Assert.Equal("qr", status.Phase);
Assert.True(status.HasQrUrl);
Assert.Equal(FakeSessionClient.DefaultQrUrl, status.QrUrl);
});
}
/// <summary>
/// QR-сканирование (эмуляция) → фаза "ready" с account; сессия сохранена.
/// </summary>
[Fact]
public async Task StartQr_ScanCompleted_ReadyWithAccount()
{
await RunScenarioAsync(
async channel =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options());
FakeSessionClient fake = SingleCreatedClient();
fake.CompleteQrScan();
GetStatusReply status = await WaitForPhaseAsync(client, "ready");
Assert.True(status.Connected);
Assert.Equal("@fake_user", status.Account);
Assert.False(status.HasQrUrl);
});
}
/// <summary>
/// SendPassword вне фазы "password" (сейчас "qr") → FAILED_PRECONDITION.
/// </summary>
[Fact]
public async Task SendPassword_WhileQrPhase_FailedPrecondition()
{
await RunScenarioAsync(
async channel =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.SendPasswordAsync(new SendPasswordRequest { Password = "x" }, Options()).ResponseAsync);
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.PasswordNotRequested, exception.Status.Detail);
});
}
/// <summary>
/// Logout: ok=true, сессия удалена — GetStatus снова «Telegram не подключён».
/// </summary>
[Fact]
public async Task Logout_Ok_ThenGetStatusNotConnected()
{
await RunScenarioAsync(
async channel =>
{
var client = new TelegramService.TelegramServiceClient(channel);
await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options());
SingleCreatedClient().CompleteQrScan();
await WaitForPhaseAsync(client, "ready");
LogoutReply logout = await client.LogoutAsync(new LogoutRequest(), Options());
Assert.True(logout.Ok);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.GetStatusAsync(new GetStatusRequest(), Options()).ResponseAsync);
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
});
}
// Прогоняет сценарий RPC на хосте с фейковой фабрикой клиентов.
// scenario: Сценарий с gRPC-каналом.
private static async Task RunScenarioAsync(Func<GrpcChannel, Task> scenario)
{
var factory = new FakeClientFactory();
CurrentFactory = factory;
try
{
await TelegramTestHost.RunAsync(
TelegramTestHost.DefaultToken,
scenario,
configureServices: services => services.AddSingleton<ITelegramClientFactory>(factory));
}
finally
{
CurrentFactory = null;
}
}
// Хранилище активной фабрики сценария (для управления «сканированием»).
private static FakeClientFactory? CurrentFactory { get; set; }
// Единственный созданный клиент сценария.
private static FakeSessionClient SingleCreatedClient()
{
List<FakeSessionClient> created = CurrentFactory?.CreatedClients ?? new List<FakeSessionClient>();
Assert.Single(created);
return created[0];
}
// Опции вызова с токеном и tenant-id.
private static CallOptions Options()
=> new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, TenantId), deadline: Deadline());
// Опции вызова только с токеном (без tenant-id).
private static CallOptions TokenOnlyOptions()
=> new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken), deadline: Deadline());
// Поллинг GetStatus до ожидаемой фазы.
// client: Клиент TelegramService.
// phase: Ожидаемая фаза ("ready").
private static async Task<GetStatusReply> WaitForPhaseAsync(TelegramService.TelegramServiceClient client, string phase)
{
var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds);
while (DateTime.UtcNow < deadline)
{
GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options());
if (status.Phase == phase)
{
return status;
}
await Task.Delay(25);
}
return await client.GetStatusAsync(new GetStatusRequest(), Options());
}
// Deadline вызовов теста.
private static DateTime Deadline()
=> DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds);
}
@@ -0,0 +1,174 @@
using Deal.Grpc.Hosting;
using Deal.Telegram;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Sockets;
namespace Deal.Telegram.Tests;
// Общий харнесс интеграционных тестов telegram-service: поднимает хост (TelegramServiceHost.Create)
// в процессе теста на эфемерном порту с управляемым окружением env и каталогом сессий в temp.
// TelegramServiceHost.Create требует DEAL_TELEGRAM_SESSION_KEY (fail-closed), поэтому харнесс всегда
// выставляет валидный тестовый ключ и указывает DEAL_TELEGRAM_SESSION_DIR во временный каталог
// (сессии тестов не попадают в data/sessions репозитория). Исходные значения env восстанавливаются.
internal static class TelegramTestHost
{
/// <summary>
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor).
/// </summary>
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
/// <summary>
/// Env-ключ адреса ингресса ядра (зеркало CoreIngressOptions).
/// </summary>
public const string IngressEndpointEnvKey = CoreIngressOptions.IngressEndpointEnvVarName;
/// <summary>
/// Env-ключ ключа шифрования сессий (зеркало TgOptions).
/// </summary>
public const string SessionKeyEnvKey = TgOptions.SessionKeyEnvVarName;
/// <summary>
/// Env-ключ каталога сессий (зеркало TgOptions).
/// </summary>
public const string SessionDirEnvKey = TgOptions.SessionDirEnvVarName;
/// <summary>
/// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor).
/// </summary>
public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey;
/// <summary>
/// Ключ gRPC-metadata с tenant-id (зеркало TelegramServiceImpl).
/// </summary>
public const string TenantIdMetadataKey = TelegramServiceImpl.TenantIdMetadataKey;
/// <summary>
/// Токен сценариев теста.
/// </summary>
public const string DefaultToken = "deal-test-token";
/// <summary>
/// Тестовый ключ шифрования сессий: base64 от байтов 0..31 (валидные 32 байта AES-256).
/// </summary>
public const string SessionKeyBase64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
/// <summary>
/// Id тенанта сценариев по умолчанию.
/// </summary>
public const string DefaultTenantId = "tenant-test";
/// <summary>
/// Deadline RPC-вызовов теста (сек).
/// </summary>
public const int RpcDeadlineSeconds = 10;
/// <summary>
/// Прогоняет сценарий против поднятого хоста с заданным env-токеном и тестовым окружением сессий.
/// </summary>
/// <param name="serviceToken">Значение env DEAL_SERVICE_TOKEN (null — убрать переменную).</param>
/// <param name="scenario">Сценарий с gRPC-каналом к хосту.</param>
/// <param name="configureServices">Хук DI (подмена зависимостей фейками).</param>
/// <param name="extraEnv">Дополнительные env-переменные на время сценария (напр. адрес ингресса).</param>
public static async Task RunAsync(
string? serviceToken,
Func<GrpcChannel, Task> scenario,
Action<IServiceCollection>? configureServices = null,
IReadOnlyDictionary<string, string?>? extraEnv = null)
{
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
string? originalKey = Environment.GetEnvironmentVariable(SessionKeyEnvKey);
string? originalDir = Environment.GetEnvironmentVariable(SessionDirEnvKey);
Dictionary<string, string?> originalExtra = extraEnv?.ToDictionary(pair => pair.Key, pair => Environment.GetEnvironmentVariable(pair.Key)) ?? [];
string sessionsDir = Path.Combine(Path.GetTempPath(), "deal-tg-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(sessionsDir);
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
Environment.SetEnvironmentVariable(SessionKeyEnvKey, SessionKeyBase64);
Environment.SetEnvironmentVariable(SessionDirEnvKey, sessionsDir);
if (extraEnv is not null)
{
foreach ((string key, string? value) in extraEnv)
{
Environment.SetEnvironmentVariable(key, value);
}
}
WebApplication? app = null;
GrpcChannel? channel = null;
try
{
int port = FreeTcpPort();
app = TelegramServiceHost.Create(port, configureServices: configureServices);
await app.StartAsync();
channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}");
await scenario(channel);
}
finally
{
if (channel is not null)
{
channel.Dispose();
}
if (app is not null)
{
await app.StopAsync();
await app.DisposeAsync();
}
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
Environment.SetEnvironmentVariable(SessionKeyEnvKey, originalKey);
Environment.SetEnvironmentVariable(SessionDirEnvKey, originalDir);
foreach ((string key, string? originalValue) in originalExtra)
{
Environment.SetEnvironmentVariable(key, originalValue);
}
try
{
Directory.Delete(sessionsDir, recursive: true);
}
catch (IOException)
{
// Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим.
}
}
}
/// <summary>
/// Строит metadata вызова: service-token (+ tenant-id, если задан).
/// </summary>
/// <param name="serviceToken">Значение заголовка service-token.</param>
/// <param name="tenantId">Id тенанта (null — без заголовка tenant-id).</param>
public static Metadata CallMetadata(string? serviceToken, string? tenantId = null)
{
var metadata = new Metadata();
if (serviceToken is not null)
{
metadata.Add(ServiceTokenMetadataKey, serviceToken);
}
if (tenantId is not null)
{
metadata.Add(TenantIdMetadataKey, tenantId);
}
return metadata;
}
// Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста).
private static int FreeTcpPort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
}
@@ -0,0 +1,531 @@
using System.Diagnostics;
using Deal.Telegram.Sessions;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Тесты фазовой машины сессии тенанта (план Task 9 Acceptance: фазовые переходы на fake-клиенте
/// ISessionClient; изоляция тенантов; отсутствие сессии → отказ). Без сети — клиент фейковый,
/// хранилище реальное (temp-каталог): проверяется и сохранение сессии после авторизации.
/// </summary>
public sealed class TenantSessionTests
{
private const string TenantId = "tenant-unit";
private const int ApiId = 12345;
private const string ApiHash = "0123456789abcdef0123456789abcdef";
private const string Phone = "+79990001122";
private const string Code = "11111";
private const int PollTimeoutMilliseconds = 5000;
// Таймаут попытки переподключения в тестах (короткий, без реальных 10 с).
private static readonly TimeSpan ReconnectTestTimeout = TimeSpan.FromMilliseconds(200);
/// <summary>
/// StartPhone без ключей приложения → INVALID_ARGUMENT «Сначала сохраните…».
/// </summary>
[Fact]
public async Task StartPhone_NoApiKeys_ThrowsNoApiKeys()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
try
{
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => ctx.Session.StartPhoneAsync(0, string.Empty, Phone, CancellationToken.None));
Assert.Equal(StatusCode.InvalidArgument, exception.Code);
Assert.Equal(SessionErrorMessages.NoApiKeys, exception.Message);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// StartPhone: успех → фаза "code", код запрошен у клиента; файл сессии ещё не создан.
/// </summary>
[Fact]
public async Task StartPhone_Success_PhaseCode_AndCodeRequested()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
try
{
TenantSessionSnapshot snapshot = await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
Assert.Equal(AuthPhase.Code, snapshot.Phase);
Assert.Equal([Phone], ctx.Factory.CreatedClients.Single().RequestedPhones);
Assert.Null(await ctx.Store.LoadAsync(TenantId, CancellationToken.None));
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// SendCode без начатой сессии → FAILED_PRECONDITION «Telegram не подключён».
/// </summary>
[Fact]
public async Task SendCode_WithoutSession_ThrowsNotConnected()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
try
{
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => ctx.Session.SendCodeAsync("11111", CancellationToken.None));
Assert.Equal(StatusCode.FailedPrecondition, exception.Code);
Assert.Equal(SessionErrorMessages.NotConnected, exception.Message);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// SendPassword вне фазы "password" → FAILED_PRECONDITION (фаза "code" после StartPhone).
/// </summary>
[Fact]
public async Task SendPassword_WhenPhaseIsCode_ThrowsPasswordNotRequested()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
try
{
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => ctx.Session.SendPasswordAsync("secret", CancellationToken.None));
Assert.Equal(StatusCode.FailedPrecondition, exception.Code);
Assert.Equal(SessionErrorMessages.PasswordNotRequested, exception.Message);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// SendCode с неверным кодом → INVALID_ARGUMENT «Неверный код», фаза остаётся "code" (повтор).
/// </summary>
[Fact]
public async Task SendCode_InvalidCode_KeepsCodePhase_WithError()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
ctx.ConfigureClient(client => client.CodeError = new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.WrongCode));
try
{
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => ctx.Session.SendCodeAsync("00000", CancellationToken.None));
Assert.Equal(StatusCode.InvalidArgument, exception.Code);
Assert.Equal(SessionErrorMessages.WrongCode, exception.Message);
TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(AuthPhase.Code, snapshot?.Phase);
Assert.Equal(SessionErrorMessages.WrongCode, snapshot?.Error);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// SendCode с истёкшим кодом → INVALID_ARGUMENT «Код истёк — запросите новый».
/// </summary>
[Fact]
public async Task SendCode_ExpiredCode_ThrowsCodeExpired()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
ctx.ConfigureClient(client => client.CodeError = new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.CodeExpired));
try
{
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => ctx.Session.SendCodeAsync("99999", CancellationToken.None));
Assert.Equal(SessionErrorMessages.CodeExpired, exception.Message);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// 2FA: верный код → фаза "password"; верный пароль → "ready" + файл сессии сохранён.
/// </summary>
[Fact]
public async Task SendCode_ThenPassword_Ready_AndSessionPersisted()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
ctx.ConfigureClient(client => client.CodeResult = "password");
try
{
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
TenantSessionSnapshot codeSnapshot = await ctx.Session.SendCodeAsync("33333", CancellationToken.None);
Assert.Equal(AuthPhase.Password, codeSnapshot.Phase);
TenantSessionSnapshot readySnapshot = await ctx.Session.SendPasswordAsync("cloud-pass", CancellationToken.None);
Assert.Equal(AuthPhase.Ready, readySnapshot.Phase);
Assert.Equal("@fake_user", readySnapshot.Account);
StoredSession? stored = await ctx.Store.LoadAsync(TenantId, CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal(ApiId, stored.ApiId);
Assert.Equal(ApiHash, stored.ApiHash);
Assert.Equal(FakeSessionClient.SessionMarker, stored.SessionBytes);
Assert.True(File.Exists(Path.Combine(ctx.Directory, TenantId + ".session")));
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// QR: StartQr → фаза "qr" + URL; после сканирования — "ready" и сессия сохранена.
/// </summary>
[Fact]
public async Task StartQr_ReturnsUrl_ThenScan_ReadyAndPersisted()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
try
{
TenantSessionSnapshot snapshot = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None);
Assert.Equal(AuthPhase.Qr, snapshot.Phase);
Assert.Equal(FakeSessionClient.DefaultQrUrl, snapshot.QrUrl);
Assert.True(ctx.Factory.CreatedClients.Single().QrStarted);
ctx.Factory.CreatedClients.Single().CompleteQrScan();
TenantSessionSnapshot? ready = await WaitUntilReadyAsync(ctx);
Assert.Equal(AuthPhase.Ready, ready?.Phase);
Assert.Equal("@fake_user", ready?.Account);
Assert.NotNull(await ctx.Store.LoadAsync(TenantId, CancellationToken.None));
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// Повторный StartQr во время активного QR возвращает тот же URL без нового клиента.
/// </summary>
[Fact]
public async Task StartQr_TwiceWhileActive_ReturnsSameUrl_ReusesClient()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
try
{
await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None);
TenantSessionSnapshot second = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None);
Assert.Equal(AuthPhase.Qr, second.Phase);
Assert.Equal(FakeSessionClient.DefaultQrUrl, second.QrUrl);
Assert.Single(ctx.Factory.CreatedClients);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// TryResumeAsync (auto_resume): сохранённая авторизованная сессия → "ready" + account.
/// </summary>
[Fact]
public async Task Resume_StoredAuthorizedSession_BecomesReady()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
try
{
var stored = new StoredSession
{
ApiId = ApiId,
ApiHash = ApiHash,
SessionBytes = FakeSessionClient.SessionMarker,
};
await ctx.Store.SaveAsync(TenantId, stored, CancellationToken.None);
bool resumed = await ctx.Session.TryResumeAsync(stored, CancellationToken.None);
Assert.True(resumed);
TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(AuthPhase.Ready, snapshot?.Phase);
Assert.Equal("@fake_user", snapshot?.Account);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// StartQr на уже авторизованной (возобновлённой) сессии → "ready", QR-URL пуст.
/// </summary>
[Fact]
public async Task StartQr_WhenAlreadyAuthorized_ReturnsReady_NoUrl()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
try
{
var stored = new StoredSession { ApiId = ApiId, ApiHash = ApiHash, SessionBytes = FakeSessionClient.SessionMarker };
await ctx.Store.SaveAsync(TenantId, stored, CancellationToken.None);
await ctx.Session.TryResumeAsync(stored, CancellationToken.None);
TenantSessionSnapshot snapshot = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None);
Assert.Equal(AuthPhase.Ready, snapshot.Phase);
Assert.Null(snapshot.QrUrl);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// Logout: фаза idle, сессия удалена, файл стёрт; последующие команды — «Telegram не подключён».
/// </summary>
[Fact]
public async Task Logout_RemovesSessionAndFile_ThenNotConnected()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
ctx.ConfigureClient(client => client.CodeResult = "password");
try
{
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
await ctx.Session.SendCodeAsync("33333", CancellationToken.None);
await ctx.Session.SendPasswordAsync("cloud-pass", CancellationToken.None);
Assert.NotNull(await ctx.Store.LoadAsync(TenantId, CancellationToken.None));
FakeSessionClient client = ctx.Factory.CreatedClients.Single();
TenantSessionSnapshot? afterLogout = await ctx.Session.LogoutAsync(CancellationToken.None);
Assert.Null(afterLogout);
Assert.True(client.LoggedOut);
Assert.Null(await ctx.Session.GetSnapshotAsync(CancellationToken.None));
Assert.Null(await ctx.Store.LoadAsync(TenantId, CancellationToken.None));
Assert.False(File.Exists(Path.Combine(ctx.Directory, TenantId + ".session")));
SessionException exception = await Assert.ThrowsAsync<SessionException>(
() => ctx.Session.SendCodeAsync("11111", CancellationToken.None));
Assert.Equal(StatusCode.FailedPrecondition, exception.Code);
Assert.Equal(SessionErrorMessages.NotConnected, exception.Message);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// QR-отмены (переключение на phone-вход) не ломают новую фазу: после CancelQrFlow через StartPhone — code.
/// </summary>
[Fact]
public async Task StartPhone_AfterActiveQr_SwitchesToCodePhase()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
try
{
await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None);
TenantSessionSnapshot phoneSnapshot = await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
Assert.Equal(AuthPhase.Code, phoneSnapshot.Phase);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// Отмена RPC StartQr до первого URL отменяет и фоновый QR-вход (не «скрытая» авторизация).
/// </summary>
[Fact]
public async Task StartQr_CancelledBeforeFirstUrl_CancelsBackgroundQrLogin()
{
await using SessionContext ctx = await SessionContext.CreateAsync();
try
{
// Первый URL не выдаётся, пока не случится сканирование/отмена (как в реальном QR-флоу).
ctx.ConfigureClient(client => client.DelayFirstQrUrl = true);
using var rpcCancellation = new CancellationTokenSource();
Task<TenantSessionSnapshot> startQr = ctx.Session.StartQrAsync(ApiId, ApiHash, rpcCancellation.Token);
await WaitUntilQrStartedAsync(ctx);
rpcCancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => startQr);
// Фоновая задача QR остановлена отменой, а не продолжает вход «скрыто».
Assert.True(await WaitUntilQrCancelledAsync(ctx), "фоновый QR-вход не отменён");
TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(AuthPhase.Idle, snapshot?.Phase);
}
finally
{
ctx.Cleanup();
}
}
/// <summary>
/// Heartbeat-переподключение ограничено собственным таймаутом: зависший connect не блокирует цикл.
/// </summary>
[Fact]
public async Task TryReconnect_HangingConnect_TimesOutByAttemptTimeout()
{
await using SessionContext ctx = await SessionContext.CreateAsync(reconnectTimeout: ReconnectTestTimeout);
try
{
// Готовая сессия (StartPhone → SendCode), затем «обрыв соединения».
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
await ctx.Session.SendCodeAsync(Code, CancellationToken.None);
FakeSessionClient client = ctx.Factory.CreatedClients.Single();
await client.DisposeAsync();
client.ConnectGate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var stopwatch = Stopwatch.StartNew();
await ctx.Session.TryReconnectAsync(CancellationToken.None);
stopwatch.Stop();
Assert.True(client.ConnectAttempts >= 1, "connect не вызывался");
Assert.False(client.ConnectGate.Task.IsCompleted, "connect завершился — ожидался «зависший» вызов");
Assert.True(
stopwatch.Elapsed >= ReconnectTestTimeout - TimeSpan.FromMilliseconds(50),
$"попытка вернулась слишком рано: {stopwatch.Elapsed}");
}
finally
{
ctx.Cleanup();
}
}
// Поллинг снимка до готовности (QR-сканирование обрабатывается фоновой задачей).
// ctx: Контекст сессии.
private static async Task<TenantSessionSnapshot?> WaitUntilReadyAsync(SessionContext ctx)
{
var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds);
while (DateTime.UtcNow < deadline)
{
TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None);
if (snapshot?.Phase == AuthPhase.Ready)
{
return snapshot;
}
await Task.Delay(25);
}
return await ctx.Session.GetSnapshotAsync(CancellationToken.None);
}
// Ждёт старта фонового QR-входа (клиент создан и ждёт первого URL/сканирования).
// ctx: Контекст сессии.
private static async Task WaitUntilQrStartedAsync(SessionContext ctx)
{
var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds);
while (DateTime.UtcNow < deadline)
{
if (ctx.Factory.CreatedClients.Count > 0 && ctx.Factory.CreatedClients[0].QrStarted)
{
return;
}
await Task.Delay(10);
}
Assert.Fail("QR-вход не стартовал за отведённое время");
}
// Ждёт остановки фонового QR-входа отменой (см. FakeSessionClient.QrLoginCancelled).
// ctx: Контекст сессии.
private static async Task<bool> WaitUntilQrCancelledAsync(SessionContext ctx)
{
var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds);
while (DateTime.UtcNow < deadline)
{
if (ctx.Factory.CreatedClients.Count > 0 && ctx.Factory.CreatedClients[0].QrLoginCancelled)
{
return true;
}
await Task.Delay(10);
}
return false;
}
// Контекст одного теста: реальное хранилище в temp-каталоге + фейковая фабрика и сессия тенанта.
private sealed class SessionContext : IAsyncDisposable
{
private readonly FakeClientFactory _factory;
private SessionContext(string directory, SessionStore store, TenantSession session, FakeClientFactory factory)
{
Directory = directory;
Store = store;
Session = session;
_factory = factory;
}
public string Directory { get; }
public SessionStore Store { get; }
public TenantSession Session { get; }
public FakeClientFactory Factory => _factory;
/// <summary>
/// Создаёт контекст: temp-каталог, хранилище, фейковая фабрика, сессия тенанта.
/// </summary>
/// <param name="reconnectTimeout">Таймаут попытки переподключения сессии (null — значение по умолчанию).</param>
public static Task<SessionContext> CreateAsync(TimeSpan? reconnectTimeout = null)
{
string dir = TestSessionFactory.NewSessionsDirectory();
var factory = new FakeClientFactory();
SessionStore store = TestSessionFactory.Store(dir);
var session = new TenantSession(
TenantId,
factory,
store,
NullLogger<TenantSession>.Instance,
reconnectTimeout);
return Task.FromResult(new SessionContext(dir, store, session, factory));
}
/// <summary>
/// Настраивает клиента, который создаст фабрика при первом обращении сессии.
/// </summary>
/// <param name="configure">Колбэк настройки FakeSessionClient.</param>
public void ConfigureClient(Action<FakeSessionClient> configure)
=> _factory.OnClientCreated = configure;
/// <summary>
/// Удаляет temp-каталог.
/// </summary>
public void Cleanup()
=> TestSessionFactory.Cleanup(Directory);
/// <inheritdoc />
public ValueTask DisposeAsync()
{
Cleanup();
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,89 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Core;
using Deal.Telegram.Dialogs;
using Grpc.Core;
namespace Deal.Telegram.Tests;
// Фейк исходящего канала в ядро (ICoreIngressClient) для unit-тестов служб каталога: записывает
// PushMessage/SyncDialogs без сети; SyncDialogs возвращает настраиваемый monitored-набор (роль ядра,
// Ruling 7). Проводка реального gRPC-канала проверяется отдельно — CoreIngressClientTests против
// in-proc фейк-сервера ингресса (FakeIngressServer).
internal sealed class FakeIngress : ICoreIngressClient
{
/// <summary>
/// Отправленные PushMessage (tenant + запрос), в порядке вызовов.
/// </summary>
public List<(string TenantId, PushMessageRequest Message)> Pushes { get; } = [];
/// <summary>
/// Синхронизации каталога (tenant + entries), в порядке вызовов.
/// </summary>
public List<(string TenantId, IReadOnlyList<DialogEntry> Entries)> Syncs { get; } = [];
/// <summary>
/// Monitored-набор, который фейк возвращает ответом SyncDialogs (как ядро).
/// </summary>
public List<string> MonitoredIdsToReturn { get; set; } = [];
/// <summary>
/// Ошибка PushMessageAsync (null — успех).
/// </summary>
public Exception? PushError { get; set; }
/// <summary>
/// Ошибка SyncDialogsAsync (null — успех).
/// </summary>
public Exception? SyncError { get; set; }
/// <summary>
/// Текст последнего PushMessage (для быстрых проверок).
/// </summary>
public string? LastPushText => Pushes.Count == 0 ? null : Pushes[^1].Message.Text;
/// <summary>
/// Id последнего отправленного диалога.
/// </summary>
public string? LastPushDialogId => Pushes.Count == 0 ? null : Pushes[^1].Message.DialogId;
/// <inheritdoc />
public Task<PushMessageReply> PushMessageAsync(string tenantId, PushMessageRequest message, CancellationToken cancellationToken)
{
if (PushError is not null)
{
throw PushError;
}
Pushes.Add((tenantId, message));
return Task.FromResult(new PushMessageReply { Accepted = true, Duplicate = false });
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> SyncDialogsAsync(string tenantId, IReadOnlyList<DialogEntry> entries, CancellationToken cancellationToken)
{
if (SyncError is not null)
{
throw SyncError;
}
Syncs.Add((tenantId, entries.ToArray()));
return Task.FromResult<IReadOnlyList<string>>(MonitoredIdsToReturn.ToArray());
}
}
// Фейк анти-бан-пейсера: паузы не ждёт, а записывает запрошенные диапазоны (сек) — проверка
// «паузы 1.5–3 с/сообщение, 3–6 с/диалог» без реальных задержек (fake clock плана Task 10).
internal sealed class RecordingPacer : IBackfillPacer
{
/// <summary>
/// Запрошенные диапазоны пауз (min/max, сек), в порядке вызовов.
/// </summary>
public List<(double MinSeconds, double MaxSeconds)> Waits { get; } = [];
/// <inheritdoc />
public Task WaitAsync(double minSeconds, double maxSeconds, CancellationToken cancellationToken)
{
Waits.Add((minSeconds, maxSeconds));
return Task.CompletedTask;
}
}
@@ -0,0 +1,60 @@
using Deal.Telegram.Sessions;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Telegram.Tests;
// Фабрика компонентов хранилища сессий для unit-тестов: уникальный temp-каталог на сценарий,
// тестовый ключ AES-256 и реальные SessionFileCipher/SessionStore (без сети).
internal static class TestSessionFactory
{
/// <summary>
/// Тестовый ключ AES-256 (байты 1..32).
/// </summary>
public static byte[] TestKey { get; } = Enumerable.Range(1, 32).Select(index => (byte)index).ToArray();
/// <summary>
/// Создаёт уникальный temp-каталог для сценария теста.
/// </summary>
public static string NewSessionsDirectory()
=> Path.Combine(Path.GetTempPath(), "deal-tg-sessions", Guid.NewGuid().ToString("N"));
/// <summary>
/// Опции хранилища над temp-каталогом.
/// </summary>
/// <param name="sessionsDirectory">Каталог сессий (NewSessionsDirectory).</param>
/// <param name="key">Ключ шифрования (по умолчанию <see cref="TestKey"/>).</param>
public static TgOptions Options(string sessionsDirectory, byte[]? key = null)
=> TgOptions.Create(key ?? TestKey, sessionsDirectory);
/// <summary>
/// Шифр сессий над каталогом.
/// </summary>
/// <param name="sessionsDirectory">Каталог сессий.</param>
/// <param name="key">Ключ шифрования.</param>
public static SessionFileCipher Cipher(string sessionsDirectory, byte[]? key = null)
=> new(Options(sessionsDirectory, key));
/// <summary>
/// Реальное файловое хранилище над temp-каталогом (NullLogger).
/// </summary>
/// <param name="sessionsDirectory">Каталог сессий.</param>
/// <param name="key">Ключ шифрования.</param>
public static SessionStore Store(string sessionsDirectory, byte[]? key = null)
=> new(Options(sessionsDirectory, key), Cipher(sessionsDirectory, key), NullLogger<SessionStore>.Instance);
/// <summary>
/// Удаляет temp-каталог после сценария (ошибки удаления игнорируются).
/// </summary>
/// <param name="sessionsDirectory">Каталог сессий.</param>
public static void Cleanup(string sessionsDirectory)
{
try
{
Directory.Delete(sessionsDirectory, recursive: true);
}
catch (IOException)
{
// Занятый файл — тестовый мусор в temp допустим.
}
}
}
@@ -0,0 +1,219 @@
using Deal.Telegram.Telegram;
using TL;
using Xunit;
namespace Deal.Telegram.Tests;
/// <summary>
/// Unit-тесты веток realtime-разбора на фейковых TL-объектах (без сети; план Task 10, ревью-фикс:
/// каналы/супергруппы идут UpdateNewChannelMessage, короткие — UpdateShortMessage/UpdateShortChatMessage).
/// Библиотека нормализует все варианты в UpdateNewMessage (UpdateNewChannelMessage — его подкласс,
/// короткие синтезируются списком UpdateList), поэтому юниты гоняют классификатор NewMessageFrom и
/// маппер TlMessageMapper на настоящих TL-типах. Живая проверка каналов — Manual (см. отчёт).
/// </summary>
public sealed class TlMessageMapperTests
{
private const long ChannelId = 111;
private const long ChatId = 222;
private const long UserId = 333;
/// <summary>
/// UpdateNewMessage (обычный чат) — извлекается сообщение.
/// </summary>
[Fact]
public void NewMessageFrom_UpdateNewMessage_ReturnsMessage()
{
var message = PrivateMessage(id: 5, text: "привет");
var update = new UpdateNewMessage { message = message, pts = 1, pts_count = 1 };
Assert.Same(message, TlMessageMapper.NewMessageFrom(update));
}
/// <summary>
/// UpdateNewChannelMessage (каналы/супергруппы) — извлекается (подкласс UpdateNewMessage).
/// </summary>
[Fact]
public void NewMessageFrom_UpdateNewChannelMessage_ReturnsMessage()
{
var message = ChannelMessage(id: 42, text: "пост канала");
var update = new UpdateNewChannelMessage { message = message, pts = 1, pts_count = 1 };
Assert.Same(message, TlMessageMapper.NewMessageFrom(update));
}
/// <summary>
/// Обновления «не нового сообщения» (edit/delete/…) — null (игнор).
/// </summary>
[Fact]
public void NewMessageFrom_OtherUpdates_ReturnsNull()
{
var message = ChannelMessage(id: 7, text: "текст");
Assert.Null(TlMessageMapper.NewMessageFrom(new UpdateEditMessage { message = message }));
Assert.Null(TlMessageMapper.NewMessageFrom(new UpdateDeleteMessages { messages = [7], pts = 1, pts_count = 1 }));
Assert.Null(TlMessageMapper.NewMessageFrom(new UpdateMessageID { id = 7, random_id = 1 }));
}
/// <summary>
/// Короткий личный UpdateShortMessage — библиотека синтезирует UpdateNewMessage → сообщение.
/// </summary>
[Fact]
public void NewMessageFrom_UpdateShortMessage_SynthesizesIncomingMessage()
{
var shortUpdate = new UpdateShortMessage
{
id = 9,
message = "короткое личное",
date = UtcDate(),
user_id = UserId,
pts = 1,
pts_count = 1,
};
var users = new Dictionary<long, User> { [UserId] = PrivateUser() };
MessageBase? synthesized = Assert.Single(shortUpdate.UpdateList) is UpdateNewMessage { message: MessageBase m } ? m : null;
Assert.NotNull(synthesized);
Assert.NotNull(TlMessageMapper.NewMessageFrom(new UpdateNewMessage { message = synthesized! }));
TelegramMessage? mapped = TlMessageMapper.ToMessage(synthesized!, new Dictionary<long, ChatBase>(), users);
Assert.NotNull(mapped);
Assert.Equal("+" + UserId, mapped.DialogId);
Assert.Equal("короткое личное", mapped.Text);
}
/// <summary>
/// Короткий групповой UpdateShortChatMessage — синтез UpdateNewMessage в чат (базовая группа).
/// </summary>
[Fact]
public void NewMessageFrom_UpdateShortChatMessage_SynthesizesIncomingMessage()
{
var shortUpdate = new UpdateShortChatMessage
{
id = 11,
message = "короткое в группе",
date = UtcDate(),
chat_id = ChatId,
from_id = UserId,
pts = 1,
pts_count = 1,
};
var chats = new Dictionary<long, ChatBase> { [ChatId] = new Chat { id = ChatId, title = "Базовая группа" } };
MessageBase? synthesized = Assert.Single(shortUpdate.UpdateList) is UpdateNewMessage { message: MessageBase m } ? m : null;
Assert.NotNull(synthesized);
TelegramMessage? mapped = TlMessageMapper.ToMessage(synthesized!, chats, new Dictionary<long, User>());
Assert.NotNull(mapped);
Assert.Equal("-" + ChatId, mapped.DialogId);
Assert.Equal("Базовая группа", mapped.DialogName);
}
/// <summary>
/// Маппинг сообщения канала: подписанный id «-100…», имя/username из сущности, текст.
/// </summary>
[Fact]
public void ToMessage_ChannelMessage_MapsSignedIdAndChannelFields()
{
var message = ChannelMessage(id: 42, text: "пост канала");
var chats = new Dictionary<long, ChatBase> { [ChannelId] = ChannelEntity() };
TelegramMessage? mapped = TlMessageMapper.ToMessage(message, chats, new Dictionary<long, User>());
Assert.NotNull(mapped);
Assert.Equal("-100" + ChannelId, mapped.DialogId);
Assert.Equal("пост канала", mapped.Text);
Assert.Equal("IT Канал", mapped.DialogName);
Assert.Equal("it_ch", mapped.DialogHandle);
Assert.Equal(42, mapped.Id);
Assert.Equal(new DateTimeOffset(UtcDate()).ToUnixTimeMilliseconds(), mapped.DateMs);
}
/// <summary>
/// Исходящее (out_) сообщение не поднимается как входящее (incoming-семантика python).
/// </summary>
[Fact]
public void ToMessage_OutgoingMessage_ReturnsNull()
{
var message = ChannelMessage(id: 43, text: "своё исходящее", flags: Message.Flags.out_);
Assert.Null(TlMessageMapper.ToMessage(message, new Dictionary<long, ChatBase>(), new Dictionary<long, User>()));
}
/// <summary>
/// Пустой/служебный текст не отдаётся в downstream (как python `if not text: continue`).
/// </summary>
[Fact]
public void ToMessage_EmptyOrServiceMessage_ReturnsNull()
{
Assert.Null(TlMessageMapper.ToMessage(ChannelMessage(id: 44, text: " "), new Dictionary<long, ChatBase>(), new Dictionary<long, User>()));
Assert.Null(TlMessageMapper.ToMessage(new MessageService { id = 45, peer_id = new PeerChannel { channel_id = ChannelId } }, new Dictionary<long, ChatBase>(), new Dictionary<long, User>()));
}
/// <summary>
/// Сообщение личного чата: подписанный id «+…» и имя пользователя.
/// </summary>
[Fact]
public void ToMessage_PrivateMessage_MapsUserDialog()
{
var message = PrivateMessage(id: 3, text: "личное");
var users = new Dictionary<long, User> { [UserId] = PrivateUser() };
TelegramMessage? mapped = TlMessageMapper.ToMessage(message, new Dictionary<long, ChatBase>(), users);
Assert.NotNull(mapped);
Assert.Equal("+" + UserId, mapped.DialogId);
Assert.Equal("Иван Петров", mapped.DialogName);
Assert.Equal("ivan", mapped.DialogHandle);
}
// Дата сообщения канала сценария (UTC).
private static DateTime UtcDate() => new(2023, 11, 15, 12, 0, 0, DateTimeKind.Utc);
// Сообщение канала (peer_id — PeerChannel).
// id: Id сообщения.
// text: Текст.
// flags: Флаги сообщения (по умолчанию входящее).
private static Message ChannelMessage(int id, string text, Message.Flags flags = 0)
=> new()
{
id = id,
message = text,
flags = flags,
date = UtcDate(),
peer_id = new PeerChannel { channel_id = ChannelId },
};
// Сообщение личного чата (peer_id — PeerUser).
// id: Id сообщения.
// text: Текст.
private static Message PrivateMessage(int id, string text)
=> new()
{
id = id,
message = text,
date = UtcDate(),
peer_id = new PeerUser { user_id = UserId },
};
// Канал сценария (broadcast, title/username).
private static Channel ChannelEntity()
=> new()
{
id = ChannelId,
access_hash = 987654321,
title = "IT Канал",
username = "it_ch",
flags = Channel.Flags.broadcast,
};
// Пользователь сценария (имя/username).
private static User PrivateUser()
=> new()
{
id = UserId,
access_hash = 123456,
first_name = "Иван",
last_name = "Петров",
username = "ivan",
};
}
+76
View File
@@ -0,0 +1,76 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Telegram", "Deal.Telegram\Deal.Telegram.csproj", "{34AB858D-C167-4A54-A45D-B3D8551842CF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Telegram.Tests", "Deal.Telegram.Tests\Deal.Telegram.Tests.csproj", "{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{03D1A800-5206-467E-92F1-0EED932AF0E9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x64.ActiveCfg = Debug|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x64.Build.0 = Debug|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x86.ActiveCfg = Debug|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x86.Build.0 = Debug|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|Any CPU.Build.0 = Release|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x64.ActiveCfg = Release|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x64.Build.0 = Release|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x86.ActiveCfg = Release|Any CPU
{34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x86.Build.0 = Release|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x64.ActiveCfg = Debug|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x64.Build.0 = Debug|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x86.ActiveCfg = Debug|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x86.Build.0 = Debug|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|Any CPU.Build.0 = Release|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x64.ActiveCfg = Release|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x64.Build.0 = Release|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x86.ActiveCfg = Release|Any CPU
{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x86.Build.0 = Release|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x64.ActiveCfg = Debug|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x64.Build.0 = Debug|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x86.ActiveCfg = Debug|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x86.Build.0 = Debug|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|Any CPU.Build.0 = Release|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x64.ActiveCfg = Release|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x64.Build.0 = Release|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x86.ActiveCfg = Release|Any CPU
{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x86.Build.0 = Release|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x64.ActiveCfg = Debug|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x64.Build.0 = Debug|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x86.ActiveCfg = Debug|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x86.Build.0 = Debug|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|Any CPU.Build.0 = Release|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x64.ActiveCfg = Release|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x64.Build.0 = Release|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x86.ActiveCfg = Release|Any CPU
{03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
@@ -0,0 +1,117 @@
using System.Diagnostics.CodeAnalysis;
namespace Deal.Telegram.Caching;
/// <summary>
/// Кэш с ограниченной ёмкостью и вытеснением least-recently-used (LRU): при переполнении удаляется
/// элемент, к которому дольше всего не обращались. Нужен там, где раньше жил неограниченный
/// <see cref="Dictionary{TKey,TValue}"/> и память росла с числом сущностей (долгоживущий процесс сервиса).
/// </summary>
/// <remarks>
/// НЕ потокобезопасен: вызывающий обязан сериализовать доступ (в telegram-service кэши
/// WTelegramSessionClient защищены общим <c>_entityCacheGate</c>). Вытеснение — забота производительности,
/// а не корректности: потерянное значение всегда можно получить повторным запросом к Telegram.
/// </remarks>
/// <typeparam name="TKey">Тип ключа (ссылочный или значимый).</typeparam>
/// <typeparam name="TValue">Тип значения.</typeparam>
public sealed class LruCache<TKey, TValue>
where TKey : notnull
{
private readonly Dictionary<TKey, LinkedListNode<Entry>> _map;
private readonly LinkedList<Entry> _recency = new();
/// <summary>
/// Создаёт кэш заданной ёмкости.
/// </summary>
/// <param name="capacity">Максимальное число элементов (должно быть больше нуля).</param>
/// <exception cref="ArgumentOutOfRangeException">Ёмкость не положительна.</exception>
public LruCache(int capacity)
{
if (capacity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "Ёмкость LRU-кэша должна быть положительной");
}
Capacity = capacity;
_map = new Dictionary<TKey, LinkedListNode<Entry>>(capacity);
}
/// <summary>
/// Максимальное число элементов кэша.
/// </summary>
public int Capacity { get; }
/// <summary>
/// Текущее число элементов кэша.
/// </summary>
public int Count => _map.Count;
/// <summary>
/// Пробует получить значение по ключу, отмечая его как недавно использованный.
/// </summary>
/// <param name="key">Ключ.</param>
/// <param name="value">Найденное значение (иначе значение по умолчанию).</param>
/// <returns>True — ключ найден.</returns>
public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value)
{
if (!_map.TryGetValue(key, out LinkedListNode<Entry>? node))
{
value = default;
return false;
}
Touch(node);
value = node.Value.Value;
return true;
}
/// <summary>
/// Проверяет наличие ключа, не меняя недавность (лёгкая проверка без вытеснения).
/// </summary>
/// <param name="key">Ключ.</param>
/// <returns>True — ключ есть в кэше.</returns>
public bool ContainsKey(TKey key) => _map.ContainsKey(key);
/// <summary>
/// Записывает значение по ключу (вставка или обновление) и вытесняет самый давний элемент при
/// переполнении. Обновление существующего ключа делает его самым недавним.
/// </summary>
/// <param name="key">Ключ.</param>
/// <param name="value">Значение.</param>
public void Set(TKey key, TValue value)
{
if (_map.TryGetValue(key, out LinkedListNode<Entry>? existing))
{
existing.Value = new Entry(key, value);
Touch(existing);
return;
}
var node = new LinkedListNode<Entry>(new Entry(key, value));
_recency.AddFirst(node);
_map[key] = node;
if (_map.Count > Capacity)
{
LinkedListNode<Entry> oldest = _recency.Last!;
_recency.RemoveLast();
_map.Remove(oldest.Value.Key);
}
}
// Перемещает узел в начало списка недавности (последний использованный).
// node: Узел кэша.
private void Touch(LinkedListNode<Entry> node)
{
if (!ReferenceEquals(node, _recency.First))
{
_recency.Remove(node);
_recency.AddFirst(node);
}
}
// Элемент кэша (ключ хранится для удаления при вытеснении из списка недавности).
// Key: Ключ.
// Value: Значение.
private readonly record struct Entry(TKey Key, TValue Value);
}
@@ -0,0 +1,127 @@
using Deal.Grpc.Hosting;
using Deal.Grpc.Telegram;
using Deal.Telegram.Sessions;
using Grpc.Core;
using Grpc.Net.Client;
namespace Deal.Telegram.Core;
/// <summary>
/// Исходящий gRPC-канал в ядро: клиент Deal.Grpc.Telegram.IngressService (план Task 10, Core/
/// CoreIngressClient.cs; Ruling 1/7). Каждый RPC несёт metadata tenant-id + service-token (Ruling 1);
/// принадлежность сообщений тенанту — только по metadata (ядро не доверяет полю). Сбой связи →
/// <see cref="Sessions.SessionException"/> UNAVAILABLE «Ядро недоступно…» с логом: буфер недоставленных
/// сообщений не ведётся — неподтверждённые сообщения не помечаются прочитанными и догоняются
/// realtime_sweep (упущенное после рестарта/разрыва, Ruling 7/план Task 10).
/// </summary>
public sealed class CoreIngressClient : ICoreIngressClient
{
/// <summary>
/// Ключ gRPC-metadata с id тенанта (зеркало интерцепторов, Ruling 1).
/// </summary>
public const string TenantIdMetadataKey = "tenant-id";
/// <summary>
/// Ключ gRPC-metadata с service-token (зеркало интерцепторов, Ruling 1).
/// </summary>
public const string ServiceTokenMetadataKey = "service-token";
private readonly CoreIngressOptions _options;
private readonly ILogger<CoreIngressClient> _logger;
private readonly MtlsCertificates? _mtlsCertificates;
private readonly object _channelGate = new();
private IngressService.IngressServiceClient? _client;
private GrpcChannel? _channel;
/// <summary>
/// Создаёт клиент ингресса ядра.
/// </summary>
/// <param name="options">Конфигурация (адрес из env, service-token).</param>
/// <param name="logger">Логгер.</param>
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал (dev).</param>
public CoreIngressClient(CoreIngressOptions options, ILogger<CoreIngressClient> logger, MtlsCertificates? mtlsCertificates = null)
{
_options = options;
_logger = logger;
_mtlsCertificates = mtlsCertificates;
}
/// <inheritdoc />
public async Task<PushMessageReply> PushMessageAsync(string tenantId, PushMessageRequest message, CancellationToken cancellationToken)
{
IngressService.IngressServiceClient client = GetClient();
try
{
return await client.PushMessageAsync(message, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw Fail("PushMessage", exception);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> SyncDialogsAsync(string tenantId, IReadOnlyList<DialogEntry> entries, CancellationToken cancellationToken)
{
IngressService.IngressServiceClient client = GetClient();
var request = new SyncDialogsRequest();
request.Entries.AddRange(entries);
try
{
SyncDialogsReply reply = await client.SyncDialogsAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
return reply.MonitoredIds;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw Fail("SyncDialogs", exception);
}
}
// Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса).
private IngressService.IngressServiceClient GetClient()
{
lock (_channelGate)
{
if (_client is null)
{
// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским
// сертификатом и проверяет CA ядра; dev — plaintext-канал (Ruling 2 этапа 6).
if (_mtlsCertificates is not null)
{
_channel = GrpcChannel.ForAddress(
_options.IngressEndpoint,
new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() });
}
else
{
_channel = GrpcChannel.ForAddress(_options.IngressEndpoint);
}
_client = new IngressService.IngressServiceClient(_channel);
}
return _client;
}
}
// Metadata вызова (tenant-id + service-token) и deadline (Ruling 1).
// tenantId: Id тенанта.
private CallOptions CallOptions(string tenantId)
{
var metadata = new Metadata
{
{ TenantIdMetadataKey, tenantId },
{ ServiceTokenMetadataKey, _options.ServiceToken },
};
return new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(CoreIngressOptions.RpcTimeoutSeconds));
}
// Переводит сбой вызова в SessionException (UNAVAILABLE) со структурированным логом.
// action: Действие (PushMessage/SyncDialogs) для лога аудита (Ruling 13).
// exception: Исключение вызова.
private SessionException Fail(string action, Exception exception)
{
_logger.LogWarning(exception, "Аудит: ингресс ядра {Action} → сбой ({Endpoint})", action, _options.IngressEndpoint);
return new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable, exception);
}
}
@@ -0,0 +1,86 @@
namespace Deal.Telegram.Core;
/// <summary>
/// Конфигурация исходящего канала в ядро (план Task 10; Ruling 2/12/13: только env).
///
/// Адрес gRPC-ингресса ядра — env `SERVICES__CORE__INGRESS` (Ruling 12: в dev/compose
/// http://host.docker.internal:5082; на хосте — http://localhost:5082); service-token — тот же общий
/// env `DEAL_SERVICE_TOKEN`, что проверяет интерцептор ядра (Ruling 1: каждый RPC несёт tenant-id и
/// service-token). Ключи/токены только env — не читаются из appsettings (Ruling 13).
/// </summary>
public sealed class CoreIngressOptions
{
/// <summary>
/// Env-ключ адреса gRPC-ингресса ядра (Ruling 12).
/// </summary>
public const string IngressEndpointEnvVarName = "SERVICES__CORE__INGRESS";
/// <summary>
/// Ключ конфигурации адреса ингресса (env `__` → `:` провайдером env).
/// </summary>
public const string IngressEndpointConfigKey = "Services:Core:Ingress";
/// <summary>
/// Env-ключ service-token (общий токен сервисов, Ruling 12).
/// </summary>
public const string ServiceTokenEnvVarName = "DEAL_SERVICE_TOKEN";
/// <summary>
/// Адрес ингресса ядра по умолчанию (core dev на хосте, Ruling 12).
/// </summary>
public const string DefaultIngressEndpoint = "http://localhost:5082";
/// <summary>
/// Таймаут одного RPC в ядро (сек).
/// </summary>
public const int RpcTimeoutSeconds = 15;
private CoreIngressOptions(string ingressEndpoint, string serviceToken)
{
IngressEndpoint = ingressEndpoint;
ServiceToken = serviceToken;
}
/// <summary>
/// Адрес gRPC-ингресса ядра (например, http://localhost:5082).
/// </summary>
public string IngressEndpoint { get; }
/// <summary>
/// Service-token для metadata каждого RPC (может быть пустым — ядро откажет).
/// </summary>
public string ServiceToken { get; }
/// <summary>
/// Создаёт опции с явным адресом и токеном (unit-тесты канала).
/// </summary>
/// <param name="ingressEndpoint">Адрес ингресса ядра.</param>
/// <param name="serviceToken">Service-token (пустой — вызовы будут отвергнуты ядром).</param>
public static CoreIngressOptions Create(string ingressEndpoint, string serviceToken)
{
if (string.IsNullOrWhiteSpace(ingressEndpoint))
{
throw new ArgumentException("Адрес ингресса ядра не задан.", nameof(ingressEndpoint));
}
return new CoreIngressOptions(ingressEndpoint, serviceToken);
}
/// <summary>
/// Читает конфигурацию из env/конфигурации хоста. Адрес не задан — значение по умолчанию
/// <see cref="DefaultIngressEndpoint"/> (localhost-ядро dev); токен — как в env (может быть пуст).
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
/// <returns>Опции исходящего канала в ядро.</returns>
public static CoreIngressOptions FromConfiguration(IConfiguration configuration)
{
string? endpoint = configuration[IngressEndpointConfigKey];
if (string.IsNullOrWhiteSpace(endpoint))
{
endpoint = DefaultIngressEndpoint;
}
string token = configuration[ServiceTokenEnvVarName] ?? string.Empty;
return new CoreIngressOptions(endpoint.Trim(), token);
}
}
@@ -0,0 +1,36 @@
using Deal.Grpc.Telegram;
namespace Deal.Telegram.Core;
/// <summary>
/// Исходящий канал в ядро (IngressService telegram.proto; план Task 10 Core/CoreIngressClient.cs, Ruling 7).
///
/// telegram-service — клиент Ingress ядра (:5082, `SERVICES__CORE__INGRESS`): сырые сообщения
/// мониторящихся диалогов (PushMessage), синхронизация каталога с ответом monitored-набора
/// (SyncDialogs). Интерфейс — seam: реальная реализация ходит по gRPC, тесты подставляют фейк/в-proc
/// сервер ингресса (план: «PushMessage-клиент к in-proc fake-серверу ингресса»).
/// </summary>
public interface ICoreIngressClient
{
/// <summary>
/// Отправляет сообщение диалога в ядро (Ingress.PushMessage → очередь пайплайна, Ruling 7).
/// Сбой связи — <see cref="Sessions.SessionException"/> (UNAVAILABLE «Ядро недоступно…»);
/// недоставленное сообщение не помечается прочитанным и догоняется realtime_sweep.
/// </summary>
/// <param name="tenantId">Id тенанта (metadata tenant-id, Ruling 1).</param>
/// <param name="message">Сообщение в контракте PushMessageRequest.</param>
/// <param name="cancellationToken">Отмена вызова.</param>
/// <returns>Ответ ядра (accepted/duplicate — дубль dialog+msgId в очереди не растёт).</returns>
public Task<PushMessageReply> PushMessageAsync(string tenantId, PushMessageRequest message, CancellationToken cancellationToken);
/// <summary>
/// Синхронизирует каталог с ядром (Ingress.SyncDialogs → SyncFromTelegram, Ruling 7). Ядро применяет
/// entries (авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и отвечает
/// актуальным списком monitored id — зеркало сервиса обновляется ответом.
/// </summary>
/// <param name="tenantId">Id тенанта (metadata tenant-id, Ruling 1).</param>
/// <param name="entries">Актуальный каталог диалогов (как refresh_dialogs).</param>
/// <param name="cancellationToken">Отмена вызова.</param>
/// <returns>Список id диалогов с включённым мониторингом (по версии ядра).</returns>
public Task<IReadOnlyList<string>> SyncDialogsAsync(string tenantId, IReadOnlyList<DialogEntry> entries, CancellationToken cancellationToken);
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
Deal.Telegram — gRPC-хост telegram-service (план Task 2; Ruling 1/2/12).
Кодогенерация .proto — в общем проекте src/contracts/Deal.Proto.csproj (Task 1, Ruling 1):
сервис подключает его ProjectReference и использует сгенерированную серверную базу
Deal.Grpc.Telegram.TelegramServiceBase (решение по способу подключения — T2, см. Note
task-1-report). Клиентская сторона telegram.proto (в т.ч. IngressService — исходящие в ядро,
Ruling 7) сгенерирована в Deal.Proto (GrpcServices="Both") и понадобится задачам 9–12.
Сборка: 0 warnings / 0 errors (TreatWarningsAsErrors, Directory.Build.props каталога сервиса).
-->
<PropertyGroup>
<AssemblyName>Deal.Telegram</AssemblyName>
<RootNamespace>Deal.Telegram</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Структурированные логи Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл
data/logs/deal-telegram-*.json; конфигурация — Deal.Telegram/DealLogging.cs (Program.cs).
Пакет тянет консоль/файл/compact-формат транзитивно. -->
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<!-- gRPC-сервер ASP.NET Core (Kestrel HTTP/2) + стандартный gRPC-health (Ruling 12). -->
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
<PackageReference Include="Grpc.AspNetCore.HealthChecks" Version="2.83.0" />
<PackageReference Include="WTelegramClient" Version="4.4.8" />
</ItemGroup>
<ItemGroup>
<!-- Общая серверная обвязка gRPC-хостов (C31): интерцепторы/mTLS/DealLogging/GrpcServer —
единый источник вместо копий в трёх сервисах. -->
<ProjectReference Include="..\..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj" />
<ProjectReference Include="..\..\contracts\Deal.Proto.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,198 @@
using System.Collections.Concurrent;
using Deal.Grpc.Telegram;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Backfill диалога: перечитывание последних ~10 сообщений и отправка их в ядро потоком PushMessage
/// (план Task 10, Dialogs/BackfillService.cs; прототип backfill_dialog/_backfill_dialogs L331–390).
///
/// 1:1 с прототипом: сообщения читаются от старых к новым (reversed — как реальный поток), между
/// отправками — анти-бан-пауза 1.5–3 с/сообщение (Ruling 3, BACKFILL_PER_MESSAGE L35); между
/// диалогами одного тенанта — пауза 3–6 с (BACKFILL_PER_DIALOG L36, python sleep между диалогами
/// L347). В конце — read-ack (снять «новое» в Telegram). Ошибка середины потока прерывает backfill
/// без read-ack: сообщения, не дошедшие до ядра, останутся непрочитанными и будут догнаны
/// realtime_sweep; дубли уже доставленных в ядро не растут (дубль-гвард dialog+msgId, Ruling 7).
/// Параметр force («Перечитать» по кнопке) прототип использует против своего флага backfilled;
/// в разделении флаг живёт в БД ядра, поэтому RPC исполняется всегда — дубли гасит ядро.
/// </summary>
public sealed class BackfillService
{
/// <summary>
/// Сколько последних сообщений читает backfill (прототип: limit=10, L371).
/// </summary>
public const int MessagesLimit = 10;
/// <summary>
/// Нижняя граница анти-бан-паузы между сообщениями (BACKFILL_PER_MESSAGE, L35).
/// </summary>
public const double MinPerMessageDelaySeconds = 1.5;
/// <summary>
/// Верхняя граница анти-бан-паузы между сообщениями (BACKFILL_PER_MESSAGE, L35).
/// </summary>
public const double MaxPerMessageDelaySeconds = 3.0;
/// <summary>
/// Нижняя граница паузы между диалогами одного тенанта (BACKFILL_PER_DIALOG, L36).
/// </summary>
public const double MinPerDialogDelaySeconds = 3.0;
/// <summary>
/// Верхняя граница паузы между диалогами одного тенанта (BACKFILL_PER_DIALOG, L36).
/// </summary>
public const double MaxPerDialogDelaySeconds = 6.0;
private readonly SessionFarm _sessionFarm;
private readonly ICoreIngressClient _ingress;
private readonly IBackfillPacer _pacer;
private readonly ILogger<BackfillService> _logger;
// Гард повторного входа: (тенант, диалог) уже перечитывается (python `_backfilling` L99).
private readonly ConcurrentDictionary<(string TenantId, string DialogId), byte> _running = new();
// Сериализация backfill'ов одного тенанта (python: один asyncio-loop на все диалоги).
private readonly ConcurrentDictionary<string, SemaphoreSlim> _tenantGates = new(StringComparer.Ordinal);
// Момент завершения последнего backfill тенанта (пауза 3–6 с между диалогами).
private readonly Dictionary<string, DateTime> _tenantLastFinishUtc = new(StringComparer.Ordinal);
private readonly object _lastFinishGate = new();
/// <summary>
/// Создаёт службу backfill'а.
/// </summary>
/// <param name="sessionFarm">Пул сессий тенантов (read-операции диалогов).</param>
/// <param name="ingress">Канал в ядро (PushMessage).</param>
/// <param name="pacer">Анти-бан-паузы (реальный — случайные, тесты — фейк).</param>
/// <param name="logger">Логгер.</param>
public BackfillService(
SessionFarm sessionFarm,
ICoreIngressClient ingress,
IBackfillPacer pacer,
ILogger<BackfillService> logger)
{
_sessionFarm = sessionFarm;
_ingress = ingress;
_pacer = pacer;
_logger = logger;
}
/// <summary>
/// Перечитывает последние сообщения диалога в ядро (Backfill RPC; кнопка «Перечитать»).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="force">True — «Перечитать» по кнопке (семантика флага — в ядре, см. класс).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Сколько сообщений отправлено в ядро (0 — диалог уже перечитывается/нет текстов).</returns>
public async Task<int> ExecuteAsync(string tenantId, string dialogId, bool force, CancellationToken cancellationToken)
{
var key = (tenantId, dialogId);
if (!_running.TryAdd(key, 0))
{
// Прототип L355–356: повторный вход в уже перечитываемый диалог → 0.
_logger.LogInformation("Аудит: backfill {TenantId} {DialogId} → пропущен (уже перечитывается)", tenantId, dialogId);
return 0;
}
try
{
SemaphoreSlim gate = _tenantGates.GetOrAdd(tenantId, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await EnsureDialogSpacingAsync(tenantId, cancellationToken).ConfigureAwait(false);
int processed = await BackfillDialogAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false);
lock (_lastFinishGate)
{
_tenantLastFinishUtc[tenantId] = DateTime.UtcNow;
}
_logger.LogInformation(
"Аудит: backfill {TenantId} {DialogId} → {Processed} сообщений в ядро (force {Force})",
tenantId,
dialogId,
processed,
force);
return processed;
}
finally
{
gate.Release();
}
}
finally
{
_running.TryRemove(key, out _);
}
}
// Пауза 3–6 с между backfill'ами разных диалогов одного тенанта (python L347).
// tenantId: Id тенанта.
// cancellationToken: Отмена операции.
private async Task EnsureDialogSpacingAsync(string tenantId, CancellationToken cancellationToken)
{
DateTime? lastFinishUtc;
lock (_lastFinishGate)
{
lastFinishUtc = _tenantLastFinishUtc.TryGetValue(tenantId, out DateTime value) ? value : null;
}
if (lastFinishUtc is null)
{
return;
}
double elapsedSeconds = (DateTime.UtcNow - lastFinishUtc.Value).TotalSeconds;
if (elapsedSeconds >= MaxPerDialogDelaySeconds)
{
return;
}
double minSeconds = Math.Max(0, MinPerDialogDelaySeconds - elapsedSeconds);
double maxSeconds = MaxPerDialogDelaySeconds - elapsedSeconds;
if (maxSeconds < minSeconds)
{
maxSeconds = minSeconds;
}
await _pacer.WaitAsync(minSeconds, maxSeconds, cancellationToken).ConfigureAwait(false);
}
// Читает и отправляет сообщения одного диалога (паузы между сообщениями, read-ack).
// tenantId: Id тенанта.
// dialogId: Подписанный id диалога.
// cancellationToken: Отмена операции.
// Возвращает: Сколько сообщений отправлено в ядро.
private async Task<int> BackfillDialogAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
{
IReadOnlyList<TelegramMessage> messages = await _sessionFarm
.GetMessagesAsync(tenantId, dialogId, MessagesLimit, cancellationToken)
.ConfigureAwait(false);
int processed = 0;
foreach (TelegramMessage message in messages.Reverse())
{
// От старых к новым — как реальный поток (прототип L372: `for m in reversed(msgs)`).
PushMessageReply reply = await _ingress
.PushMessageAsync(tenantId, DialogProtoMapper.ToPushRequest(message), cancellationToken)
.ConfigureAwait(false);
if (reply.Accepted && !reply.Duplicate)
{
processed++;
}
// Анти-бан между сообщениями (прототип L381; Ruling 3).
await _pacer.WaitAsync(MinPerMessageDelaySeconds, MaxPerMessageDelaySeconds, cancellationToken).ConfigureAwait(false);
}
// «Перечитали» — снимаем «новое» в Telegram (прототип L383–386). При ошибке выше read-ack
// не выполняется: неотправленное останется непрочитанным и догонится realtime_sweep.
await _sessionFarm.MarkReadAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false);
return processed;
}
}
@@ -0,0 +1,210 @@
using System.Collections.ObjectModel;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Зеркало каталога диалогов тенанта в памяти telegram-service (план Task 10, Dialogs/DialogCatalog.cs;
/// Ruling 7: «сервис держит зеркало мониторинга в памяти», ядро — владелец списка мониторинга в своей БД).
///
/// Тенант хранит два набора id диалогов: полный каталог (из refresh_dialogs/realtime_sweep, L468–519)
/// и подмножество с включённым мониторингом. Мониторинг актуализируется тремя путями:
/// * командой SetMonitor/SetMonitorAll ядра (обновление одной записи / всех сразу);
/// * ответом Ingress.SyncDialogs (ядро применило entries с autoMonitorNew — L244–246 «_reload_monitored»);
/// * очисткой после Logout/отключения аккаунта (прототип L203: `_monitored.clear()`).
/// Все операции потокобезопасны; зеркало чисто в памяти — потерю при рестарте догоняет realtime_sweep.
/// </summary>
public sealed class DialogCatalog
{
private readonly object _gate = new();
private readonly Dictionary<string, HashSet<string>> _knownByTenant = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _monitoredByTenant = new(StringComparer.Ordinal);
/// <summary>
/// Обновляет полный каталог диалогов тенанта (entries refresh/realtime_sweep). Мониторинг при этом
/// не трогается — актуальный monitored-набор приходит ответом SyncDialogs (или командами SetMonitor).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogIds">Актуальные id каталога (entries списка диалогов).</param>
public void ReplaceKnown(string tenantId, IEnumerable<string> dialogIds)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
lock (_gate)
{
_knownByTenant[tenantId] = new HashSet<string>(dialogIds, StringComparer.Ordinal);
}
}
/// <summary>
/// Включает/выключает мониторинг одного диалога (SetMonitor RPC, set_monitor L536–546). Команда
/// приходит от ядра после обновления его БД; зеркало повторяет решение без собственной проверки
/// «известности» — запись может появиться раньше каталога (включение после рестарта сервиса).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Id диалога.</param>
/// <param name="enabled">Мониторить (true) или выключить (false).</param>
public void SetMonitored(string tenantId, string dialogId, bool enabled)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(dialogId);
lock (_gate)
{
HashSet<string> monitored = MonitoredSet(tenantId);
if (enabled)
{
monitored.Add(dialogId);
}
else
{
monitored.Remove(dialogId);
}
}
}
/// <summary>
/// Заменяет monitored-набор ответом SyncDialogs (актуальный список ядра после SyncFromTelegram —
/// авто-мониторинг новых/удаление отсутствующих, Ruling 7). Это авторитетный источник зеркала.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="monitoredIds">Id диалогов с включённым мониторингом.</param>
public void ReplaceMonitored(string tenantId, IEnumerable<string> monitoredIds)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
lock (_gate)
{
_monitoredByTenant[tenantId] = new HashSet<string>(monitoredIds, StringComparer.Ordinal);
}
}
/// <summary>
/// Включает/выключает мониторинг всех диалогов каталога (SetMonitorAll RPC, set_monitor_all
/// L548–567). При включении учитываются и уже мониторящиеся записи (каталог может отставать от БД
/// ядра после рестарта — «монитор» из ответа SyncDialogs приедет следующим циклом realtime_sweep).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="enabled">Мониторить все (true) или снять мониторинг со всех (false).</param>
/// <returns>Сколько диалогов в каталоге тенанта (SetMonitorAllReply.count).</returns>
public int SetAllMonitored(string tenantId, bool enabled)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
lock (_gate)
{
HashSet<string> known = KnownSet(tenantId);
if (enabled)
{
HashSet<string> monitored = MonitoredSet(tenantId);
monitored.UnionWith(known);
}
else
{
_monitoredByTenant[tenantId] = new HashSet<string>(StringComparer.Ordinal);
}
return known.Count;
}
}
/// <summary>
/// Проверяет, мониторится ли диалог (фильтр realtime-событий L262 и догона sweep L429).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Id диалога.</param>
/// <returns>True — диалог в monitored-наборе тенанта.</returns>
public bool IsMonitored(string tenantId, string dialogId)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(dialogId))
{
return false;
}
lock (_gate)
{
return _monitoredByTenant.TryGetValue(tenantId, out HashSet<string>? monitored) && monitored.Contains(dialogId);
}
}
/// <summary>
/// Сколько диалогов знает каталог тенанта (ответ monitor-all, count каталога).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <returns>Размер каталога; 0 — каталог ещё не синхронизирован (нет refresh/sweep).</returns>
public int KnownCount(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return 0;
}
lock (_gate)
{
return _knownByTenant.TryGetValue(tenantId, out HashSet<string>? known) ? known.Count : 0;
}
}
/// <summary>
/// Возвращает копию мониторящихся id тенанта (для логов/тестов).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <returns>Набор мониторящихся id (пустой, если тенанта нет).</returns>
public IReadOnlyCollection<string> SnapshotMonitored(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Array.Empty<string>();
}
lock (_gate)
{
return _monitoredByTenant.TryGetValue(tenantId, out HashSet<string>? monitored)
? new ReadOnlyCollection<string>(monitored.ToArray())
: Array.Empty<string>();
}
}
/// <summary>
/// Сбрасывает состояние тенанта (Logout/отключение аккаунта — прототип L203).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public void Reset(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return;
}
lock (_gate)
{
_knownByTenant.Remove(tenantId);
_monitoredByTenant.Remove(tenantId);
}
}
// Полный каталог тенанта (создаёт пустой при первом обращении). Вызывается под _gate.
// tenantId: Id тенанта.
private HashSet<string> KnownSet(string tenantId)
{
if (!_knownByTenant.TryGetValue(tenantId, out HashSet<string>? known))
{
known = new HashSet<string>(StringComparer.Ordinal);
_knownByTenant[tenantId] = known;
}
return known;
}
// Monitored-набор тенанта (создаёт пустой при первом обращении). Вызывается под _gate.
// tenantId: Id тенанта.
private HashSet<string> MonitoredSet(string tenantId)
{
if (!_monitoredByTenant.TryGetValue(tenantId, out HashSet<string>? monitored))
{
monitored = new HashSet<string>(StringComparer.Ordinal);
_monitoredByTenant[tenantId] = monitored;
}
return monitored;
}
}
@@ -0,0 +1,43 @@
using System.Text;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Детерминированный цвет диалога из палитры DIALOG_HUES (1:1 с dialog_hue python-прототипа
/// telegram.py L876–880 + backend/app/constants.py DIALOG_HUES; Ruling 7: «hue считает сервис»).
/// Палитра и хэш идентичны прототипу, чтобы цвета источников совпадали между реализациями.
/// </summary>
public static class DialogHue
{
// Палитра диалоговых цветов (hex) — 1:1 constants.py DIALOG_HUES (8 цветов).
private static readonly string[] Palette =
[
"#3b82f6",
"#38bdf8",
"#f472b6",
"#f59e0b",
"#a78bfa",
"#34d399",
"#fb7185",
"#22c55e",
];
/// <summary>
/// Цвет источника по id диалога и имени: хэш по кодовым точкам (dialog_id + name) по модулю длины
/// палитры — 1:1 dialog_hue прототипа (порядок символов и множитель 31 сохранены; EnumerateRunes
/// повторяет итерацию по Unicode-кодовым точкам python `for ch in ...`).
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="name">Отображаемое имя (title/first_name); пустое — как в прототипе.</param>
/// <returns>Цвет палитры, hex "#rrggbb".</returns>
public static string Compute(string dialogId, string name)
{
int hash = 0;
foreach (Rune rune in (dialogId + name).EnumerateRunes())
{
hash = (hash * 31 + rune.Value) % Palette.Length;
}
return Palette[hash];
}
}
@@ -0,0 +1,59 @@
using System.Globalization;
using Deal.Grpc.Telegram;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Маппер нейтральных результатов сессии в protobuf-контракт (telegram.proto; план Task 10).
///
/// DialogEntry уходит в RefreshDialogsReply и SyncDialogsRequest (hue считает сервис по палитре —
/// Ruling 7); PushMessageRequest — в Ingress.PushMessage (канальные поля плоские, Ruling 7).
/// </summary>
public static class DialogProtoMapper
{
/// <summary>
/// Нейтральный диалог → DialogEntry контракта (id/name/username/kind/hue).
/// </summary>
/// <param name="dialog">Диалог из списка сессии.</param>
/// <returns>Запись каталога контракта с цветом палитры.</returns>
public static DialogEntry ToEntry(TelegramDialog dialog)
=> new()
{
Id = dialog.Id,
Name = dialog.Name,
Username = dialog.Username,
Kind = dialog.Kind,
Hue = DialogHue.Compute(dialog.Id, dialog.Name),
};
/// <summary>
/// Нейтральное сообщение → PreviewMessage превью (id строкой, время epoch-ms).
/// </summary>
/// <param name="message">Сообщение диалога (непустой текст).</param>
/// <returns>Сообщение превью ReadRecentReply (от новых к старым).</returns>
public static PreviewMessage ToPreview(TelegramMessage message)
=> new()
{
Id = message.Id.ToString(CultureInfo.InvariantCulture),
Text = message.Text,
Time = message.DateMs,
};
/// <summary>
/// Нейтральное сообщение → PushMessageRequest ингресса (1:1 QueuedMessage, Ruling 7).
/// </summary>
/// <param name="message">Сообщение диалога (непустой текст).</param>
/// <returns>Запрос Ingress.PushMessage с канальными полями и дубль-гвардом msg_id.</returns>
public static PushMessageRequest ToPushRequest(TelegramMessage message)
=> new()
{
DialogId = message.DialogId,
ChannelName = message.DialogName,
ChannelHandle = message.DialogHandle,
ChannelHue = DialogHue.Compute(message.DialogId, message.DialogName),
MsgId = message.Id,
Text = message.Text,
MsgAt = message.DateMs,
};
}
@@ -0,0 +1,21 @@
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Seam анти-бан-пауз между сетевыми операциями каталога (Ruling 3: «Внутренний анти-бан сервиса
/// (паузы между сетевыми операциями одной сессии): backfill 1.5–3 с/сообщение и 3–6 с/диалог»).
///
/// Реальная реализация (<see cref="RandomBackfillPacer"/>) спит случайное время в диапазоне как
/// `random.uniform` прототипа (BACKFILL_PER_MESSAGE/BACKFILL_PER_DIALOG, telegram.py L35–36);
/// тесты подставляют фейк-пейсер, записывающий запрошенные диапазоны (fake clock, план Task 10).
/// </summary>
public interface IBackfillPacer
{
/// <summary>
/// Ждёт случайное время в диапазоне [minSeconds, maxSeconds] (анти-бан).
/// </summary>
/// <param name="minSeconds">Нижняя граница паузы, секунды (≥ 0).</param>
/// <param name="maxSeconds">Верхняя граница паузы, секунды (≥ minSeconds).</param>
/// <param name="cancellationToken">Отмена ожидания.</param>
/// <returns>Задача ожидания.</returns>
public Task WaitAsync(double minSeconds, double maxSeconds, CancellationToken cancellationToken);
}
@@ -0,0 +1,27 @@
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Реальная реализация <see cref="IBackfillPacer"/>: случайная пауза в диапазоне (Random.Shared) —
/// эквивалент `await asyncio.sleep(random.uniform(min, max))` прототипа (telegram.py L381/L347).
/// </summary>
public sealed class RandomBackfillPacer : IBackfillPacer
{
/// <inheritdoc />
public async Task WaitAsync(double minSeconds, double maxSeconds, CancellationToken cancellationToken)
{
if (maxSeconds < 0 || minSeconds < 0 || minSeconds > maxSeconds)
{
throw new ArgumentOutOfRangeException(nameof(maxSeconds), $"Некорректный диапазон паузы: [{minSeconds}, {maxSeconds}].");
}
if (maxSeconds == 0)
{
return;
}
double seconds = minSeconds == maxSeconds
? minSeconds
: minSeconds + Random.Shared.NextDouble() * (maxSeconds - minSeconds);
await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,99 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Realtime-listener мониторящихся диалогов одного тенанта (план Task 10, Dialogs/RealtimeListener.cs;
/// прототип _on_message L255–283). Подписывается на события <see cref="TenantSession.MessageReceived"/>
/// (входящие текстовые сообщения аккаунта), фильтрует по зеркалу мониторинга <see cref="DialogCatalog"/>
/// и отправляет сообщение в ядро Ingress.PushMessage; после успешной отправки — mark-as-read
/// (send_read_acknowledge, ТЗ: «сразу помечаются прочитанными», Ruling 3).
/// Упущенное при сбое/рестарте догоняет realtime_sweep (read-ack после сбоя не выполняется).
/// Жизненный цикл экземпляра — за <see cref="Hosting.RealtimeMonitorService"/> (по одной сессии ready).
/// </summary>
public sealed class RealtimeListener
{
private readonly TenantSession _session;
private readonly DialogCatalog _catalog;
private readonly ICoreIngressClient _ingress;
private readonly ILogger<RealtimeListener> _logger;
/// <summary>
/// Создаёт listener сессии.
/// </summary>
/// <param name="session">Ready-сессия тенанта (события сообщений её клиента).</param>
/// <param name="catalog">Зеркало мониторинга (фильтр «мониторится ли диалог», Ruling 7).</param>
/// <param name="ingress">Канал в ядро (PushMessage).</param>
/// <param name="logger">Логгер.</param>
public RealtimeListener(
TenantSession session,
DialogCatalog catalog,
ICoreIngressClient ingress,
ILogger<RealtimeListener> logger)
{
_session = session;
_catalog = catalog;
_ingress = ingress;
_logger = logger;
}
/// <summary>
/// Подписывает listener на события сессии (вызывается при готовности сессии).
/// </summary>
public void Start()
{
_session.MessageReceived += OnMessageReceivedAsync;
_session.SetListenerActive(true);
}
/// <summary>
/// Отписывает listener (сессия ушла из ready/остановка хоста).
/// </summary>
public void Stop()
{
_session.MessageReceived -= OnMessageReceivedAsync;
_session.SetListenerActive(false);
}
// Обработчик входящего сообщения: фильтр по зеркалу → PushMessage в ядро → mark-as-read.
// Сбой отправки не роняет realtime: сообщение остаётся непрочитанным и догоняется realtime_sweep.
// message: Входящее сообщение аккаунта.
private async Task OnMessageReceivedAsync(TelegramMessage message)
{
try
{
if (!_catalog.IsMonitored(_session.TenantId, message.DialogId))
{
return;
}
PushMessageReply reply = await _ingress
.PushMessageAsync(_session.TenantId, DialogProtoMapper.ToPushRequest(message), CancellationToken.None)
.ConfigureAwait(false);
if (reply.Accepted)
{
await _session.MarkReadAsync(message.DialogId, CancellationToken.None).ConfigureAwait(false);
}
_logger.LogInformation(
"Realtime {TenantId} {DialogId} msg {MessageId} → ядро (duplicate {Duplicate})",
_session.TenantId,
message.DialogId,
message.Id,
reply.Duplicate);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Без read-ack: непрочитанное сообщение подберёт realtime_sweep (Ruling 7/план Task 10).
_logger.LogWarning(
exception,
"Realtime {TenantId} {DialogId} msg {MessageId}: не доставлено в ядро — догонит sweep",
_session.TenantId,
message.DialogId,
message.Id);
}
}
}
@@ -0,0 +1,182 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Страховочная догонялка realtime (план Task 10, Dialogs/RealtimeSweep.cs; прототип realtime_sweep
/// L392–456, цикл 30 с). Для каждой ready-сессии: список диалогов → синхронизация каталога с ядром
/// (SyncDialogs: ядро применяет entries с autoMonitorNew и отвечает актуальным monitored — зеркало
/// восстанавливается после рестарта сервиса) → по мониторящимся диалогам с unread_count > 0 читает
/// до 10 непрочитанных (от старых к новым), отправляет в ядро PushMessage и снимает «новое»
/// (read-ack). Потерянные realtime-события (рестарт/разрыв/сбой PushMessage) догоняются здесь.
/// </summary>
public sealed class RealtimeSweep
{
/// <summary>
/// Период цикла догона (сек; прототип вызывается планировщиком ~30 с).
/// </summary>
public const int SweepPeriodSeconds = 30;
/// <summary>
/// Верхняя граница списка диалогов за цикл (как refresh_dialogs L510: limit=500).
/// </summary>
public const int DialogsLimit = 500;
/// <summary>
/// Запас сообщений сверх unread_count при чтении (прототип L435: min(unread + 2, 10)).
/// </summary>
public const int UnreadSlackMessages = 2;
/// <summary>
/// Потолок сообщений одного диалога за цикл (прототип L435: 10).
/// </summary>
public const int MaxMessagesPerDialog = 10;
private readonly SessionFarm _sessionFarm;
private readonly DialogCatalog _catalog;
private readonly ICoreIngressClient _ingress;
private readonly ILogger<RealtimeSweep> _logger;
/// <summary>
/// Создаёт догонялку realtime.
/// </summary>
/// <param name="sessionFarm">Пул сессий тенантов.</param>
/// <param name="catalog">Зеркало каталога/мониторинга (актуализируется ответом SyncDialogs).</param>
/// <param name="ingress">Канал в ядро (PushMessage/SyncDialogs).</param>
/// <param name="logger">Логгер.</param>
public RealtimeSweep(SessionFarm sessionFarm, DialogCatalog catalog, ICoreIngressClient ingress, ILogger<RealtimeSweep> logger)
{
_sessionFarm = sessionFarm;
_catalog = catalog;
_ingress = ingress;
_logger = logger;
}
/// <summary>
/// Один цикл догона: все ready-сессии по очереди (сбой тенанта не останавливает остальных).
/// </summary>
/// <param name="cancellationToken">Отмена цикла.</param>
public async Task SweepAllAsync(CancellationToken cancellationToken)
{
foreach (TenantSession session in _sessionFarm.Sessions)
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
if (session.Phase != AuthPhase.Ready)
{
continue;
}
try
{
await SweepTenantAsync(session.TenantId, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "realtime sweep {TenantId}: цикл завершился с ошибкой", session.TenantId);
}
}
}
// Догон одного тенанта: синк каталога + непрочитанные мониторящихся диалогов.
// tenantId: Id тенанта.
// cancellationToken: Отмена операции.
private async Task SweepTenantAsync(string tenantId, CancellationToken cancellationToken)
{
IReadOnlyList<TelegramDialog> dialogs = await _sessionFarm
.ListDialogsAsync(tenantId, DialogsLimit, cancellationToken)
.ConfigureAwait(false);
if (dialogs.Count == 0)
{
return;
}
// Синхронизация каталога: зеркало мониторинга восстанавливается ответом ядра (L399–426).
List<string> dialogIds = dialogs.Select(dialog => dialog.Id).ToList();
_catalog.ReplaceKnown(tenantId, dialogIds);
List<DialogEntry> entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList();
try
{
IReadOnlyList<string> monitored = await _ingress.SyncDialogsAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
_catalog.ReplaceMonitored(tenantId, monitored);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Ядро недоступно: упущенное догонит следующий цикл, зеркало остаётся прежним.
_logger.LogWarning(exception, "realtime sweep {TenantId}: синхронизация каталога не выполнена", tenantId);
}
foreach (TelegramDialog dialog in dialogs)
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
if (!_catalog.IsMonitored(tenantId, dialog.Id) || dialog.UnreadCount <= 0)
{
continue;
}
await SweepDialogAsync(tenantId, dialog, cancellationToken).ConfigureAwait(false);
}
}
// Догон одного диалога: чтение непрочитанных → PushMessage → read-ack (L427–456).
// tenantId: Id тенанта.
// dialog: Диалог с unread_count &gt; 0.
// cancellationToken: Отмена операции.
private async Task SweepDialogAsync(string tenantId, TelegramDialog dialog, CancellationToken cancellationToken)
{
try
{
int limit = Math.Min(dialog.UnreadCount + UnreadSlackMessages, MaxMessagesPerDialog);
IReadOnlyList<TelegramMessage> messages = await _sessionFarm
.GetMessagesAsync(tenantId, dialog.Id, limit, cancellationToken)
.ConfigureAwait(false);
int added = 0;
foreach (TelegramMessage message in messages.Reverse())
{
PushMessageReply reply = await _ingress
.PushMessageAsync(tenantId, DialogProtoMapper.ToPushRequest(message), cancellationToken)
.ConfigureAwait(false);
if (reply.Accepted && !reply.Duplicate)
{
added++;
}
}
if (added > 0)
{
_logger.LogInformation(
"realtime sweep {TenantId} {DialogId}: +{Added} в ядро (потерянные события)",
tenantId,
dialog.Id,
added);
}
// Снимаем «новое» в Telegram (прототип L454). При ошибке выше — без read-ack: следующий
// цикл увидит unread снова и дочитает то, что не ушло в ядро.
await _sessionFarm.MarkReadAsync(tenantId, dialog.Id, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "realtime sweep {TenantId} {DialogId}: диалог не догнан", tenantId, dialog.Id);
}
}
}
@@ -0,0 +1,161 @@
using Deal.Telegram.Dialogs;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
using Grpc.Core;
namespace Deal.Telegram.Discovery;
/// <summary>
/// Discovery-операции telegram-service поверх пула сессий тенантов (план Task 11; 1:1 discovery_* методов
/// python-прототипа telegram.py L622–873). Каждая операция исполняется на сессии своего тенанта
/// (SessionFarm → TenantSession, Ruling 1): поиск (Search), инфо об источнике (GetInfo), чтение выборки
/// для оценки (ReadForEval), вступление (Join) и выход (Leave).
///
/// Внутренний анти-бан сервиса (Ruling 3) — пауза 2–4 с после поискового запроса (ban_guard.search_pause
/// L78–80). Внешний анти-бан вступления (суточный лимит 50/тенант, паузы 50–70 с, flood-день, стоп-кран) —
/// владение core-воркера Discovery (Ruling 10): здесь Join — ручная операция вне квот (прототип L818–823),
/// FloodWait переводится в RESOURCE_EXHAUSTED (detail с префиксом "flood") — базовый флуд-гард сессии.
/// </summary>
public sealed class DiscoveryOps
{
/// <summary>
/// Верхняя граница поиска по умолчанию, если core не передал (прототип: 30, L624).
/// </summary>
public const int SearchDefaultLimit = 30;
/// <summary>
/// Нижняя граница анти-бан-паузы после поиска (ban_guard.search_pause, 2 с).
/// </summary>
public const double SearchPauseMinSeconds = 2.0;
/// <summary>
/// Верхняя граница анти-бан-паузы после поиска (ban_guard.search_pause, 4 с).
/// </summary>
public const double SearchPauseMaxSeconds = 4.0;
/// <summary>
/// Размер выборки чтения по умолчанию, если core не передал (ReadForEvalRequest limit ≥ 1).
/// </summary>
public const int ReadForEvalDefaultLimit = 30;
private readonly SessionFarm _sessionFarm;
private readonly IBackfillPacer _pacer;
private readonly ILogger<DiscoveryOps> _logger;
/// <summary>
/// Создаёт службу discovery-операций.
/// </summary>
/// <param name="sessionFarm">Пул сессий тенантов (операции только на сессии своего тенанта).</param>
/// <param name="pacer">Анти-бан-паузы (реальный — случайные 2–4 с, тесты — фейк).</param>
/// <param name="logger">Логгер.</param>
public DiscoveryOps(SessionFarm sessionFarm, IBackfillPacer pacer, ILogger<DiscoveryOps> logger)
{
_sessionFarm = sessionFarm;
_pacer = pacer;
_logger = logger;
}
/// <summary>
/// Глобальный поиск источников по ключу (discovery_search L624–664). Выполняет поиск на сессии тенанта,
/// затем держит анти-бан-паузу 2–4 с (Ruling 3) и возвращает результат без дублей и не длиннее лимита.
/// Личные чаты/боты (kind=chat) не отсеиваются — это делает ядро (Ruling 10).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
/// <param name="limit">Верхняя граница результата (≤ 0 — прототип-дефолт 30).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Найденные источники (каналы/группы/личные) без дублей, не длиннее limit.</returns>
public async Task<IReadOnlyList<TelegramDialog>> SearchAsync(string tenantId, string query, int limit, CancellationToken cancellationToken)
{
int effectiveLimit = limit > 0 ? limit : SearchDefaultLimit;
IReadOnlyList<TelegramDialog> found = await _sessionFarm
.SearchAsync(tenantId, query, effectiveLimit, cancellationToken)
.ConfigureAwait(false);
// Пауза между поисковыми запросами (анти-бан; Ruling 3, ban_guard.search_pause L78–80).
await _pacer.WaitAsync(SearchPauseMinSeconds, SearchPauseMaxSeconds, cancellationToken).ConfigureAwait(false);
return DedupeAndCap(found, effectiveLimit);
}
/// <summary>
/// Инфо об источнике для оценки кандидата (discovery_info L666–716).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id источника («-100…»/«-…»/«+…»).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Инфо об источнике (сбои определения не бросаются — см. ISessionClient.GetInfoAsync).</returns>
public Task<TelegramSourceInfo> GetInfoAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
=> _sessionFarm.GetInfoAsync(tenantId, dialogId, cancellationToken);
/// <summary>
/// Выборка последних сообщений источника для оценки (discovery_read L718–800).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="limit">Размер выборки (≤ 0 — дефолт; форумы читаются по активным темам).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>ok/сообщения либо ok=false/error="no_history" (не ошибка RPC).</returns>
public Task<DiscoveryReadResult> ReadForEvalAsync(string tenantId, string dialogId, int limit, CancellationToken cancellationToken)
=> _sessionFarm.ReadForEvalAsync(tenantId, dialogId, limit > 0 ? limit : ReadForEvalDefaultLimit, cancellationToken);
/// <summary>
/// Вступить в канал/группу по username (discovery_join L818–839; ручной join из API — вне квот, паузу
/// перед авто-join делает воркер ядра, Ruling 10). Пустой username → INVALID_ARGUMENT; FloodWait →
/// SessionException RESOURCE_EXHAUSTED с префиксом "flood" (флуд-гард, контракт telegram.proto).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="username">Username источника («@»/пробелы нормализуются).</param>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task JoinAsync(string tenantId, string username, CancellationToken cancellationToken)
{
string normalized = NormalizeUsername(username);
if (normalized.Length == 0)
{
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.JoinUsernameMissing);
}
await _sessionFarm.JoinAsync(tenantId, normalized, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Discovery: вступили в @{Username} от tenant {TenantId}", normalized, tenantId);
}
/// <summary>
/// Выйти из канала/группы (discovery_leave L841–848).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task LeaveAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
=> _sessionFarm.LeaveAsync(tenantId, dialogId, cancellationToken);
/// <summary>
/// Нормализует username вступления 1:1 прототипа L826 (strip + lstrip "@"): срезает пробелы и ведущие «@».
/// </summary>
/// <param name="username">Username из запроса (может быть пуст/null).</param>
/// <returns>Нормализованный username (пустой — вступать не по чему).</returns>
public static string NormalizeUsername(string? username)
=> (username ?? string.Empty).Trim().TrimStart('@');
// Убирает дубли id и обрезает результат до лимита, сохраняя порядок (1:1 L643–663).
// found: Результат поиска сессии (chats затем users).
// limit: Верхняя граница числа записей.
private static IReadOnlyList<TelegramDialog> DedupeAndCap(IReadOnlyList<TelegramDialog> found, int limit)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
var items = new List<TelegramDialog>(Math.Min(found.Count, limit));
foreach (TelegramDialog item in found)
{
if (!seen.Add(item.Id))
{
continue;
}
items.Add(item);
if (items.Count >= limit)
{
break;
}
}
return items;
}
}
@@ -0,0 +1,86 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Discovery;
/// <summary>
/// Маппер нейтральных результатов discovery в protobuf-контракт (telegram.proto; план Task 11).
///
/// Чистый и без зависимостей от TL-слоя — единое место разбора, которое используют RPC-реализации
/// (TelegramServiceImpl) и unit-тесты формата ответов (записи поиска — как в каталоге,
/// ChannelInfo/ReadForEvalReply — здесь). hue считает сервис (Ruling 7), не TL-слой.
/// </summary>
public static class DiscoveryProtoMapper
{
/// <summary>
/// Нейтральное инфо источника → ChannelInfo контракта (participants/is_forum, hue).
/// </summary>
/// <param name="info">Инфо из сессии (по умолчанию — только id/name).</param>
public static ChannelInfo ToChannelInfo(TelegramSourceInfo info)
{
var result = new ChannelInfo
{
Id = info.Id,
Name = info.Name,
Username = info.Username,
Kind = info.Kind,
Hue = DialogHue.Compute(info.Id, info.Name),
IsForum = info.IsForum,
};
if (info.Participants is int participants)
{
result.Participants = participants;
}
return result;
}
/// <summary>
/// Сообщение выборки → EvalMessage контракта (темы форума — optional-поля).
/// </summary>
/// <param name="message">Сообщение discovery-read (непустой текст).</param>
public static EvalMessage ToEvalMessage(DiscoveryMessage message)
{
var result = new EvalMessage
{
Id = message.Id,
Text = message.Text,
DateMs = message.DateMs,
};
if (message.TopicId is long topicId)
{
result.TopicId = topicId;
}
if (!string.IsNullOrEmpty(message.TopicTitle))
{
result.TopicTitle = message.TopicTitle;
}
return result;
}
/// <summary>
/// Результат чтения выборки → ReadForEvalReply контракта (ok/error/messages).
/// </summary>
/// <param name="result">Результат из сессии (ok=false → error="no_history").</param>
public static ReadForEvalReply ToReadForEvalReply(DiscoveryReadResult result)
{
var reply = new ReadForEvalReply { Ok = result.Ok };
if (!result.Ok)
{
reply.Error = result.Error ?? DiscoveryReadResult.NoHistoryError;
return reply;
}
foreach (DiscoveryMessage message in result.Messages)
{
reply.Messages.Add(ToEvalMessage(message));
}
return reply;
}
}
@@ -0,0 +1,38 @@
# telegram-service: gRPC-хост Telegram (план Task 2; Ruling 12 — запись в deploy/compose.dev.yml).
#
# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Telegram.csproj ссылается на src/contracts/Deal.Proto.csproj
# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из
# src/telegram-service. Запуск из корня: docker build -f src/telegram-service/Deal.Telegram/Dockerfile .
# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5101.
# --- Этап сборки: restore + publish ---
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /repo
# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников).
COPY src/contracts/Deal.Proto.csproj src/contracts/
COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/
COPY src/telegram-service/Directory.Build.props src/telegram-service/
COPY src/telegram-service/Deal.Telegram/Deal.Telegram.csproj src/telegram-service/Deal.Telegram/
RUN dotnet restore src/telegram-service/Deal.Telegram/Deal.Telegram.csproj
# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса.
COPY src/contracts/ src/contracts/
COPY src/grpc-hosting/ src/grpc-hosting/
COPY src/telegram-service/Deal.Telegram/ src/telegram-service/Deal.Telegram/
RUN dotnet publish src/telegram-service/Deal.Telegram/Deal.Telegram.csproj -c Release -o /app/publish
# --- Runtime-этап ---
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
EXPOSE 5101
COPY --from=build /app/publish .
# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token
# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata.
COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe
# Сессии тенантов — файлы /data/sessions (Ruling 3): каталог монтируется volume-ом deal_tg_sessions
# из compose.dev.yml; создаётся при первом сохранении сессии (задачи 9–11).
ENTRYPOINT ["dotnet", "Deal.Telegram.dll"]
@@ -0,0 +1,132 @@
using Deal.Telegram.Core;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Sessions;
namespace Deal.Telegram.Hosting;
/// <summary>
/// Фоновый reconcile realtime-listener'ов (план Task 10; «подписка: новые сообщения → PushMessage»).
///
/// Держит по одному <see cref="RealtimeListener"/> на ready-сессию: сессия перешла в ready — listener
/// подписывается на её события (GetStatus.listener → true); сессия ушла из ready (Logout/ошибка) —
/// отписка. Период мал (2 с) — подписка появляется сразу после QR-сканирования/auto_resume; упущенное
/// в окне между ready и подпиской догоняет realtime_sweep (без read-ack сообщения остаются unread).
/// </summary>
public sealed class RealtimeMonitorService : BackgroundService
{
/// <summary>
/// Период reconcile подписок (сек).
/// </summary>
public const int ReconcileIntervalSeconds = 2;
private readonly SessionFarm _sessionFarm;
private readonly DialogCatalog _catalog;
private readonly ICoreIngressClient _ingress;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<RealtimeMonitorService> _logger;
private readonly Dictionary<TenantSession, RealtimeListener> _listeners = new();
private readonly object _listenersGate = new();
/// <summary>
/// Создаёт фоновый reconcile listener'ов.
/// </summary>
/// <param name="sessionFarm">Пул сессий тенантов.</param>
/// <param name="catalog">Зеркало мониторинга (фильтр сообщений).</param>
/// <param name="ingress">Канал в ядро (PushMessage).</param>
/// <param name="loggerFactory">Фабрика логгеров (логгеры listener'ов).</param>
public RealtimeMonitorService(
SessionFarm sessionFarm,
DialogCatalog catalog,
ICoreIngressClient ingress,
ILoggerFactory loggerFactory)
{
_sessionFarm = sessionFarm;
_catalog = catalog;
_ingress = ingress;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<RealtimeMonitorService>();
}
/// <summary>
/// Периодический reconcile подписок ready-сессий.
/// </summary>
/// <param name="stoppingToken">Токен остановки хоста.</param>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(TimeSpan.FromSeconds(ReconcileIntervalSeconds));
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
try
{
Reconcile();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception exception)
{
_logger.LogError(exception, "Reconcile realtime-listener'ов завершился с ошибкой — следующий цикл");
}
}
}
/// <summary>
/// При остановке хоста отписывает все listener'ы.
/// </summary>
/// <param name="cancellationToken">Токен остановки.</param>
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken).ConfigureAwait(false);
DetachAll();
}
// Сверяет подписки с ready-сессиями пула (подписка/отписка по факту фазы).
private void Reconcile()
{
HashSet<TenantSession> readySessions = _sessionFarm.Sessions
.Where(session => session.Phase == AuthPhase.Ready)
.ToHashSet();
lock (_listenersGate)
{
foreach (TenantSession session in _listeners.Keys.ToArray())
{
if (!readySessions.Contains(session))
{
if (_listeners.Remove(session, out RealtimeListener? listener))
{
listener.Stop();
}
}
}
foreach (TenantSession session in readySessions)
{
if (_listeners.ContainsKey(session))
{
continue;
}
var listener = new RealtimeListener(session, _catalog, _ingress, _loggerFactory.CreateLogger<RealtimeListener>());
listener.Start();
_listeners[session] = listener;
_logger.LogInformation("Realtime-listener {TenantId} подписан", session.TenantId);
}
}
}
// Отписывает все listener'ы (остановка хоста).
private void DetachAll()
{
lock (_listenersGate)
{
foreach (RealtimeListener listener in _listeners.Values)
{
listener.Stop();
}
_listeners.Clear();
}
}
}
@@ -0,0 +1,55 @@
using Deal.Telegram.Dialogs;
namespace Deal.Telegram.Hosting;
/// <summary>
/// Фоновый цикл догона realtime (план Task 10; эталон SessionHeartbeatService). Каждые 30 секунд
/// вызывает <see cref="RealtimeSweep.SweepAllAsync"/> по ready-сессиям; первый проход — сразу после
/// старта (зеркало мониторинга восстанавливается после рестарта, упущенное догоняется — Ruling 7).
/// Сбои отдельного цикла не роняют хост.
/// </summary>
public sealed class RealtimeSweepService : BackgroundService
{
private readonly RealtimeSweep _sweep;
private readonly ILogger<RealtimeSweepService> _logger;
/// <summary>
/// Создаёт фоновый цикл догона.
/// </summary>
/// <param name="sweep">Логика догона realtime.</param>
/// <param name="logger">Логгер.</param>
public RealtimeSweepService(RealtimeSweep sweep, ILogger<RealtimeSweepService> logger)
{
_sweep = sweep;
_logger = logger;
}
/// <summary>
/// Первый проход сразу, затем цикл 30 с.
/// </summary>
/// <param name="stoppingToken">Токен остановки хоста.</param>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(TimeSpan.FromSeconds(RealtimeSweep.SweepPeriodSeconds));
while (true)
{
try
{
await _sweep.SweepAllAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception exception)
{
_logger.LogError(exception, "Цикл realtime sweep завершился с ошибкой — следующий цикл");
}
if (!await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
break;
}
}
}
}
@@ -0,0 +1,76 @@
using Deal.Telegram.Sessions;
namespace Deal.Telegram.Hosting;
/// <summary>
/// Фоновый цикл сессий telegram-service (план Task 9: «heartbeat/авто-возобновление фоновым циклом
/// 30 с»; heartbeat прототипа L318–327). На старте — auto_resume сохранённых сессий тенантов
/// (авторизованная сессия → ready), далее каждые 30 секунд — повторное подключение оборвавшихся
/// сессий и (при остановке хоста) сохранение живых сессий в файлы (Ruling 3).
/// Сетевых вызовов без сессий не делает; с фейковой фабрикой в тестах — no-op.
/// </summary>
public sealed class SessionHeartbeatService : BackgroundService
{
/// <summary>
/// Период цикла сердцебиения (сек; прототип heartbeat вызывается планировщиком).
/// </summary>
public const int HeartbeatIntervalSeconds = 30;
private readonly SessionFarm _sessionFarm;
private readonly ILogger<SessionHeartbeatService> _logger;
/// <summary>
/// Создаёт фоновый цикл сессий.
/// </summary>
/// <param name="sessionFarm">Пул сессий тенантов.</param>
/// <param name="logger">Логгер.</param>
public SessionHeartbeatService(SessionFarm sessionFarm, ILogger<SessionHeartbeatService> logger)
{
_sessionFarm = sessionFarm;
_logger = logger;
}
/// <summary>
/// Первый проход — auto_resume файлов сессий (создаёт ready-сессии после рестарта контейнера);
/// затем периодический heartbeat оборвавшихся соединений.
/// </summary>
/// <param name="stoppingToken">Токен остановки хоста.</param>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await _sessionFarm.ResumeAllAsync(stoppingToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogError(exception, "auto_resume сессий не выполнен — продолжаем без возобновления");
}
using PeriodicTimer timer = new(TimeSpan.FromSeconds(HeartbeatIntervalSeconds));
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
try
{
await _sessionFarm.HeartbeatTickAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception exception)
{
_logger.LogError(exception, "Heartbeat сессий завершился с ошибкой — следующий цикл");
}
}
}
/// <summary>
/// При остановке хоста сохраняет живые сессии (перешифровка при остановке, Ruling 3).
/// </summary>
/// <param name="cancellationToken">Токен остановки.</param>
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken).ConfigureAwait(false);
await _sessionFarm.ShutdownAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,54 @@
// telegram-service — точка входа gRPC-хоста (план Task 2, L227–238; Ruling 1/2/12).
//
// Kestrel HTTP/2 на порту 5101 (env GRPC_PORT, затем PORT) + AddGrpc с интерцепторами service-token
// и access-лога + стандартный gRPC-health (grpc.health.v1.Health). Транспорт: dev — plaintext
// (Ruling 2); mTLS (TLS + клиентский сертификат) — при DEAL_MTLS_ENABLED=1 (Ruling 6, план Task 13;
// сертификаты deploy/certs — scripts/mtls-certs.sh, env передаёт compose-prod Task 14); fail-closed:
// Production без mTLS не стартует (GrpcHostEnvironment.RequireMtlsInProduction).
// Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика
// TelegramServiceHost.Create используется и интеграционными тестами (Deal.Telegram.Tests), которые
// поднимают его в своём процессе на эфемерном порту. Реализованы RPC сессий (Task 9),
// каталога/мониторинга (Task 10) и discovery-операции Search/GetInfo/ReadForEval/Join/Leave
// (Task 11, см. TelegramServiceImpl/DiscoveryOps).
using Deal.Grpc.Hosting;
using Deal.Telegram;
// Порт по умолчанию — 5101 (Ruling 12, compose.dev.yml); переопределяется env GRPC_PORT (контейнер)
// или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort.
const int defaultGrpcPort = 5101;
// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-telegram-<дата>.json.
const string telegramProcessName = "telegram";
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
// Порт эндпоинта метрик /metrics (HTTP/1.1, отдельно от gRPC HTTP/2; этап 12, пакет A).
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
// Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл data/logs/deal-telegram-*.json —
// конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают
// без Serilog, DealLogging.Configure в TelegramServiceHost/Create вызывается только здесь). Метрики
// (OTel → Prometheus, /metrics) — тем же хуком до builder.Build().
WebApplication app = TelegramServiceHost.Create(
grpcPort,
configureBuilder: builder =>
{
DealLogging.Configure(builder, telegramProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
});
// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A).
DealMetricsHosting.MapDealMetrics(app);
// Режим транспорта — из тех же env, что читал хост (Ruling 6, Task 13): mTLS при DEAL_MTLS_ENABLED=1.
MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration);
// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать
// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше.
GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions);
app.Logger.LogInformation(
"telegram-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)",
mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token",
grpcPort);
await app.RunAsync();
@@ -0,0 +1,38 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Фаза входа/подключения аккаунта тенанта (1:1 с каноном telegram.proto:
/// idle|phone|code|password|qr|ready — status() прототипа L85, план Task 9).
/// </summary>
public enum AuthPhase
{
/// <summary>
/// Активного входа нет (клиент не создан, вход не начат или завершён Logout).
/// </summary>
Idle,
/// <summary>
/// Фаза ввода номера телефона (в прототипе используется как промежуточная; код ещё не запрошен).
/// </summary>
Phone,
/// <summary>
/// SMS-код отправлен, ожидается код (submit_code).
/// </summary>
Code,
/// <summary>
/// Включена двухфакторная аутентификация — нужен облачный пароль (submit_password).
/// </summary>
Password,
/// <summary>
/// QR-вход запущен: ожидается сканирование, qr_url актуален.
/// </summary>
Qr,
/// <summary>
/// Аккаунт авторизован, сессия сохранена (клиент готов исполнять команды).
/// </summary>
Ready,
}
@@ -0,0 +1,110 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Канонические тексты причин сессионных ошибок (detail RPC и статус-поле GetStatus.error).
/// Тексты 1:1 с прототипом backend/app/services/telegram.py и перечнем строк плана
/// (задачи: «Telegram не подключён», «Сначала сохраните Telegram api_id и api_hash в настройках»,
/// «Неверный код», «Код истёк — запросите новый», «Неверный облачный пароль»).
/// </summary>
public static class SessionErrorMessages
{
/// <summary>
/// Ключи приложения не переданы ядром (INVALID_ARGUMENT).
/// </summary>
public const string NoApiKeys = "Сначала сохраните Telegram api_id и api_hash в настройках";
/// <summary>
/// Аккаунт тенанта не подключён — нет сессии (FAILED_PRECONDITION).
/// </summary>
public const string NotConnected = "Telegram не подключён";
/// <summary>
/// Неверный SMS-код входа (INVALID_ARGUMENT).
/// </summary>
public const string WrongCode = "Неверный код";
/// <summary>
/// SMS-код истёк, нужен новый (INVALID_ARGUMENT).
/// </summary>
public const string CodeExpired = "Код истёк — запросите новый";
/// <summary>
/// Неверный облачный пароль 2FA (INVALID_ARGUMENT).
/// </summary>
public const string WrongPassword = "Неверный облачный пароль";
/// <summary>
/// SendCode вызван вне фазы "code" (FAILED_PRECONDITION).
/// </summary>
public const string CodeNotRequested = "Код не запрашивался — начните вход по номеру телефона";
/// <summary>
/// SendPassword вызван вне фазы "password" (FAILED_PRECONDITION).
/// </summary>
public const string PasswordNotRequested = "2FA-пароль не запрашивался — сначала отправьте код";
/// <summary>
/// Metadata tenant-id отсутствует или пуст (UNAUTHENTICATED).
/// </summary>
public const string TenantIdMissing = "tenant-id отсутствует в metadata";
/// <summary>
/// Некорректный tenant-id (INVALID_ARGUMENT).
/// </summary>
public const string InvalidTenantId = "Некорректный tenant-id в metadata";
/// <summary>
/// Telegram/сеть недоступны (UNAVAILABLE).
/// </summary>
public const string TelegramUnavailable = "Telegram недоступен — повторите попытку позже";
/// <summary>
/// Ядро (gRPC-ингресс) недоступно — PushMessage/SyncDialogs не доставлены (UNAVAILABLE).
/// </summary>
public const string IngressUnavailable = "Ядро недоступно — повторите попытку позже";
/// <summary>
/// Диалог не найден в аккаунте/кэше сущностей сессии (INVALID_ARGUMENT).
/// </summary>
public const string UnknownDialog = "Источник не найден в аккаунте — обновите список каналов";
/// <summary>
/// Пустой username вступления (INVALID_ARGUMENT; 1:1 ValueError discovery_join L828).
/// </summary>
public const string JoinUsernameMissing = "Не указан username для вступления";
/// <summary>
/// Поисковый запрос длиннее верхней границы (INVALID_ARGUMENT; защита границы сервиса).
/// </summary>
public const string SearchQueryTooLong = "Слишком длинный поисковый запрос";
/// <summary>
/// Username вступления длиннее лимита Telegram (INVALID_ARGUMENT).
/// </summary>
public const string UsernameTooLong = "Слишком длинный username";
/// <summary>
/// Внутренняя ошибка сервиса (INTERNAL; сбой реализации, а не транспорт/сеть).
/// </summary>
public const string InternalError = "Внутренняя ошибка сервиса — повторите попытку позже";
/// <summary>
/// По username найден не канал/группа (личный чат/бот) — вступить нельзя (INVALID_ARGUMENT).
/// </summary>
public const string JoinTargetNotChannel = "По этому username найден не канал/группа — вступить нельзя";
/// <summary>
/// Некорректный (неподписанный) id диалога в запросе (INVALID_ARGUMENT).
/// </summary>
public const string InvalidDialogId = "Некорректный id источника";
/// <summary>
/// Телефон не зарегистрирован в Telegram (регистрация из сервиса не выполняется).
/// </summary>
public const string SignUpRequired = "Номер не зарегистрирован в Telegram — зарегистрируйте его в приложении Telegram";
/// <summary>
/// Ключ шифрования сессий не задан в env (служебная ошибка конфигурации).
/// </summary>
public const string SessionKeyNotConfigured = "Ключ шифрования сессий не задан (DEAL_TELEGRAM_SESSION_KEY)";
}
@@ -0,0 +1,31 @@
using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Доменная ошибка сессий telegram-service (план Task 9; Ruling 1/3).
///
/// Ошибки Telegram/логики подключения переводятся в gRPC-статусы ядром только через этот тип:
/// обработчики TelegramServiceImpl ловят его и возвращают RpcException с кодом <see cref="Code"/>
/// и detail = сообщению (текст причины 1:1 с прототипом, см. <see cref="SessionErrorMessages"/>).
/// Неизвестные/сетевые сбои адаптер WTelegramClient также оборачивает в этот тип (UNAVAILABLE).
/// </summary>
public sealed class SessionException : Exception
{
/// <summary>
/// Создаёт ошибку сессии с gRPC-кодом, в который она должна превратиться на границе.
/// </summary>
/// <param name="code">gRPC-статус ошибки (контракт telegram.proto, шапка файла).</param>
/// <param name="message">Текст причины — detail RPC (1:1 с текстами прототипа).</param>
/// <param name="innerException">Внутренняя причина (исключение Telegram/адаптера), если есть.</param>
public SessionException(StatusCode code, string message, Exception? innerException = null)
: base(message, innerException)
{
Code = code;
}
/// <summary>
/// gRPC-статус, в который ошибка превращается на границе сервиса.
/// </summary>
public StatusCode Code { get; }
}
@@ -0,0 +1,298 @@
using System.Collections.Concurrent;
using Grpc.Core;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Пул сессий тенантов «1 аккаунт на тенанта» (план Task 9, Sessions/SessionFarm.cs; Ruling 3,
/// архитектура §7.1). Сессия создаётся на первый вход/возобновление и переиспользуется (Logout
/// сбрасывает её в состояние «отключено» — из карты объект не удаляется, гонок вызова нет).
/// Все сетевые команды исполняются на сессии своего тенанта (Ruling 1); операций по чужим сессиям нет.
/// </summary>
public sealed class SessionFarm
{
private readonly ITelegramClientFactory _clientFactory;
private readonly SessionStore _sessionStore;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<SessionFarm> _logger;
private readonly ConcurrentDictionary<string, TenantSession> _sessions = new(StringComparer.Ordinal);
/// <summary>
/// Создаёт пул сессий.
/// </summary>
/// <param name="clientFactory">Фабрика клиентов Telegram (реальная или фейк в тестах).</param>
/// <param name="sessionStore">Файловое хранилище сессий.</param>
/// <param name="loggerFactory">Фабрика логгеров (логгеры TenantSession).</param>
/// <param name="logger">Логгер.</param>
public SessionFarm(
ITelegramClientFactory clientFactory,
SessionStore sessionStore,
ILoggerFactory loggerFactory,
ILogger<SessionFarm> logger)
{
_clientFactory = clientFactory;
_sessionStore = sessionStore;
_loggerFactory = loggerFactory;
_logger = logger;
}
/// <summary>
/// Возвращает сессию тенанта, если она уже создана (иначе null — «Telegram не подключён»).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public TenantSession? FindSession(string tenantId)
=> _sessions.TryGetValue(tenantId, out TenantSession? session) ? session : null;
/// <summary>
/// Все сессии пула (снимок; для realtime-циклов каталога — RealtimeMonitorService/Sweep).
/// </summary>
public IReadOnlyCollection<TenantSession> Sessions
=> _sessions.Values.ToArray();
/// <summary>
/// Список диалогов аккаунта тенанта (только ready-сессия; план Task 10).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="limit">Верхняя граница числа диалогов.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<IReadOnlyList<TelegramDialog>> ListDialogsAsync(string tenantId, int limit, CancellationToken cancellationToken)
=> RequireSession(tenantId).ListDialogsAsync(limit, cancellationToken);
/// <summary>
/// Последние сообщения диалога тенанта (только ready-сессия; план Task 10).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="limit">Сколько последних сообщений запросить.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(string tenantId, string dialogId, int limit, CancellationToken cancellationToken)
=> RequireSession(tenantId).GetMessagesAsync(dialogId, limit, cancellationToken);
/// <summary>
/// Помечает диалог тенанта прочитанным (только ready-сессия; план Task 10).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task MarkReadAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
=> RequireSession(tenantId).MarkReadAsync(dialogId, cancellationToken);
/// <summary>
/// Глобальный поиск каналов/групп по ключу (только ready-сессия; план Task 11).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
/// <param name="limit">Верхняя граница результата.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<IReadOnlyList<TelegramDialog>> SearchAsync(string tenantId, string query, int limit, CancellationToken cancellationToken)
=> RequireSession(tenantId).SearchAsync(query, limit, cancellationToken);
/// <summary>
/// Инфо об источнике для оценки кандидата (только ready-сессия; план Task 11).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TelegramSourceInfo> GetInfoAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
=> RequireSession(tenantId).GetInfoAsync(dialogId, cancellationToken);
/// <summary>
/// Выборка сообщений источника для оценки (только ready-сессия; план Task 11).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="limit">Размер выборки.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<DiscoveryReadResult> ReadForEvalAsync(string tenantId, string dialogId, int limit, CancellationToken cancellationToken)
=> RequireSession(tenantId).ReadForEvalAsync(dialogId, limit, cancellationToken);
/// <summary>
/// Вступить в канал/группу по username (только ready-сессия; план Task 11).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="username">Username (без «@»; нормализует DiscoveryOps).</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task JoinAsync(string tenantId, string username, CancellationToken cancellationToken)
=> RequireSession(tenantId).JoinAsync(username, cancellationToken);
/// <summary>
/// Выйти из канала/группы (только ready-сессия; план Task 11).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task LeaveAsync(string tenantId, string dialogId, CancellationToken cancellationToken)
=> RequireSession(tenantId).LeaveAsync(dialogId, cancellationToken);
/// <summary>
/// Вход по телефону: сессия создаётся при первом обращении.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="apiId">api_id приложения.</param>
/// <param name="apiHash">api_hash приложения.</param>
/// <param name="phone">Номер телефона.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot> StartPhoneAsync(string tenantId, int apiId, string apiHash, string phone, CancellationToken cancellationToken)
=> GetOrCreate(tenantId).StartPhoneAsync(apiId, apiHash, phone, cancellationToken);
/// <summary>
/// QR-вход: сессия создаётся при первом обращении.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="apiId">api_id приложения.</param>
/// <param name="apiHash">api_hash приложения.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot> StartQrAsync(string tenantId, int apiId, string apiHash, CancellationToken cancellationToken)
=> GetOrCreate(tenantId).StartQrAsync(apiId, apiHash, cancellationToken);
/// <summary>
/// Отправка SMS-кода на сессии тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="code">Код.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot> SendCodeAsync(string tenantId, string code, CancellationToken cancellationToken)
=> RequireSession(tenantId).SendCodeAsync(code, cancellationToken);
/// <summary>
/// Отправка облачного пароля 2FA на сессии тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="password">Пароль.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot> SendPasswordAsync(string tenantId, string password, CancellationToken cancellationToken)
=> RequireSession(tenantId).SendPasswordAsync(password, cancellationToken);
/// <summary>
/// Отключение аккаунта: Auth_LogOut + удаление файла сессии тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot?> LogoutAsync(string tenantId, CancellationToken cancellationToken)
{
TenantSession? session = FindSession(tenantId);
if (session is null)
{
// Сессии нет — нечего отключать; файла на диске тоже быть не должно (чистим на всякий случай).
return DeleteStrayFileAsync(tenantId, cancellationToken);
}
return session.LogoutAsync(cancellationToken);
}
/// <summary>
/// Статус сессии тенанта; null — аккаунт не подключён (сессии нет).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TenantSessionSnapshot?> GetStatusAsync(string tenantId, CancellationToken cancellationToken)
{
TenantSession? session = FindSession(tenantId);
return session is null ? Task.FromResult<TenantSessionSnapshot?>(null) : session.GetSnapshotAsync(cancellationToken);
}
/// <summary>
/// Авто-возобновление на старте (auto_resume L209–222): для каждого файла сессии на диске создаёт
/// клиент и при авторизации переводит тенанта в "ready". Сбои не роняют старт (внутри TryResumeAsync).
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task ResumeAllAsync(CancellationToken cancellationToken)
{
foreach (string tenantId in _sessionStore.ListTenantIds())
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
StoredSession? stored;
try
{
stored = await _sessionStore.LoadAsync(tenantId, cancellationToken).ConfigureAwait(false);
}
catch (SessionException exception)
{
_logger.LogWarning(exception, "auto_resume: сессия {TenantId} не прочитана", tenantId);
continue;
}
if (stored is null)
{
continue;
}
TenantSession session = GetOrCreate(tenantId);
await session.TryResumeAsync(stored, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Сердцебиение (30 с): повторное подключение оборвавшихся "ready"-сессий (heartbeat L318–327).
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task HeartbeatTickAsync(CancellationToken cancellationToken)
{
foreach (TenantSession session in _sessions.Values)
{
if (cancellationToken.IsCancellationRequested)
{
return;
}
await session.TryReconnectAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Остановка хоста: сохраняет живые сессии (перешифровка при остановке, Ruling 3) и освобождает
/// клиенты. Ошибки отдельных сессий не останавливают остальные.
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
foreach (TenantSession session in _sessions.Values)
{
try
{
await session.FlushAndDisposeAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "Остановка: сессия {TenantId} не освобождена", session.TenantId);
}
}
}
// Создаёт (или возвращает существующую) сессию тенанта.
// tenantId: Id тенанта.
private TenantSession GetOrCreate(string tenantId)
=> _sessions.GetOrAdd(tenantId, id => new TenantSession(
id,
_clientFactory,
_sessionStore,
_loggerFactory.CreateLogger<TenantSession>()));
// Сессия обязана существовать (иначе FAILED_PRECONDITION «Telegram не подключён»).
// tenantId: Id тенанта.
private TenantSession RequireSession(string tenantId)
=> FindSession(tenantId)
?? throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected);
// Logout без сессии в памяти: удаляет возможный осиротевший файл сессии.
// tenantId: Id тенанта.
// cancellationToken: Отмена операции.
private async Task<TenantSessionSnapshot?> DeleteStrayFileAsync(string tenantId, CancellationToken cancellationToken)
{
try
{
await _sessionStore.DeleteAsync(tenantId, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "Logout {TenantId}: осиротевший файл сессии не удалён", tenantId);
}
return null;
}
}
@@ -0,0 +1,110 @@
using System.Security.Cryptography;
namespace Deal.Telegram.Sessions;
/// <summary>
/// AES-256-GCM-обёртка файла сессии тенанта (план Task 9; Ruling 3).
///
/// Дублирование подхода AesGcmSecretCipher этапа 2 в отдельном процессе: сервисы этапа не имеют
/// ссылок на core (Ruling — общий только .proto и NuGet), поэтому маленький шифр реализован локально.
/// Формат значения тот же, что в core: <c>enc:</c> + Base64(nonce ‖ шифротекст ‖ tag)
/// (nonce 12 байт, tag 16 байт, ключ 32 байта из env DEAL_TELEGRAM_SESSION_KEY).
/// Экземпляр AesGcm создаётся на операцию — разделяемого криптографического состояния нет.
/// </summary>
public sealed class SessionFileCipher
{
/// <summary>
/// Префикс зашифрованного значения (маркер формата в файле сессии).
/// </summary>
public const string EncryptedPrefix = "enc:";
// Размер nonce AES-GCM (рекомендованный NIST — 96 бит).
private const int NonceSizeBytes = 12;
// Размер тега аутентичности.
private const int TagSizeBytes = 16;
private readonly byte[] _key;
/// <summary>
/// Создаёт шифр с ключом из опций (env DEAL_TELEGRAM_SESSION_KEY).
/// </summary>
/// <param name="options">Опции хранения сессий.</param>
public SessionFileCipher(TgOptions options)
{
_key = options.SessionKey;
}
/// <summary>
/// Шифрует содержимое файла сессии: случайный nonce + AES-GCM, возвращает значение
/// <c>enc:</c>+Base64(nonce ‖ шифротекст ‖ tag) — готовый текст файла data/sessions/&lt;tenant&gt;.session.
/// </summary>
/// <param name="plainBytes">Открытое содержимое сессии (расшифрованная копия в памяти процесса).</param>
/// <returns>Зашифрованное значение для записи в файл.</returns>
public string EncryptBytes(byte[] plainBytes)
{
ArgumentNullException.ThrowIfNull(plainBytes);
byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
byte[] cipherBytes = new byte[plainBytes.Length];
byte[] tag = new byte[TagSizeBytes];
using (AesGcm aesGcm = new AesGcm(_key, TagSizeBytes))
{
aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
}
byte[] payload = new byte[nonce.Length + cipherBytes.Length + tag.Length];
nonce.CopyTo(payload, 0);
cipherBytes.CopyTo(payload, nonce.Length);
tag.CopyTo(payload, nonce.Length + cipherBytes.Length);
return EncryptedPrefix + Convert.ToBase64String(payload);
}
/// <summary>
/// Расшифровывает значение файла сессии. null — значение не в формате сервиса (не enc: или
/// не base64): файл чужой/повреждён и трактуется как отсутствующий. Несовпадение тега/чужой
/// ключ — <see cref="CryptographicException"/> (файл повреждён или ключ сменился).
/// </summary>
/// <param name="encryptedValue">Содержимое файла сессии (enc: + base64).</param>
/// <returns>Открытые байты сессии либо null (не наш формат).</returns>
/// <exception cref="CryptographicException">Тег аутентичности не совпал (битый файл/чужой ключ).</exception>
public byte[]? DecryptBytes(string encryptedValue)
{
if (string.IsNullOrEmpty(encryptedValue) || !encryptedValue.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
{
return null;
}
byte[] payload;
try
{
payload = Convert.FromBase64String(encryptedValue[EncryptedPrefix.Length..]);
}
catch (FormatException)
{
return null;
}
if (payload.Length < NonceSizeBytes + TagSizeBytes)
{
// Слишком короткий payload: nonce и tag в нём не помещаются.
return null;
}
int cipherTextLength = payload.Length - NonceSizeBytes - TagSizeBytes;
byte[] plainBytes = new byte[cipherTextLength];
using (AesGcm aesGcm = new AesGcm(_key, TagSizeBytes))
{
aesGcm.Decrypt(
new ReadOnlySpan<byte>(payload, 0, NonceSizeBytes),
new ReadOnlySpan<byte>(payload, NonceSizeBytes, cipherTextLength),
new ReadOnlySpan<byte>(payload, NonceSizeBytes + cipherTextLength, TagSizeBytes),
plainBytes);
}
return plainBytes;
}
}
@@ -0,0 +1,172 @@
using System.Security.Cryptography;
using System.Text.Json;
using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Файловое хранилище сессий тенантов (план Task 9; Ruling 3) — аналог SessionManager/SessionStore
/// задачи: файлы data/sessions/&lt;tenantId&gt;.session, содержимое — AES-GCM-обёртка
/// (<see cref="SessionFileCipher"/>) сериализованного <see cref="StoredSession"/>.
///
/// Запись — атомарная (временный файл в том же каталоге + File.Move), чтобы рестарт/сбой
/// не оставил битый файл сессии; все записи сериализованы одним семафором (файлы маленькие,
/// запись редкая). Нечитаемый/повреждённый файл трактуется как отсутствие сессии (лог-warning),
/// файл не удаляется — диагностика сохраняется.
/// </summary>
public sealed class SessionStore
{
/// <summary>
/// Расширение файла сессии (data/sessions/&lt;tenantId&gt;.session).
/// </summary>
public const string SessionFileExtension = ".session";
private readonly string _sessionsDirectory;
private readonly SessionFileCipher _cipher;
private readonly ILogger<SessionStore> _logger;
private readonly SemaphoreSlim _writeLock = new(1, 1);
/// <summary>
/// Создаёт хранилище над каталогом из опций.
/// </summary>
/// <param name="options">Опции хранения сессий (каталог, ключ шифрования).</param>
/// <param name="cipher">AES-GCM-обёртка файлов сессий.</param>
/// <param name="logger">Логгер.</param>
public SessionStore(TgOptions options, SessionFileCipher cipher, ILogger<SessionStore> logger)
{
_sessionsDirectory = options.SessionsDirectory;
_cipher = cipher;
_logger = logger;
}
/// <summary>
/// Читает и расшифровывает сессию тенанта. Возвращает null, если файла нет или он нечитаем
/// (чужой формат/повреждён/другой ключ — лог-warning; файл сохраняется для диагностики).
/// </summary>
/// <param name="tenantId">Id тенанта (принадлежность сессии; Ruling 3 — 1 аккаунт на тенанта).</param>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task<StoredSession?> LoadAsync(string tenantId, CancellationToken cancellationToken = default)
{
string filePath = PathFor(tenantId);
if (!File.Exists(filePath))
{
return null;
}
string encryptedValue;
try
{
encryptedValue = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(exception, "Сессия {TenantId} не читается — трактуется как отсутствующая", tenantId);
return null;
}
try
{
byte[]? plainBytes = _cipher.DecryptBytes(encryptedValue);
if (plainBytes is null)
{
_logger.LogWarning("Файл сессии {TenantId} не в формате сервиса — трактуется как отсутствующий", tenantId);
return null;
}
StoredSession? stored = JsonSerializer.Deserialize<StoredSession>(plainBytes);
if (stored is null || stored.FormatVersion != StoredSession.CurrentFormatVersion)
{
_logger.LogWarning("Файл сессии {TenantId} имеет неизвестную версию формата — трактуется как отсутствующий", tenantId);
return null;
}
return stored;
}
catch (CryptographicException exception)
{
_logger.LogWarning(exception, "Сессия {TenantId} не расшифрована (повреждён файл или сменился ключ)", tenantId);
return null;
}
catch (JsonException exception)
{
_logger.LogWarning(exception, "Содержимое сессии {TenantId} не является корректным JSON", tenantId);
return null;
}
}
/// <summary>
/// Шифрует и атомарно сохраняет сессию тенанта (создаёт каталог при первом сохранении).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="session">Открытое содержимое сессии для шифрования at-rest.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public async Task SaveAsync(string tenantId, StoredSession session, CancellationToken cancellationToken = default)
{
string filePath = PathFor(tenantId);
byte[] plainBytes = JsonSerializer.SerializeToUtf8Bytes(session);
string encryptedValue = _cipher.EncryptBytes(plainBytes);
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
Directory.CreateDirectory(_sessionsDirectory);
string tempPath = filePath + ".tmp";
await File.WriteAllTextAsync(tempPath, encryptedValue, cancellationToken).ConfigureAwait(false);
File.Move(tempPath, filePath, overwrite: true);
}
finally
{
_writeLock.Release();
}
}
/// <summary>
/// Удаляет файл сессии тенанта (Logout). Отсутствие файла не считается ошибкой.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task DeleteAsync(string tenantId, CancellationToken cancellationToken = default)
{
string filePath = PathFor(tenantId);
if (File.Exists(filePath))
{
File.Delete(filePath);
}
return Task.CompletedTask;
}
/// <summary>
/// Перечисляет id тенантов, для которых на диске есть файл сессии (auto_resume на старте).
/// Каталога нет — пустой список (каталог создаётся лениво, при первом сохранении).
/// </summary>
public IEnumerable<string> ListTenantIds()
{
if (!Directory.Exists(_sessionsDirectory))
{
return [];
}
return Directory
.EnumerateFiles(_sessionsDirectory, "*" + SessionFileExtension)
.Select(Path.GetFileNameWithoutExtension)
.Where(fileName => fileName is not null)
.Cast<string>()
.ToArray();
}
// Путь файла сессии тенанта (валидация tenant-id: только безопасное имя файла).
// tenantId: Id тенанта.
// Исключение SessionException: Tenant-id пуст или содержит недопустимые для имени файла символы.
private string PathFor(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId) || tenantId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidTenantId);
}
return Path.Combine(_sessionsDirectory, tenantId + SessionFileExtension);
}
}
@@ -0,0 +1,38 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Открытое содержимое файла сессии тенанта (план Task 9; Ruling 3).
///
/// Файл data/sessions/&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;
/// <summary>
/// Версия формата; при несовпадении файл считается нечитаемым.
/// </summary>
public int FormatVersion { get; init; } = CurrentFormatVersion;
/// <summary>
/// api_id приложения Telegram, под которым авторизована сессия.
/// </summary>
public int ApiId { get; init; }
/// <summary>
/// api_hash приложения Telegram, под которым авторизована сессия.
/// </summary>
public string ApiHash { get; init; } = string.Empty;
/// <summary>
/// Байты файла сессии WTelegramClient (внутренне уже зашифрованы библиотекой).
/// </summary>
public byte[] SessionBytes { get; init; } = [];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Снимок состояния сессии тенанта для GetStatus/ответов RPC подключения (план Task 9).
/// Live-поля GetStatusReply: phase/connected/listener/account/error/qr_url (статус прототипа L110–118);
/// qrUrl заполнен только при phase == Qr (как в прототипе L118), monitored/keysSet ядро считает само.
/// </summary>
public sealed record TenantSessionSnapshot
{
/// <summary>
/// Создаёт снимок состояния сессии.
/// </summary>
/// <param name="phase">Текущая фаза входа.</param>
/// <param name="connected">Клиент Telegram соединён.</param>
/// <param name="listener">Жив ли realtime-listener (включается задачами каталога, Task 10).</param>
/// <param name="account">Аккаунт "@username" авторизованного пользователя (иначе null).</param>
/// <param name="error">Текст последней ошибки (иначе null).</param>
/// <param name="qrUrl">URL QR-входа (только при phase == Qr; иначе null).</param>
public TenantSessionSnapshot(
AuthPhase phase,
bool connected,
bool listener,
string? account,
string? error,
string? qrUrl)
{
Phase = phase;
Connected = connected;
Listener = listener;
Account = account;
Error = error;
QrUrl = qrUrl;
}
/// <summary>
/// Фаза входа (idle|phone|code|password|qr|ready).
/// </summary>
public AuthPhase Phase { get; }
/// <summary>
/// Клиент Telegram соединён (bool connected статуса прототипа).
/// </summary>
public bool Connected { get; }
/// <summary>
/// Realtime-listener жив (заполняется с Task 10; в задаче сессий — false).
/// </summary>
public bool Listener { get; }
/// <summary>
/// Аккаунт "@username" (для справки; источник истины — KV tgAccount ядра).
/// </summary>
public string? Account { get; }
/// <summary>
/// Текст последней ошибки входа/соединения (null — ошибки нет).
/// </summary>
public string? Error { get; }
/// <summary>
/// URL QR-входа (заполнен только при phase == Qr).
/// </summary>
public string? QrUrl { get; }
}
@@ -0,0 +1,123 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Конфигурация хранения сессий telegram-service (план Task 9 L316–318; Ruling 3/12/13).
///
/// Два источника — только env и корень хоста:
/// * DEAL_TELEGRAM_SESSION_KEY — ключ AES-256-GCM обёртки файлов сессий (32 байта, base64);
/// * DEAL_TELEGRAM_SESSION_DIR — каталог файлов сессий (volume /data/sessions в compose,
/// Ruling 12); по умолчанию data/sessions относительно ContentRoot.
/// Ключ — только env (Ruling 13: ключи/секреты не логируются и не читаются из appsettings).
/// </summary>
public sealed class TgOptions
{
/// <summary>
/// Env-ключ ключа шифрования сессий (32 байта base64; Ruling 3).
/// </summary>
public const string SessionKeyEnvVarName = "DEAL_TELEGRAM_SESSION_KEY";
/// <summary>
/// Env-ключ каталога сессий (опционально; по умолчанию data/sessions под ContentRoot).
/// </summary>
public const string SessionDirEnvVarName = "DEAL_TELEGRAM_SESSION_DIR";
/// <summary>
/// Относительный каталог сессий по умолчанию (под ContentRoot хоста).
/// </summary>
public const string DefaultSessionDirRelative = "data/sessions";
/// <summary>
/// Размер ключа AES-256 (байт).
/// </summary>
public const int KeySizeBytes = 32;
private TgOptions(byte[] sessionKey, string sessionsDirectory)
{
SessionKey = sessionKey;
SessionsDirectory = sessionsDirectory;
}
/// <summary>
/// Ключ AES-256-GCM обёртки файлов сессий (из env DEAL_TELEGRAM_SESSION_KEY).
/// </summary>
public byte[] SessionKey { get; }
/// <summary>
/// Абсолютный путь к каталогу файлов сессий data/sessions/&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>
public static TgOptions Create(byte[] sessionKey, string sessionsDirectory)
{
if (sessionKey is null || sessionKey.Length != KeySizeBytes)
{
throw new ArgumentException($"Ключ AES-256 должен быть длиной {KeySizeBytes} байта; получено {sessionKey?.Length ?? 0}.", nameof(sessionKey));
}
if (string.IsNullOrWhiteSpace(sessionsDirectory))
{
throw new ArgumentException("Каталог сессий не задан.", nameof(sessionsDirectory));
}
return new TgOptions(sessionKey, sessionsDirectory);
}
/// <summary>
/// Читает конфигурацию из env. Отсутствующий/некорректный DEAL_TELEGRAM_SESSION_KEY —
/// ошибка конфигурации (fail-closed: файлы сессий не могут храниться в открытом виде).
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
/// <param name="environment">Окружение хоста (ContentRootPath для каталога по умолчанию).</param>
/// <returns>Опции хранения сессий.</returns>
/// <exception cref="InvalidOperationException">Ключ отсутствует или не является base64-представлением 32 байт.</exception>
public static TgOptions FromConfiguration(IConfiguration configuration, IHostEnvironment environment)
{
string? keyBase64 = configuration[SessionKeyEnvVarName];
if (string.IsNullOrWhiteSpace(keyBase64))
{
throw new InvalidOperationException($"{SessionKeyEnvVarName} не задан — без ключа сессии нельзя шифровать (fail-closed).");
}
byte[] sessionKey;
try
{
sessionKey = Convert.FromBase64String(keyBase64);
}
catch (FormatException exception)
{
throw new InvalidOperationException($"{SessionKeyEnvVarName} не является корректным base64.", exception);
}
if (sessionKey.Length != KeySizeBytes)
{
throw new InvalidOperationException(
$"{SessionKeyEnvVarName} должен быть base64-представлением {KeySizeBytes} байт (AES-256); получено {sessionKey.Length}.");
}
string? configuredDir = configuration[SessionDirEnvVarName];
string sessionsDirectory = ResolveSessionsDirectory(configuredDir, environment.ContentRootPath);
return new TgOptions(sessionKey, sessionsDirectory);
}
// Разрешает каталог сессий: абсолютный env-путь как есть, иначе — под ContentRoot.
// configuredDir: Значение DEAL_TELEGRAM_SESSION_DIR (может быть пустым).
// contentRootPath: ContentRoot хоста.
private static string ResolveSessionsDirectory(string? configuredDir, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredDir))
{
return Path.Combine(contentRootPath, DefaultSessionDirRelative);
}
string trimmed = configuredDir.Trim();
return Path.IsPathRooted(trimmed)
? trimmed
: Path.Combine(contentRootPath, trimmed);
}
}
@@ -0,0 +1,17 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Фабрика реальных клиентов WTelegramClient (план Task 9; Ruling 3).
///
/// Каждый вызов создаёт изолированный клиент для сессии тенанта: api_id/api_hash — из запроса
/// (их передаёт ядро из настроек tgKeys, Ruling 3), байты сессии — расшифрованная копия файла
/// data/sessions/&lt;tenant&gt;.session. Анти-бан-паузы между сетевыми операциями (Ruling 3:
/// backfill 1.5–3 с/сообщение, 3–6 с/диалог, поиск 2–4 с) добавляются на операции задач каталога
/// (Task 10–11), вход/QR пауз не требуют.
/// </summary>
public sealed class ClientFactory : ITelegramClientFactory
{
/// <inheritdoc />
public ISessionClient Create(int apiId, string apiHash, byte[]? storedSession)
=> new WTelegramSessionClient(apiId, apiHash, storedSession);
}
@@ -0,0 +1,29 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Канон типов диалогов/источников контракта (шапка src/contracts/telegram.proto: DialogEntry.kind,
/// ChannelInfo.kind — channel|group|forum|chat). Значения строковые 1:1 с proto; python-прототип хранит
/// русские «канал»/«группа»/«чат», на границе контракта используется EN-канон (task-1-report).
/// </summary>
public static class DialogKinds
{
/// <summary>
/// Канал (broadcast): kind "channel".
/// </summary>
public const string Channel = "channel";
/// <summary>
/// Группа (базовая или супергруппа без тем): kind "group".
/// </summary>
public const string Group = "group";
/// <summary>
/// Супергруппа с темами (форум): kind "forum".
/// </summary>
public const string Forum = "forum";
/// <summary>
/// Личный чат (пользователь): kind "chat".
/// </summary>
public const string Chat = "chat";
}
@@ -0,0 +1,54 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Сообщение выборки discovery-read для оценки кандидата в нейтральном для TL-слоя виде (план
/// Task 11; 1:1 _discovery_message_item python-прототипа telegram.py L803–816).
///
/// В отличие от <see cref="TelegramMessage"/> (поток каталога) здесь не нужны канальные поля — выборка
/// идёт «внутри» уже известного диалога, а темы форума помечаются topic_id/topic_title (для обычных
/// источников оба пусты). Только непустые тексты: пустые/media/service отбрасывает TL-слой, как python.
/// </summary>
public sealed record DiscoveryMessage
{
/// <summary>
/// Создаёт сообщение выборки discovery-read.
/// </summary>
/// <param name="id">Id сообщения в Telegram.</param>
/// <param name="text">Текст сообщения (непустой).</param>
/// <param name="dateMs">Время сообщения, epoch-ms.</param>
/// <param name="topicId">Id темы форума (для обычных источников пуст).</param>
/// <param name="topicTitle">Название темы форума (для обычных источников пусто).</param>
public DiscoveryMessage(long id, string text, long dateMs, long? topicId, string? topicTitle)
{
Id = id;
Text = text;
DateMs = dateMs;
TopicId = topicId;
TopicTitle = topicTitle;
}
/// <summary>
/// Id сообщения в Telegram.
/// </summary>
public long Id { get; }
/// <summary>
/// Текст сообщения (непустой).
/// </summary>
public string Text { get; }
/// <summary>
/// Время сообщения, epoch-ms.
/// </summary>
public long DateMs { get; }
/// <summary>
/// Id темы форума (для обычных источников пуст).
/// </summary>
public long? TopicId { get; }
/// <summary>
/// Название темы форума (для обычных источников пусто).
/// </summary>
public string? TopicTitle { get; }
}
@@ -0,0 +1,55 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Результат чтения выборки источника для оценки кандидата (план Task 11; 1:1 discovery_read
/// python-прототипа telegram.py L718–760: {ok, error, messages}).
///
/// ok=false — история недоступна (приватный/закрытый источник без членства), error="no_history";
/// это НЕ ошибка сессии/RPC, а нормальный ответ контракта (ReadForEvalReply.ok=false).
/// </summary>
public sealed record DiscoveryReadResult
{
/// <summary>
/// Код причины ok=false: история недоступна без членства (1:1 прототип L726).
/// </summary>
public const string NoHistoryError = "no_history";
/// <summary>
/// Пустой успешный результат (limit ≤ 0 — выборка не запрашивалась, прототип L730–732).
/// </summary>
public static DiscoveryReadResult Empty { get; } = new(true, null, []);
/// <summary>
/// Создаёт результат чтения выборки.
/// </summary>
/// <param name="ok">True — выборка получена; false — история недоступна.</param>
/// <param name="error">Код причины при ok=false ("no_history"); иначе null.</param>
/// <param name="messages">Сообщения выборки (форумы — по темам, topic_id/topic_title заполнены).</param>
public DiscoveryReadResult(bool ok, string? error, IReadOnlyList<DiscoveryMessage> messages)
{
Ok = ok;
Error = error;
Messages = messages;
}
/// <summary>
/// Создаёт результат «история недоступна» (ok=false, error=no_history, сообщений нет).
/// </summary>
public static DiscoveryReadResult NoHistory()
=> new(false, NoHistoryError, []);
/// <summary>
/// True — выборка получена; false — история недоступна без членства.
/// </summary>
public bool Ok { get; }
/// <summary>
/// Код причины при ok=false: "no_history"; иначе null.
/// </summary>
public string? Error { get; }
/// <summary>
/// Сообщения выборки (для обычных источников topic_id/topic_title пусты).
/// </summary>
public IReadOnlyList<DiscoveryMessage> Messages { get; }
}
@@ -0,0 +1,193 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Абстракция клиента Telegram для сессии тенанта (план Task 9: «абстракция ISessionClient»; план
/// Task 10: операции каталога/мониторинга на том же seam).
///
/// Это seam между логикой фаз/состояния (TenantSession, SessionFarm) и WTelegramClient:
/// реальная реализация (WTelegramSessionClient) говорит с сетью Telegram, фейки в тестах —
/// нет. Контракт повторяет шаги веб-входа прототипа (start_phone/submit_code/submit_password/
/// qr_start L134–312): код запрашивается по номеру, код/пароль отправляются отдельными вызовами,
/// QR-вход выполняется в фоне до авторизации с обновлением URL через колбэк.
/// Сетевые ошибки и ошибки домена реализация переводит в <see cref="Sessions.SessionException"/>.
/// </summary>
public interface ISessionClient : IAsyncDisposable
{
/// <summary>
/// Авторизован ли клиент (в сессии есть пользователь Telegram).
/// </summary>
public bool IsAuthorized { get; }
/// <summary>
/// Есть ли активное соединение с Telegram.
/// </summary>
public bool IsConnected { get; }
/// <summary>
/// api_id приложения, под которым создан клиент (для сохранения в файл сессии).
/// </summary>
public int ApiId { get; }
/// <summary>
/// api_hash приложения, под которым создан клиент.
/// </summary>
public string ApiHash { get; }
/// <summary>
/// Последние байты сессии WTelegramClient (обновляются библиотекой в момент сохранения сессии).
/// Хранилище шифрует их в файл data/sessions/&lt;tenant&gt;.session (Ruling 3).
/// </summary>
public byte[]? SessionBytes { get; }
/// <summary>
/// Устанавливает соединение с Telegram (идемпотентно для уже соединённого клиента).
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public Task ConnectAsync(CancellationToken cancellationToken);
/// <summary>
/// Запрашивает SMS-код для номера (start_phone L134–147). После успеха клиент готов принять код.
/// Ошибки: нет соединения/недоступен Telegram, некорректный номер.
/// </summary>
/// <param name="phone">Номер в международном формате (как ввёл пользователь).</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task RequestCodeAsync(string phone, CancellationToken cancellationToken);
/// <summary>
/// Отправляет SMS-код (submit_code L149–166). Возвращает следующий запрашиваемый шаг:
/// "password" — включён 2FA, нужен облачный пароль; null — авторизация завершена (готово).
/// Ошибки: «Неверный код», «Код истёк — запросите новый» (INVALID_ARGUMENT).
/// </summary>
/// <param name="code">Код из SMS/Telegram-сообщения.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>"password" при необходимости 2FA, иначе null.</returns>
public Task<string?> SubmitCodeAsync(string code, CancellationToken cancellationToken);
/// <summary>
/// Отправляет облачный пароль 2FA (submit_password L168–176). Возвращается после успешной
/// авторизации. Ошибка: «Неверный облачный пароль» (INVALID_ARGUMENT).
/// </summary>
/// <param name="password">Облачный пароль.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken);
/// <summary>
/// Выполняет QR-вход (qr_start L286–300): метод возвращается после авторизации; новые URL
/// (в т.ч. после истечения токена) приходят через <paramref name="onQrUrl"/> до завершения.
/// Отмена токена прерывает ожидание сканирования.
/// </summary>
/// <param name="onQrUrl">Колбэк нового URL QR-входа (tg://login?token=...).</param>
/// <param name="cancellationToken">Отмена операции (прерывает ожидание сканирования).</param>
public Task StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken);
/// <summary>
/// Полный выход: отзывает авторизацию на стороне Telegram (Auth_LogOut).
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public Task LogOutAsync(CancellationToken cancellationToken);
/// <summary>
/// Возвращает строку аккаунта для статуса: "@username" авторизованного пользователя либо
/// "@user", если username не задан (1:1 _finalize L180: f"@{me.username or 'user'}").
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<string> GetAccountAsync(CancellationToken cancellationToken);
// --- Каталог и сообщения (план Task 10; Ruling 3/7; нейтральные типы Telegram/*) ---
/// <summary>
/// Список диалогов аккаунта (refresh_dialogs L505–519 / iter_dialogs). Возвращает диалоги от
/// свежих к старым (как список Telegram), верхняя граница <paramref name="limit"/>.
/// Ошибки сети/Telegram — <see cref="Sessions.SessionException"/>.
/// </summary>
/// <param name="limit">Верхняя граница числа диалогов (прототип: 500).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Диалоги аккаунта в нейтральном виде.</returns>
public Task<IReadOnlyList<TelegramDialog>> GetDialogsAsync(int limit, CancellationToken cancellationToken);
/// <summary>
/// Последние сообщения диалога (get_messages прототипа L371/L435/L590): от новых к старым,
/// только непустые тексты (пустые/media/service отбрасывает реализация — как python).
/// Ошибки сети/неизвестный источник — <see cref="Sessions.SessionException"/>.
/// </summary>
/// <param name="dialogId">Подписанный id диалога (каналы "-100…", группы "-…", личные "+…").</param>
/// <param name="limit">Сколько последних сообщений запросить.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Сообщения диалога (с канальными полями для PushMessage).</returns>
public Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(string dialogId, int limit, CancellationToken cancellationToken);
/// <summary>
/// Помечает весь диалог прочитанным (send_read_acknowledge прототипа L277/L383/L454/L609).
/// Ошибки сети/неизвестный источник — <see cref="Sessions.SessionException"/>.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Задача завершения.</returns>
public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken);
/// <summary>
/// Событие входящего текстового сообщения аккаунта (realtime; events.NewMessage прототипа
/// L255–283). Реализация поднимает событие для всех входящих сообщений с непустым текстом;
/// фильтр по зеркалу мониторинга делает служба каталога (Ruling 7). Подписчики исполняются
/// последовательно; исключение подписчика не роняет остальных и realtime-цикл.
/// </summary>
public event Func<TelegramMessage, Task>? MessageReceived;
// --- Discovery (план Task 11; discovery_search/info/read/join/leave L622–873; Ruling 3/7) ---
/// <summary>
/// Глобальный поиск каналов/групп по ключу (contacts.search прототипа L624–664). Возвращает
/// сущности результата (чаты и пользователи) нейтральными записями от chats к users; id подписанные.
/// Личные чаты/ботов (kind=chat) отсеивает ядро (Ruling 10). Пауза анти-бана 2–4 с после поиска —
/// уровень службы (DiscoveryOps), не клиента. Ошибки — <see cref="Sessions.SessionException"/>.
/// </summary>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
/// <param name="limit">Верхняя граница результата (прототип: default 30).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Найденные источники в нейтральном виде (каналы/группы/личные).</returns>
public Task<IReadOnlyList<TelegramDialog>> SearchAsync(string query, int limit, CancellationToken cancellationToken);
/// <summary>
/// Инфо об источнике для оценки кандидата (discovery_info L666–716): имя/username/kind + участники
/// из полного чата (GetFullChannel/GetFullChat) и признак форума. Сбои определения не бросаются —
/// возвращается <see cref="TelegramSourceInfo"/> с тем, что удалось получить (прототип наружу
/// исключения не выпускает: participants пуст, недоступная сущность — поля по умолчанию).
/// </summary>
/// <param name="dialogId">Подписанный id источника («-100…»/«-…»/«+…»).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Инфо об источнике (по умолчанию — только id и name=id).</returns>
public Task<TelegramSourceInfo> GetInfoAsync(string dialogId, CancellationToken cancellationToken);
/// <summary>
/// Последние сообщения источника для оценки кандидата (discovery_read L718–800): форумы читаются
/// по активным темам (GetForumTopics + по каждой теме getReplies), обычные источники — лентой;
/// темы помечаются topic_id/topic_title. История недоступна (приватный/закрытый источник) —
/// результат ok=false/error="no_history" (это НЕ ошибка сессии, прототип L752–753). Только непустые
/// тексты. Ошибка тем форума — безопасный фолбэк на обычную ленту (прототип L743–748).
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="limit">Размер выборки (прототип: limit сообщений/тем; limit ≤ 0 — пусто без сети).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Результат чтения выборки (ok + сообщения либо no_history).</returns>
public Task<DiscoveryReadResult> ReadForEvalAsync(string dialogId, int limit, CancellationToken cancellationToken);
/// <summary>
/// Вступить в канал/группу по username (discovery_join L818–839; ручной join вне квот — паузу перед
/// авто-join делает воркер ядра, Ruling 10). Username нормализует уровень службы (DiscoveryOps).
/// FloodWait Telegram → SessionException RESOURCE_EXHAUSTED (detail с префиксом "flood", контракт
/// telegram.proto; базовый флуд-гард — здесь, суточный стоп — в ядре).
/// </summary>
/// <param name="username">Username канала/группы (без «@»).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Задача завершения (ok=true при успехе).</returns>
public Task JoinAsync(string username, CancellationToken cancellationToken);
/// <summary>
/// Выйти из канала/группы (discovery_leave L841–848). Неизвестный/недоступный источник —
/// SessionException (уровень контракта Leave: нет диалога/членства).
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Задача завершения (ok=true при успехе).</returns>
public Task LeaveAsync(string dialogId, CancellationToken cancellationToken);
}
@@ -0,0 +1,23 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Фабрика клиентов Telegram для сессий тенантов (план Task 9, Telegram/ClientFactory.cs).
///
/// Создаёт <see cref="ISessionClient"/> для сессии тенанта по ключам приложения и байтам сохранённой
/// сессии (или пустой — новый вход). Интерфейс — seam для тестов: тесты регистрируют фейковую
/// фабрику, реальный <see cref="ClientFactory"/> сеть Telegram не трогает до первого вызова.
/// </summary>
public interface ITelegramClientFactory
{
/// <summary>
/// Создаёт клиент Telegram для сессии тенанта.
/// </summary>
/// <param name="apiId">api_id приложения Telegram (настройка tgKeys тенанта, из тела запроса).</param>
/// <param name="apiHash">api_hash приложения Telegram.</param>
/// <param name="storedSession">
/// Байты сохранённой сессии WTelegramClient (расшифрованная копия файла data/sessions/&lt;tenant&gt;.session)
/// либо null — новая сессия (нет файла или ключи приложения сменились).
/// </param>
/// <returns>Клиент, готовый к ConnectAsync/логину.</returns>
public ISessionClient Create(int apiId, string apiHash, byte[]? storedSession);
}
@@ -0,0 +1,62 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Диалог (источник) аккаунта в нейтральном для TL-слоя виде (план Task 10; Ruling 3/7).
///
/// Это результат «списка диалогов» сессии (refresh_dialogs прототипа L505–519): id в подписанном
/// каноне контракта («-100…» каналы, «-…» группы, «+…» личные), отображаемое имя, username, тип
/// канона channel|group|forum|chat и счётчики для догонялки непрочитанных (realtime_sweep L427–456).
/// TL-реализация (<see cref="WTelegramSessionClient"/>) и фейки тестов возвращают именно этот тип;
/// службы каталога не зависят от библиотеки WTelegramClient.
/// </summary>
public sealed record TelegramDialog
{
/// <summary>
/// Создаёт описание диалога.
/// </summary>
/// <param name="id">Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).</param>
/// <param name="name">Отображаемое имя (title/first_name) или id, если имени нет.</param>
/// <param name="username">Username (handle); пуст, если публичного username нет.</param>
/// <param name="kind">Тип канона: channel|group|forum|chat (шапка telegram.proto).</param>
/// <param name="unreadCount">Число непрочитанных сообщений (для realtime_sweep).</param>
/// <param name="topMessageId">Id самого свежего сообщения диалога (для read-ack).</param>
public TelegramDialog(string id, string name, string username, string kind, int unreadCount, int topMessageId)
{
Id = id;
Name = name;
Username = username;
Kind = kind;
UnreadCount = unreadCount;
TopMessageId = topMessageId;
}
/// <summary>
/// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).
/// </summary>
public string Id { get; }
/// <summary>
/// Отображаемое имя (title/first_name) или id, если имени нет.
/// </summary>
public string Name { get; }
/// <summary>
/// Username (handle); пуст, если публичного username нет.
/// </summary>
public string Username { get; }
/// <summary>
/// Тип канона контракта: channel|group|forum|chat.
/// </summary>
public string Kind { get; }
/// <summary>
/// Число непрочитанных сообщений диалога (для догона realtime_sweep).
/// </summary>
public int UnreadCount { get; }
/// <summary>
/// Id самого свежего сообщения диалога.
/// </summary>
public int TopMessageId { get; }
}
@@ -0,0 +1,61 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Текстовое сообщение диалога в нейтральном для TL-слоя виде (план Task 10; Ruling 3/7).
///
/// Используется тремя путями сообщений: backfill («Перечитать» L349–390), realtime-listener
/// (L255–283) и realtime_sweep (L392–456). Пустые тексты и служебные сообщения (media/service)
/// TL-слой не отдаёт — только непустой текст. Канальные поля (имя/username) нужны для PushMessage
/// в ядро (PushMessageRequest.channel_name/channel_handle, Ruling 7): hue считает служба каталога.
/// </summary>
public sealed record TelegramMessage
{
/// <summary>
/// Создаёт сообщение диалога.
/// </summary>
/// <param name="dialogId">Подписанный id диалога-источника («-100…»/«-…»/«+…»).</param>
/// <param name="id">Id сообщения в Telegram (дубль-гвард dialog+msgId ядра).</param>
/// <param name="text">Текст сообщения (непустой).</param>
/// <param name="dateMs">Время сообщения, epoch-ms.</param>
/// <param name="dialogName">Имя диалога (title/first_name) для PushMessage.channel_name.</param>
/// <param name="dialogHandle">Username диалога для PushMessage.channel_handle.</param>
public TelegramMessage(string dialogId, int id, string text, long dateMs, string dialogName, string dialogHandle)
{
DialogId = dialogId;
Id = id;
Text = text;
DateMs = dateMs;
DialogName = dialogName;
DialogHandle = dialogHandle;
}
/// <summary>
/// Подписанный id диалога-источника.
/// </summary>
public string DialogId { get; }
/// <summary>
/// Id сообщения в Telegram (дубль-гвард диалога ядра).
/// </summary>
public int Id { get; }
/// <summary>
/// Текст сообщения (непустой).
/// </summary>
public string Text { get; }
/// <summary>
/// Время сообщения, epoch-ms.
/// </summary>
public long DateMs { get; }
/// <summary>
/// Имя диалога (title/first_name) — для PushMessage.channel_name.
/// </summary>
public string DialogName { get; }
/// <summary>
/// Username диалога (пуст, если нет) — для PushMessage.channel_handle.
/// </summary>
public string DialogHandle { get; }
}
@@ -0,0 +1,61 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Инфо об источнике для оценки кандидата discovery в нейтральном для TL-слоя виде (план Task 11;
/// 1:1 результат discovery_info python-прототипа telegram.py L666–716).
///
/// Поля повторяют словарь прототипа {id, name, username, kind, participants, is_forum}; hue прототип
/// не хранит — его считает маппер ответа (Ruling 7: цвет считает сервис). kind — EN-канон контракта
/// channel|group|forum|chat; пустая строка — тип определить не удалось (прототип L678: kind "").
/// </summary>
public sealed record TelegramSourceInfo
{
/// <summary>
/// Создаёт инфо об источнике.
/// </summary>
/// <param name="id">Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).</param>
/// <param name="name">Отображаемое имя (title/first_name) или id, если имени нет.</param>
/// <param name="username">Username (handle); пуст, если публичного username нет.</param>
/// <param name="kind">Тип канона контракта: channel|group|forum|chat (пусто — не определён).</param>
/// <param name="participants">Число участников (full_chat); null — определить не удалось.</param>
/// <param name="isForum">True — мегагруппа с темами (форум; core трактует kind как forum).</param>
public TelegramSourceInfo(string id, string name, string username, string kind, int? participants, bool isForum)
{
Id = id;
Name = name;
Username = username;
Kind = kind;
Participants = participants;
IsForum = isForum;
}
/// <summary>
/// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).
/// </summary>
public string Id { get; }
/// <summary>
/// Отображаемое имя (title/first_name) или id, если имени нет.
/// </summary>
public string Name { get; }
/// <summary>
/// Username (handle); пуст, если публичного username нет.
/// </summary>
public string Username { get; }
/// <summary>
/// Тип канона контракта: channel|group|forum|chat (пусто — не определён).
/// </summary>
public string Kind { get; }
/// <summary>
/// Число участников (full_chat); null — определить не удалось.
/// </summary>
public int? Participants { get; }
/// <summary>
/// True — мегагруппа с темами (форум; core трактует kind как forum, Ruling 10).
/// </summary>
public bool IsForum { get; }
}
@@ -0,0 +1,211 @@
using System.Globalization;
using Deal.Telegram.Sessions;
using Grpc.Core;
using TL;
namespace Deal.Telegram.Telegram;
/// <summary>
/// Чистый маппер TL-объектов каталога/сообщений в нейтральные типы сервиса (план Task 10; Ruling 3/7).
///
/// Статический и без зависимостей от клиента — единое место разбора, используемое WTelegramSessionClient
/// (список диалогов, история, realtime-события) и unit-тестами на фейковых TL-объектах (без сети):
/// ветки типов обновлений, подписанные id, фильтр пустых/служебных/исходящих текстов.
/// </summary>
public static class TlMessageMapper
{
/// <summary>
/// Извлекает сообщение из «нового сообщения» обновления. Канон обновлений библиотеки:
/// обычные чаты — UpdateNewMessage; каналы/супергруппы — UpdateNewChannelMessage (подкласс
/// UpdateNewMessage); короткие UpdateShortMessage/UpdateShortChatMessage синтезируются в
/// UpdateNewMessage списком UpdateList; собственные исходящие (UpdateShortSentMessage) менеджер
/// обновлений не поднимает. Прочие обновления (edit/delete/…) → null (не «новое сообщение»).
/// </summary>
/// <param name="update">Одно нормализованное обновление.</param>
/// <returns>Сообщение нового входящего события или null.</returns>
public static MessageBase? NewMessageFrom(Update update)
=> update is UpdateNewMessage { message: MessageBase message } ? message : null;
/// <summary>
/// Превращает диалог списка в нейтральный <see cref="TelegramDialog"/> (null — нет сущности).
/// </summary>
/// <param name="dialog">Диалог из ответа getDialogs.</param>
/// <param name="chats">Сущности чатов контейнера (по raw id).</param>
/// <param name="users">Сущности пользователей контейнера (по raw id).</param>
public static TelegramDialog? ToDialog(DialogBase dialog, IReadOnlyDictionary<long, ChatBase> chats, IReadOnlyDictionary<long, User> users)
{
if (dialog.Peer is null)
{
return null;
}
string signedId = SignedIdOf(dialog.Peer);
(string name, string handle, string kind) = DescribePeer(signedId, FindChat(dialog.Peer, chats), FindUser(dialog.Peer, users));
int unreadCount = dialog is Dialog fullDialog ? fullDialog.unread_count : 0;
return new TelegramDialog(signedId, name, handle, kind, unreadCount, dialog.TopMessage);
}
/// <summary>
/// Превращает сообщение истории/обновления в нейтральное (null — не текст/служебное/своё исходящее).
/// Пустые тексты и media/service (MessageService/MessageEmpty) отбрасываются — как python
/// `if not m.text or not m.text.strip(): continue`; исходящие (out_) тоже (incoming-семантика).
/// </summary>
/// <param name="message">Сообщение (MessageBase).</param>
/// <param name="chats">Сущности чатов контейнера.</param>
/// <param name="users">Сущности пользователей контейнера.</param>
public static TelegramMessage? ToMessage(MessageBase message, IReadOnlyDictionary<long, ChatBase> chats, IReadOnlyDictionary<long, User> users)
{
if (message is not Message textMessage
|| string.IsNullOrWhiteSpace(textMessage.message)
|| (textMessage.flags & Message.Flags.out_) != 0
|| textMessage.Peer is null)
{
return null;
}
string signedId = SignedIdOf(textMessage.Peer);
(string name, string handle, _) = DescribePeer(signedId, FindChat(textMessage.Peer, chats), FindUser(textMessage.Peer, users));
return new TelegramMessage(signedId, textMessage.id, textMessage.message, ToEpochMs(textMessage.Date), name, handle);
}
// --- Discovery (план Task 11; contacts.search entity → TelegramDialog, сообщение выборки → DiscoveryMessage) ---
/// <summary>
/// Сущность чата/канала результата contacts.SearchRequest → запись поиска (1:1 discovery_search
/// L644–661: id подписанный, name/username из сущности, kind EN-канона; счётчики пустые — у результата
/// поиска их нет). Используется TL-слоем поиска (Search) для chats результата.
/// </summary>
/// <param name="chat">Сущность канала/группы из chats результата поиска.</param>
public static TelegramDialog ToFoundChat(ChatBase chat)
{
string signedId = SignedChatId(chat);
(string name, string handle, string kind) = DescribePeer(signedId, chat, null);
return new TelegramDialog(signedId, name, handle, kind, unreadCount: 0, topMessageId: 0);
}
/// <summary>
/// Сущность пользователя результата contacts.SearchRequest → запись поиска (личный чат/бот; core
/// отсеивает kind=chat сам — Ruling 10). Поля и id — как у <see cref="ToFoundChat"/>.
/// </summary>
/// <param name="user">Сущность пользователя из users результата поиска.</param>
public static TelegramDialog ToFoundUser(User user)
{
string signedId = "+" + user.id.ToString(CultureInfo.InvariantCulture);
(string name, string handle, string kind) = DescribePeer(signedId, null, user);
return new TelegramDialog(signedId, name, handle, kind, unreadCount: 0, topMessageId: 0);
}
/// <summary>
/// Сообщение выборки discovery-read → нейтральное (1:1 _discovery_message_item L803–816): только
/// непустые тексты (пустые/media/service отбрасываются); темы форума помечены topicId/topicTitle.
/// </summary>
/// <param name="message">Сообщение ленты/темы форума (MessageBase).</param>
/// <param name="topicId">Id темы форума (для обычных источников null).</param>
/// <param name="topicTitle">Название темы форума (для обычных источников null).</param>
/// <param name="now">Текущее время (фолбэк даты сообщения без времени, как python `now`).</param>
public static DiscoveryMessage? ToEvalMessage(MessageBase message, long? topicId, string? topicTitle, DateTimeOffset now)
{
if (message is not Message textMessage
|| string.IsNullOrWhiteSpace(textMessage.message))
{
return null;
}
return new DiscoveryMessage(
textMessage.id,
textMessage.message,
ToEpochMsOrNow(textMessage.Date, now),
topicId,
topicTitle);
}
/// <summary>
/// Подписанный id канона контракта по peer диалога/сообщения.
/// </summary>
/// <param name="peer">Peer (PeerChannel/PeerChat/PeerUser).</param>
public static string SignedIdOf(Peer peer)
=> peer switch
{
PeerChannel channel => "-100" + channel.channel_id.ToString(CultureInfo.InvariantCulture),
PeerChat chat => "-" + chat.chat_id.ToString(CultureInfo.InvariantCulture),
PeerUser user => "+" + user.user_id.ToString(CultureInfo.InvariantCulture),
_ => throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId),
};
// Имя/username/тип диалога по его сущности (пустой сущности — имя и тип по умолчанию).
// signedId: Подписанный id диалога (фолбэк имени).
// chat: Сущность чата/канала (или null).
// user: Сущность пользователя (или null).
private static (string Name, string Handle, string Kind) DescribePeer(string signedId, ChatBase? chat, UserBase? user)
{
if (chat is not null)
{
string title = chat.Title ?? string.Empty;
return (title.Length > 0 ? title : signedId, chat.MainUsername ?? string.Empty, KindOf(chat));
}
if (user is User regularUser)
{
string name = (regularUser.first_name + " " + regularUser.last_name).Trim();
string username = regularUser.MainUsername ?? string.Empty;
if (name.Length == 0)
{
name = username.Length > 0 ? username : signedId;
}
return (name, username, DialogKinds.Chat);
}
return (signedId, string.Empty, DialogKinds.Chat);
}
/// <summary>
/// Тип диалога канона контракта по сущности чата/канала (Ruling 3, kind-маппинг task-1).
/// </summary>
/// <param name="chat">Сущность канала/группы.</param>
public static string KindOf(ChatBase chat)
=> chat switch
{
Channel channel when (channel.flags & Channel.Flags.forum) != 0 => DialogKinds.Forum,
Channel channel when (channel.flags & Channel.Flags.broadcast) != 0 => DialogKinds.Channel,
_ => DialogKinds.Group,
};
// Подписанный id чата/канала по самой сущности (каналы «-100…», группы «-…»).
// chat: Сущность канала/группы.
private static string SignedChatId(ChatBase chat)
=> chat switch
{
Channel channel => "-100" + channel.id.ToString(CultureInfo.InvariantCulture),
Chat group => "-" + group.id.ToString(CultureInfo.InvariantCulture),
_ => throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId),
};
// Дата сообщения → epoch-ms; без даты (default) — текущее время (python `date or now`).
// date: Дата сообщения.
// now: Текущее время (фолбэк).
private static long ToEpochMsOrNow(DateTime date, DateTimeOffset now)
=> date == default ? now.ToUnixTimeMilliseconds() : ToEpochMs(date);
// Ищет сущность чата/канала диалога в словаре ответа.
// peer: Peer сообщения/диалога.
// chats: Сущности чатов контейнера.
private static ChatBase? FindChat(Peer peer, IReadOnlyDictionary<long, ChatBase> chats)
=> peer switch
{
PeerChannel channel when chats.TryGetValue(channel.channel_id, out ChatBase? chat) => chat,
PeerChat chat when chats.TryGetValue(chat.chat_id, out ChatBase? group) => group,
_ => null,
};
// Ищет сущность пользователя диалога в словаре ответа.
// peer: Peer сообщения/диалога.
// users: Сущности пользователей контейнера.
private static UserBase? FindUser(Peer peer, IReadOnlyDictionary<long, User> users)
=> peer is PeerUser user && users.TryGetValue(user.user_id, out User? regularUser) ? regularUser : null;
// DateTime (UTC от Telegram) → epoch-ms (1:1 int(date.timestamp()*1000)).
// date: Время сообщения.
private static long ToEpochMs(DateTime date)
=> new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)).ToUnixTimeMilliseconds();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
using Deal.Grpc.Hosting;
using Deal.Telegram.Core;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Discovery;
using Deal.Telegram.Hosting;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram;
/// <summary>
/// Собирает WebApplication gRPC-хоста telegram-service (план Task 2/9/10; L227–238 + задачи сессий и
/// диалогов/мониторинга).
///
/// Продакшн-точка входа вызывает <see cref="Create"/> из Program.cs (порт из env GRPC_PORT/PORT);
/// интеграционные тесты (Deal.Telegram.Tests) — из своего процесса на эфемерном порту, поэтому
/// конфигурация хоста живёт здесь один раз и не дублируется в тестах.
/// Транспорт/AddGrpc/health — общая серверная обвязка <see cref="GrpcServer"/> (Deal.Grpc.Hosting,
/// C31): mTLS (env DEAL_MTLS_*, Ruling 6/Task 13), Kestrel HTTP/2, интерцепторы service-token и
/// access-лога, gRPC-health; здесь — только регистрации логики telegram-service.
/// Регистрации задачи сессий: хранение (TgOptions/SessionFileCipher/SessionStore), ферма сессий
/// (SessionFarm + ClientFactory), фоновый цикл auto_resume/heartbeat (SessionHeartbeatService);
/// обязательный env DEAL_TELEGRAM_SESSION_KEY проверяется при сборке хоста (fail-closed, Ruling 13).
/// Регистрации задачи диалогов/мониторинга (Task 10, Ruling 7): DialogCatalog, исходящий канал в ядро
/// (ICoreIngressClient/CoreIngressClient — SERVICES__CORE__INGRESS), BackfillService с анти-бан-
/// пейсером и фоновые циклы RealtimeSweepService/RealtimeMonitorService.
/// </summary>
public static class TelegramServiceHost
{
/// <summary>
/// Создаёт (не запускает) хост: общая обвязка GrpcServer (Kestrel HTTP/2 на 0.0.0.0:grpcPort,
/// dev — plaintext + service-token, Ruling 2; при DEAL_MTLS_ENABLED=1 — HTTPS с серверным
/// сертификатом и требованием клиентского, Ruling 6/Task 13), затем регистрации сессий и
/// диалогов/мониторинга и маппинг <see cref="TelegramServiceImpl"/>.
/// </summary>
/// <param name="grpcPort">TCP-порт Kestrel.</param>
/// <param name="args">Аргументы командной строки (Program.cs); в тестах не нужны.</param>
/// <param name="configureServices">
/// Опциональный хук DI для тестов (подмена зависимостей фейками, напр. ITelegramClientFactory).
/// </param>
/// <param name="configureBuilder">
/// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog
/// (DealLogging.Configure, Ruling 7/Task 14). Тесты хост поднимают БЕЗ этого хука — логирование
/// файлов/консоли тестам не нужно.
/// </param>
/// <returns>Собранный хост; запуск — StartAsync/RunAsync у вызывающего.</returns>
public static WebApplication Create(
int grpcPort,
string[]? args = null,
Action<IServiceCollection>? configureServices = null,
Action<WebApplicationBuilder>? configureBuilder = null)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []);
// Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка
// сертификатов сразу с fail-fast (compose-prod монтирует deploy/certs, scripts/mtls-certs.sh);
// Kestrel HTTP/2 (dev — plaintext + обязательный service-token, Ruling 2); AddGrpc
// (access-лог первым, затем service-token, потолок сообщения) и gRPC-health (Ruling 12).
// Один экземпляр mtlsCertificates используют и Kestrel ниже, и исходящий канал в ядро
// (CoreIngressClient).
MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
builder.Services.AddDealGrpcServer();
builder.Services.AddReadyHealthCheck("хост telegram-service готов");
// Сессии тенантов (задача «сессии и QR-подключение», план Task 9; Ruling 3): хранилище
// файлов data/sessions/<tenant>.session (AES-GCM, ключ из env), пул 1 аккаунт/тенант и
// фоновый цикл auto_resume/heartbeat (30 с). DEAL_TELEGRAM_SESSION_KEY обязателен —
// иначе хост не стартует (сессии не могут храниться в открытом виде).
TgOptions sessionOptions = TgOptions.FromConfiguration(builder.Configuration, builder.Environment);
builder.Services.AddSingleton(sessionOptions);
builder.Services.AddSingleton<SessionFileCipher>();
builder.Services.AddSingleton<SessionStore>();
builder.Services.AddSingleton<ITelegramClientFactory, ClientFactory>();
builder.Services.AddSingleton<SessionFarm>();
builder.Services.AddHostedService<SessionHeartbeatService>();
/// Диалоги и мониторинг (задача «диалоги/backfill/мониторинг», план Task 10; Ruling 7): зеркало
/// каталога/мониторинга (DialogCatalog), исходящий канал в ядро (CoreIngressClient — адрес
/// SERVICES__CORE__INGRESS, Ruling 12), backfill с анти-бан-паузами и фоновые циклы:
/// догон непрочитанных (RealtimeSweepService, 30 с) и reconcile realtime-listener'ов
/// (RealtimeMonitorService). Discovery-операции (план Task 11): DiscoveryOps поверх SessionFarm
/// и общего анти-бан-пейсера (поиск 2–4 с, Ruling 3). configureServices (тесты) может подменить
/// ICoreIngressClient/пейсер.
CoreIngressOptions ingressOptions = CoreIngressOptions.FromConfiguration(builder.Configuration);
builder.Services.AddSingleton(ingressOptions);
builder.Services.AddSingleton<ICoreIngressClient>(provider => new CoreIngressClient(
ingressOptions,
provider.GetRequiredService<ILogger<CoreIngressClient>>(),
mtlsCertificates));
builder.Services.AddSingleton<DialogCatalog>();
builder.Services.AddSingleton<IBackfillPacer, RandomBackfillPacer>();
builder.Services.AddSingleton<BackfillService>();
builder.Services.AddSingleton<DiscoveryOps>();
builder.Services.AddSingleton<RealtimeSweep>();
builder.Services.AddHostedService<RealtimeSweepService>();
builder.Services.AddHostedService<RealtimeMonitorService>();
configureServices?.Invoke(builder.Services);
configureBuilder?.Invoke(builder);
WebApplication app = builder.Build();
app.MapGrpcService<TelegramServiceImpl>();
app.MapGrpcHealthChecksService();
return app;
}
}
@@ -0,0 +1,551 @@
using System.Net.Http;
using Deal.Grpc.Telegram;
using Deal.Telegram.Core;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Discovery;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
using Grpc.Core;
namespace Deal.Telegram;
/// <summary>
/// Реализация серверной стороны Deal.Grpc.Telegram.TelegramService — команды core →
/// telegram-service (telegram.proto, контракты Task 1; Ruling 1/7).
///
/// Реализованы RPC подключения аккаунта (задача «сессии и QR-подключение», план Task 9, Ruling 3):
/// GetStatus/StartPhone/StartQr/SendCode/SendPassword/Logout поверх <see cref="SessionFarm"/>
/// (1 аккаунт на тенанта; tenantId только из metadata, полю не доверяем — Ruling 1). Доменные ошибки
/// (SessionException) переводятся в RPC-статусы контракта (detail = текст 1:1, шапка telegram.proto).
/// RPC каталога/мониторинга (план Task 10, Ruling 7): RefreshDialogs/SetMonitor/SetMonitorAll/Backfill/
/// ReadRecent поверх SessionFarm + DialogCatalog (зеркало мониторинга) + BackfillService.
/// RPC discovery (план Task 11): Search/GetInfo/ReadForEval/Join/Leave поверх SessionFarm через
/// DiscoveryOps (поиск с анти-бан-паузой 2–4 с, форумы по темам, join по username вне квот — паузу перед
/// авто-join делает воркер ядра, Ruling 10). IngressService здесь сервером не выставляется (его сервер —
/// Deal.Api, Ruling 7; сервис — клиент через CoreIngressClient).
/// </summary>
public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
{
/// <summary>
/// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1).
/// </summary>
public const string TenantIdMetadataKey = "tenant-id";
// Верхняя граница списка диалогов refresh (как refresh_dialogs L510: limit=500).
private const int DialogListLimit = 500;
// Лимит превью по умолчанию, если core не передал (dialog_messages прототипа, limit=24).
private const int PreviewDefaultLimit = 24;
// Максимальный лимит превью (контракт ReadRecentRequest: 1..50).
private const int PreviewMaxLimit = 50;
// Верхняя граница поискового запроса (ключ discovery; защита границы сервиса).
private const int MaxSearchQueryLength = 200;
// Верхняя граница username вступления (лимит Telegram: 5..32 символа).
private const int MaxUsernameLength = 32;
private readonly SessionFarm _sessionFarm;
private readonly DialogCatalog _catalog;
private readonly ICoreIngressClient _ingress;
private readonly BackfillService _backfill;
private readonly DiscoveryOps _discovery;
private readonly ILogger<TelegramServiceImpl> _logger;
/// <summary>
/// Создаёт сервис команд core.
/// </summary>
/// <param name="sessionFarm">Пул сессий тенантов.</param>
/// <param name="catalog">Зеркало каталога/мониторинга диалогов.</param>
/// <param name="ingress">Исходящий канал в ядро (SyncDialogs — актуализация зеркала).</param>
/// <param name="backfill">Backfill последних сообщений диалога.</param>
/// <param name="discovery">Discovery-операции (поиск/инфо/чтение/join/leave).</param>
/// <param name="logger">Логгер.</param>
public TelegramServiceImpl(
SessionFarm sessionFarm,
DialogCatalog catalog,
ICoreIngressClient ingress,
BackfillService backfill,
DiscoveryOps discovery,
ILogger<TelegramServiceImpl> logger)
{
_sessionFarm = sessionFarm;
_catalog = catalog;
_ingress = ingress;
_backfill = backfill;
_discovery = discovery;
_logger = logger;
}
// --- Подключение аккаунта и статус (Ruling 3, WTelegramClient) ---
/// <summary>
/// GetStatus — статус и фаза входа аккаунта тенанта (status L103–119).
/// </summary>
public override async Task<GetStatusReply> GetStatus(GetStatusRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
TenantSessionSnapshot? snapshot = await _sessionFarm.GetStatusAsync(tenantId, context.CancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
throw ToRpc(new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected));
}
var reply = new GetStatusReply
{
Phase = PhaseToString(snapshot.Phase),
Connected = snapshot.Connected,
Listener = snapshot.Listener,
Account = snapshot.Account ?? string.Empty,
};
if (snapshot.Error is not null)
{
reply.Error = snapshot.Error;
}
if (snapshot.QrUrl is not null)
{
reply.QrUrl = snapshot.QrUrl;
}
return reply;
}
/// <summary>
/// StartPhone — запросить код по номеру телефона (start_phone L134–147).
/// </summary>
public override async Task<StartPhoneReply> StartPhone(StartPhoneRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
TenantSessionSnapshot snapshot = await ExecuteAsync(
tenantId,
ct => _sessionFarm.StartPhoneAsync(tenantId, request.ApiId, request.ApiHash, request.Phone, ct),
"start_phone",
context).ConfigureAwait(false);
return new StartPhoneReply { Phase = PhaseToString(snapshot.Phase) };
}
/// <summary>
/// StartQr — начать вход по QR (qr_start L286–300): фаза + qrUrl.
/// </summary>
public override async Task<StartQrReply> StartQr(StartQrRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
TenantSessionSnapshot snapshot = await ExecuteAsync(
tenantId,
ct => _sessionFarm.StartQrAsync(tenantId, request.ApiId, request.ApiHash, ct),
"start_qr",
context).ConfigureAwait(false);
var reply = new StartQrReply { Phase = PhaseToString(snapshot.Phase) };
if (snapshot.QrUrl is not null)
{
reply.QrUrl = snapshot.QrUrl;
}
return reply;
}
/// <summary>
/// SendCode — отправить SMS-код входа (submit_code L149–166).
/// </summary>
public override async Task<SendCodeReply> SendCode(SendCodeRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
TenantSessionSnapshot snapshot = await ExecuteAsync(
tenantId,
ct => _sessionFarm.SendCodeAsync(tenantId, request.Code, ct),
"send_code",
context).ConfigureAwait(false);
return new SendCodeReply { Phase = PhaseToString(snapshot.Phase) };
}
/// <summary>
/// SendPassword — облачный пароль 2FA (submit_password L168–176).
/// </summary>
public override async Task<SendPasswordReply> SendPassword(SendPasswordRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
TenantSessionSnapshot snapshot = await ExecuteAsync(
tenantId,
ct => _sessionFarm.SendPasswordAsync(tenantId, request.Password, ct),
"send_password",
context).ConfigureAwait(false);
return new SendPasswordReply { Phase = PhaseToString(snapshot.Phase) };
}
/// <summary>
/// Logout — отключить аккаунт, удалить сессию тенанта (disconnect L189–207).
/// </summary>
public override async Task<LogoutReply> Logout(LogoutRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
await ExecuteAsync(
tenantId,
ct => _sessionFarm.LogoutAsync(tenantId, ct),
"logout",
context).ConfigureAwait(false);
// Отключение аккаунта: зеркало мониторинга очищается (прототип L203: `_monitored.clear()`).
_catalog.Reset(tenantId);
return new LogoutReply { Ok = true };
}
// --- Каталог и мониторинг (задача Task 10; Ruling 7) ---
/// <summary>
/// RefreshDialogs — актуальный каталог диалогов аккаунта (refresh_dialogs L505–519).
/// </summary>
public override async Task<RefreshDialogsReply> RefreshDialogs(RefreshDialogsRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
RefreshDialogsReply reply = await ExecuteAsync(
tenantId,
async ct =>
{
IReadOnlyList<Telegram.TelegramDialog> dialogs = await _sessionFarm
.ListDialogsAsync(tenantId, DialogListLimit, ct)
.ConfigureAwait(false);
List<DialogEntry> entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList();
_catalog.ReplaceKnown(tenantId, dialogs.Select(dialog => dialog.Id).ToList());
// Актуализация зеркала мониторинга ответом SyncDialogs (Ruling 7). Сбой ядра не роняет
// ответ — зеркало догонит realtime_sweep следующим циклом.
await SyncCatalogSilentlyAsync(tenantId, entries, ct).ConfigureAwait(false);
var result = new RefreshDialogsReply();
result.Entries.AddRange(entries);
return result;
},
"refresh_dialogs",
context).ConfigureAwait(false);
return reply;
}
/// <summary>
/// SetMonitor — включить/выключить мониторинг диалога (set_monitor L536–546).
/// </summary>
public override Task<SetMonitorReply> SetMonitor(SetMonitorRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
RequireDialogId(request.DialogId);
_catalog.SetMonitored(tenantId, request.DialogId, request.Enabled);
_logger.LogInformation(
"Аудит: tenant {TenantId} set_monitor {DialogId} → {Enabled}",
tenantId,
request.DialogId,
request.Enabled);
return Task.FromResult(new SetMonitorReply { Ok = true, Enabled = request.Enabled });
}
/// <summary>
/// SetMonitorAll — мониторинг всех диалогов каталога (set_monitor_all L548–567).
/// </summary>
public override Task<SetMonitorAllReply> SetMonitorAll(SetMonitorAllRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
int count = _catalog.SetAllMonitored(tenantId, request.Enabled);
_logger.LogInformation(
"Аудит: tenant {TenantId} set_monitor_all → {Enabled} (каталог {Count})",
tenantId,
request.Enabled,
count);
return Task.FromResult(new SetMonitorAllReply { Ok = true, Count = count, Enabled = request.Enabled });
}
/// <summary>
/// Backfill — перечитать последние сообщения диалога в ядро (backfill L568–582/«Перечитать»).
/// </summary>
public override async Task<BackfillReply> Backfill(BackfillRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
RequireDialogId(request.DialogId);
int processed = await ExecuteAsync(
tenantId,
ct => _backfill.ExecuteAsync(tenantId, request.DialogId, request.Force, ct),
"backfill",
context).ConfigureAwait(false);
return new BackfillReply { Processed = processed };
}
/// <summary>
/// ReadRecent — последние сообщения диалога для превью (dialog_messages L583–620).
/// </summary>
public override async Task<ReadRecentReply> ReadRecent(ReadRecentRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
RequireDialogId(request.DialogId);
int limit = request.Limit <= 0 ? PreviewDefaultLimit : Math.Min(request.Limit, PreviewMaxLimit);
ReadRecentReply reply = await ExecuteAsync(
tenantId,
async ct =>
{
IReadOnlyList<Telegram.TelegramMessage> messages = await _sessionFarm
.GetMessagesAsync(tenantId, request.DialogId, limit, ct)
.ConfigureAwait(false);
var result = new ReadRecentReply();
result.Messages.AddRange(messages.Select(DialogProtoMapper.ToPreview));
// Вручную вытащили сообщения — снимаем «новое» в Telegram (прототип L607–611).
await _sessionFarm.MarkReadAsync(tenantId, request.DialogId, ct).ConfigureAwait(false);
return result;
},
"read_recent",
context).ConfigureAwait(false);
return reply;
}
// --- Discovery-операции (задача Task 11; Ruling 3/7/10) ---
/// <summary>
/// Search — глобальный поиск каналов/групп по ключу (discovery_search L624–664).
/// </summary>
public override async Task<SearchReply> Search(SearchRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
RequireSearchQueryWithinBounds(request.Query);
SearchReply reply = await ExecuteAsync(
tenantId,
async ct =>
{
IReadOnlyList<Telegram.TelegramDialog> found = await _discovery
.SearchAsync(tenantId, request.Query, request.Limit, ct)
.ConfigureAwait(false);
var result = new SearchReply();
result.Results.AddRange(found.Select(DialogProtoMapper.ToEntry));
return result;
},
"search",
context).ConfigureAwait(false);
return reply;
}
/// <summary>
/// GetInfo — инфо об источнике для оценки кандидата (discovery_info L666–716).
/// </summary>
public override async Task<GetInfoReply> GetInfo(GetInfoRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
RequireDialogId(request.DialogId);
GetInfoReply reply = await ExecuteAsync(
tenantId,
async ct =>
{
TelegramSourceInfo info = await _discovery
.GetInfoAsync(tenantId, request.DialogId, ct)
.ConfigureAwait(false);
return new GetInfoReply { Info = DiscoveryProtoMapper.ToChannelInfo(info) };
},
"discovery_info",
context).ConfigureAwait(false);
return reply;
}
/// <summary>
/// ReadForEval — выборка сообщений источника для оценки (discovery_read L718–800).
/// </summary>
public override async Task<ReadForEvalReply> ReadForEval(ReadForEvalRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
RequireDialogId(request.DialogId);
ReadForEvalReply reply = await ExecuteAsync(
tenantId,
async ct =>
{
DiscoveryReadResult result = await _discovery
.ReadForEvalAsync(tenantId, request.DialogId, request.Limit, ct)
.ConfigureAwait(false);
return DiscoveryProtoMapper.ToReadForEvalReply(result);
},
"discovery_read",
context).ConfigureAwait(false);
return reply;
}
/// <summary>
/// Join — вступить в канал/группу по @username (discovery_join L818–839; вне квот, Ruling 10).
/// </summary>
public override async Task<JoinReply> Join(JoinRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
string normalizedUsername = DiscoveryOps.NormalizeUsername(request.Username);
RequireUsernameWithinBounds(normalizedUsername);
await ExecuteAsync(
tenantId,
async ct =>
{
await _discovery.JoinAsync(tenantId, normalizedUsername, ct).ConfigureAwait(false);
return true;
},
"join",
context).ConfigureAwait(false);
return new JoinReply { Ok = true };
}
/// <summary>
/// Leave — выйти из канала/группы (discovery_leave L841–848).
/// </summary>
public override async Task<LeaveReply> Leave(LeaveRequest request, ServerCallContext context)
{
string tenantId = RequireTenantId(context);
RequireDialogId(request.DialogId);
await ExecuteAsync(
tenantId,
async ct =>
{
await _discovery.LeaveAsync(tenantId, request.DialogId, ct).ConfigureAwait(false);
return true;
},
"leave",
context).ConfigureAwait(false);
return new LeaveReply { Ok = true };
}
// Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, Ruling 1).
// context: Контекст вызова.
private static string RequireTenantId(ServerCallContext context)
{
string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, SessionErrorMessages.TenantIdMissing));
}
return tenantId;
}
// Id диалога обязателен в командах каталога (пустое — INVALID_ARGUMENT).
// dialogId: Id диалога из запроса.
private static void RequireDialogId(string dialogId)
{
if (string.IsNullOrWhiteSpace(dialogId))
{
throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId));
}
}
// Поисковый запрос ограничен сверху (INVALID_ARGUMENT; защита границы сервиса).
// query: Поисковый запрос.
private static void RequireSearchQueryWithinBounds(string query)
{
if (query.Trim().Length > MaxSearchQueryLength)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.SearchQueryTooLong));
}
}
// Username вступления ограничен сверху (INVALID_ARGUMENT; лимит Telegram 5..32).
// username: Нормализованный username (без «@»/пробелов).
private static void RequireUsernameWithinBounds(string username)
{
if (username.Length > MaxUsernameLength)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.UsernameTooLong));
}
}
// Best-effort-синхронизация зеркала мониторинга с ядром (SyncDialogs): сбой (ядро недоступно)
// логируется и не роняет команду — упущенное догоняет realtime_sweep (Ruling 7/план Task 10).
// tenantId: Id тенанта.
// entries: Актуальный каталог диалогов.
// cancellationToken: Отмена операции.
private async Task SyncCatalogSilentlyAsync(string tenantId, IReadOnlyList<DialogEntry> entries, CancellationToken cancellationToken)
{
try
{
IReadOnlyList<string> monitored = await _ingress.SyncDialogsAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
_catalog.ReplaceMonitored(tenantId, monitored);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogWarning(exception, "Аудит: tenant {TenantId} sync_dialogs → сбой (зеркало прежнее; догонит sweep)", tenantId);
}
}
// Исполняет операцию сессии с единым переводом ошибок: SessionException → RPC-статус контракта,
// прочие — UNAVAILABLE «Telegram недоступен…» + структурированный лог (аудит команд, Ruling 13).
// TResult: Тип результата операции.
// tenantId: Id тенанта (для лога аудита).
// operation: Операция сессии.
// action: Действие (имя метода прототипа, для лога).
// context: Контекст вызова gRPC.
private async Task<TResult> ExecuteAsync<TResult>(
string tenantId,
Func<CancellationToken, Task<TResult>> operation,
string action,
ServerCallContext context)
{
try
{
TResult result = await operation(context.CancellationToken).ConfigureAwait(false);
_logger.LogInformation("Аудит: tenant {TenantId} {Action} → ok", tenantId, action);
return result;
}
catch (SessionException exception)
{
_logger.LogInformation("Аудит: tenant {TenantId} {Action} → ошибка {Error}", tenantId, action, exception.Message);
throw ToRpc(exception);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
_logger.LogError(exception, "Аудит: tenant {TenantId} {Action} → сбой", tenantId, action);
throw ToRpc(ToSessionFailure(exception));
}
}
// Переводит не-SessionException в доменную ошибку: транспорт/сеть — UNAVAILABLE («Telegram недоступен…»),
// внутренние сбои реализации (NRE/InvalidOperation и пр.) — INTERNAL. Раньше любые ошибки маскировались
// UNAVAILABLE (замечание code-review): внутренние дефекты должны быть видны как Internal.
// exception: Необработанное исключение операции.
private static SessionException ToSessionFailure(Exception exception)
=> IsTransportFailure(exception)
? new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception)
: new SessionException(StatusCode.Internal, SessionErrorMessages.InternalError, exception);
// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE (безопасный повтор).
// exception: Исключение для классификации.
private static bool IsTransportFailure(Exception exception)
=> exception is HttpRequestException or IOException or TimeoutException or RpcException;
// Фаза AuthPhase → строка канона контракта (idle|phone|code|password|qr|ready).
// phase: Фаза сессии.
private static string PhaseToString(AuthPhase phase)
=> phase switch
{
AuthPhase.Phone => "phone",
AuthPhase.Code => "code",
AuthPhase.Password => "password",
AuthPhase.Qr => "qr",
AuthPhase.Ready => "ready",
_ => "idle",
};
// Доменная ошибка сессии → RpcException контракта (код + detail = текст причины).
// exception: Ошибка сессии.
private static RpcException ToRpc(SessionException exception)
=> new(new Status(exception.Code, exception.Message));
}
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>