Нормализовать переводы строк в LF

Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF,
CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были
LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения),
.editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize.
Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
This commit is contained in:
Rustam Khalimov
2026-09-11 19:01:42 +03:00
parent 39c9bdc1b7
commit 713d554dc2
751 changed files with 94507 additions and 94486 deletions
@@ -1,109 +1,109 @@
using System.Diagnostics.CodeAnalysis;
namespace Deal.Telegram.Caching;
/// <summary>
/// Кэш с ограниченной ёмкостью и вытеснением least-recently-used
/// </summary>
/// <param name="TKey">Тип ключа (ссылочный или значимый).</param>
/// <param name="TValue">Тип значения.</param>
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);
}
using System.Diagnostics.CodeAnalysis;
namespace Deal.Telegram.Caching;
/// <summary>
/// Кэш с ограниченной ёмкостью и вытеснением least-recently-used
/// </summary>
/// <param name="TKey">Тип ключа (ссылочный или значимый).</param>
/// <param name="TValue">Тип значения.</param>
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);
}
@@ -1,136 +1,136 @@
using Deal.Grpc.Hosting.Interceptors;
using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
using Deal.Grpc.Sources;
using Deal.Grpc.Telegram;
using Deal.Telegram.Sessions;
using Grpc.Core;
using Grpc.Net.Client;
namespace Deal.Telegram.Core;
/// <summary>
/// Исходящий gRPC-канал в ядро
/// </summary>
public sealed class CoreIngressClient : ICoreIngressClient
{
public const string TenantIdMetadataKey = "tenant-id";
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? _ingressClient;
private SourceIngressService.SourceIngressServiceClient? _sourceClient;
private GrpcChannel? _channel;
/// <summary>
/// Создаёт клиент ингресса ядра.
/// </summary>
/// <param name="options">Конфигурация (адрес из env, service-token).</param>
/// <param name="logger">Логгер.</param>
/// <param name="mtlsCertificates">Сертификаты mTLS: null — plaintext-канал (dev).</param>
public CoreIngressClient(
CoreIngressOptions options,
ILogger<CoreIngressClient> logger,
MtlsCertificates? mtlsCertificates = null)
{
_options = options;
_logger = logger;
_mtlsCertificates = mtlsCertificates;
}
/// <inheritdoc />
public async Task<PushSourceReply> PushSourceAsync(
string tenantId,
PushSourceRequest request,
CancellationToken cancellationToken)
{
SourceIngressService.SourceIngressServiceClient client = GetSourceClient();
try
{
return await client.PushSourceAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw Fail("PushSource", exception);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> SyncDialogsAsync(
string tenantId,
IReadOnlyList<DialogEntry> entries,
CancellationToken cancellationToken)
{
IngressService.IngressServiceClient client = GetIngressClient();
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);
}
}
// Клиент SourceIngress (PushSource); канал к ядру создаётся лениво.
private SourceIngressService.SourceIngressServiceClient GetSourceClient()
{
lock (_channelGate)
{
EnsureChannelLocked();
return _sourceClient!;
}
}
// Клиент Ingress (SyncDialogs); канал к ядру создаётся лениво.
private IngressService.IngressServiceClient GetIngressClient()
{
lock (_channelGate)
{
EnsureChannelLocked();
return _ingressClient!;
}
}
// Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса).
private void EnsureChannelLocked()
{
if (_channel is not null)
{
return;
}
_channel = _mtlsCertificates is not null
? GrpcChannel.ForAddress(
_options.IngressEndpoint,
new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() })
: GrpcChannel.ForAddress(_options.IngressEndpoint);
_ingressClient = new IngressService.IngressServiceClient(_channel);
_sourceClient = new SourceIngressService.SourceIngressServiceClient(_channel);
}
private CallOptions CallOptions(string tenantId)
{
var metadata = new Metadata
{
{ TenantIdMetadataKey, tenantId },
{ ServiceTokenMetadataKey, _options.ServiceToken },
};
return new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(CoreIngressOptions.RpcTimeoutSeconds));
}
private SessionException Fail(string action, Exception exception)
{
_logger.LogWarning(exception, "Аудит: ингресс ядра {Action} → сбой ({Endpoint})", action, _options.IngressEndpoint);
return new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable, exception);
}
}
using Deal.Grpc.Hosting.Interceptors;
using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
using Deal.Grpc.Sources;
using Deal.Grpc.Telegram;
using Deal.Telegram.Sessions;
using Grpc.Core;
using Grpc.Net.Client;
namespace Deal.Telegram.Core;
/// <summary>
/// Исходящий gRPC-канал в ядро
/// </summary>
public sealed class CoreIngressClient : ICoreIngressClient
{
public const string TenantIdMetadataKey = "tenant-id";
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? _ingressClient;
private SourceIngressService.SourceIngressServiceClient? _sourceClient;
private GrpcChannel? _channel;
/// <summary>
/// Создаёт клиент ингресса ядра.
/// </summary>
/// <param name="options">Конфигурация (адрес из env, service-token).</param>
/// <param name="logger">Логгер.</param>
/// <param name="mtlsCertificates">Сертификаты mTLS: null — plaintext-канал (dev).</param>
public CoreIngressClient(
CoreIngressOptions options,
ILogger<CoreIngressClient> logger,
MtlsCertificates? mtlsCertificates = null)
{
_options = options;
_logger = logger;
_mtlsCertificates = mtlsCertificates;
}
/// <inheritdoc />
public async Task<PushSourceReply> PushSourceAsync(
string tenantId,
PushSourceRequest request,
CancellationToken cancellationToken)
{
SourceIngressService.SourceIngressServiceClient client = GetSourceClient();
try
{
return await client.PushSourceAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw Fail("PushSource", exception);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> SyncDialogsAsync(
string tenantId,
IReadOnlyList<DialogEntry> entries,
CancellationToken cancellationToken)
{
IngressService.IngressServiceClient client = GetIngressClient();
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);
}
}
// Клиент SourceIngress (PushSource); канал к ядру создаётся лениво.
private SourceIngressService.SourceIngressServiceClient GetSourceClient()
{
lock (_channelGate)
{
EnsureChannelLocked();
return _sourceClient!;
}
}
// Клиент Ingress (SyncDialogs); канал к ядру создаётся лениво.
private IngressService.IngressServiceClient GetIngressClient()
{
lock (_channelGate)
{
EnsureChannelLocked();
return _ingressClient!;
}
}
// Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса).
private void EnsureChannelLocked()
{
if (_channel is not null)
{
return;
}
_channel = _mtlsCertificates is not null
? GrpcChannel.ForAddress(
_options.IngressEndpoint,
new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() })
: GrpcChannel.ForAddress(_options.IngressEndpoint);
_ingressClient = new IngressService.IngressServiceClient(_channel);
_sourceClient = new SourceIngressService.SourceIngressServiceClient(_channel);
}
private CallOptions CallOptions(string tenantId)
{
var metadata = new Metadata
{
{ TenantIdMetadataKey, tenantId },
{ ServiceTokenMetadataKey, _options.ServiceToken },
};
return new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(CoreIngressOptions.RpcTimeoutSeconds));
}
private SessionException Fail(string action, Exception exception)
{
_logger.LogWarning(exception, "Аудит: ингресс ядра {Action} → сбой ({Endpoint})", action, _options.IngressEndpoint);
return new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable, exception);
}
}
@@ -1,77 +1,77 @@
namespace Deal.Telegram.Core;
/// <summary>
/// Конфигурация исходящего канала в ядро.
/// </summary>
public sealed class CoreIngressOptions
{
/// <summary>
/// Env-ключ адреса gRPC-ингресса ядра.
/// </summary>
public const string IngressEndpointEnvVarName = "SERVICES__CORE__INGRESS";
public const string IngressEndpointConfigKey = "Services:Core:Ingress";
/// <summary>
/// Env-ключ service-token.
/// </summary>
public const string ServiceTokenEnvVarName = "DEAL_SERVICE_TOKEN";
/// <summary>
/// Адрес ингресса ядра по умолчанию.
/// </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-ингресса ядра
/// </summary>
public string IngressEndpoint { get; }
/// <summary>
/// Service-token для metadata каждого RPC
/// </summary>
public string ServiceToken { get; }
/// <summary>
/// Создаёт опции с явным адресом и токеном
/// </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/конфигурации хоста.
/// </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);
}
}
namespace Deal.Telegram.Core;
/// <summary>
/// Конфигурация исходящего канала в ядро.
/// </summary>
public sealed class CoreIngressOptions
{
/// <summary>
/// Env-ключ адреса gRPC-ингресса ядра.
/// </summary>
public const string IngressEndpointEnvVarName = "SERVICES__CORE__INGRESS";
public const string IngressEndpointConfigKey = "Services:Core:Ingress";
/// <summary>
/// Env-ключ service-token.
/// </summary>
public const string ServiceTokenEnvVarName = "DEAL_SERVICE_TOKEN";
/// <summary>
/// Адрес ингресса ядра по умолчанию.
/// </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-ингресса ядра
/// </summary>
public string IngressEndpoint { get; }
/// <summary>
/// Service-token для metadata каждого RPC
/// </summary>
public string ServiceToken { get; }
/// <summary>
/// Создаёт опции с явным адресом и токеном
/// </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/конфигурации хоста.
/// </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);
}
}
@@ -1,34 +1,34 @@
using Deal.Grpc.Sources;
using Deal.Grpc.Telegram;
namespace Deal.Telegram.Core;
/// <summary>
/// Исходящий канал в ядро.
/// </summary>
public interface ICoreIngressClient
{
/// <summary>
/// Отправляет запись источника в ядро.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="request">Запись в контракте PushSourceRequest.</param>
/// <param name="cancellationToken">Отмена вызова.</param>
/// <returns>Ответ ядра (accepted/duplicate — дубль источника в очереди не растёт).</returns>
public Task<PushSourceReply> PushSourceAsync(
string tenantId,
PushSourceRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Синхронизирует каталог с ядром.
/// </summary>
/// <param name="tenantId">Id тенанта.</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);
}
using Deal.Grpc.Sources;
using Deal.Grpc.Telegram;
namespace Deal.Telegram.Core;
/// <summary>
/// Исходящий канал в ядро.
/// </summary>
public interface ICoreIngressClient
{
/// <summary>
/// Отправляет запись источника в ядро.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="request">Запись в контракте PushSourceRequest.</param>
/// <param name="cancellationToken">Отмена вызова.</param>
/// <returns>Ответ ядра (accepted/duplicate — дубль источника в очереди не растёт).</returns>
public Task<PushSourceReply> PushSourceAsync(
string tenantId,
PushSourceRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Синхронизирует каталог с ядром.
/// </summary>
/// <param name="tenantId">Id тенанта.</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);
}
@@ -1,40 +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>
<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>
@@ -1,185 +1,185 @@
using System.Collections.Concurrent;
using Deal.Grpc.Sources;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Backfill диалога
/// </summary>
public sealed class BackfillService
{
/// <summary>
/// Сколько последних сообщений читает backfill.
/// </summary>
public const int MessagesLimit = 10;
/// <summary>
/// Нижняя граница анти-бан-паузы между сообщениями.
/// </summary>
public const double MinPerMessageDelaySeconds = 1.5;
/// <summary>
/// Верхняя граница анти-бан-паузы между сообщениями.
/// </summary>
public const double MaxPerMessageDelaySeconds = 3.0;
/// <summary>
/// Нижняя граница паузы между диалогами одного тенанта.
/// </summary>
public const double MinPerDialogDelaySeconds = 3.0;
/// <summary>
/// Верхняя граница паузы между диалогами одного тенанта.
/// </summary>
public const double MaxPerDialogDelaySeconds = 6.0;
private readonly SessionFarm _sessionFarm;
private readonly ICoreIngressClient _ingress;
private readonly IBackfillPacer _pacer;
private readonly ILogger<BackfillService> _logger;
private readonly ConcurrentDictionary<(string TenantId, string DialogId), byte> _running = new();
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">Канал в ядро (PushSource).</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>
/// Перечитывает последние сообщения диалога в ядро
/// </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))
{
_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 _);
}
}
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())
{
PushSourceReply reply = await _ingress
.PushSourceAsync(tenantId, DialogProtoMapper.ToSourceRequest(message), cancellationToken)
.ConfigureAwait(false);
if (reply.Accepted && !reply.Duplicate)
{
processed++;
}
await _pacer.WaitAsync(MinPerMessageDelaySeconds, MaxPerMessageDelaySeconds, cancellationToken).ConfigureAwait(false);
}
await _sessionFarm.MarkReadAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false);
return processed;
}
}
using System.Collections.Concurrent;
using Deal.Grpc.Sources;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Backfill диалога
/// </summary>
public sealed class BackfillService
{
/// <summary>
/// Сколько последних сообщений читает backfill.
/// </summary>
public const int MessagesLimit = 10;
/// <summary>
/// Нижняя граница анти-бан-паузы между сообщениями.
/// </summary>
public const double MinPerMessageDelaySeconds = 1.5;
/// <summary>
/// Верхняя граница анти-бан-паузы между сообщениями.
/// </summary>
public const double MaxPerMessageDelaySeconds = 3.0;
/// <summary>
/// Нижняя граница паузы между диалогами одного тенанта.
/// </summary>
public const double MinPerDialogDelaySeconds = 3.0;
/// <summary>
/// Верхняя граница паузы между диалогами одного тенанта.
/// </summary>
public const double MaxPerDialogDelaySeconds = 6.0;
private readonly SessionFarm _sessionFarm;
private readonly ICoreIngressClient _ingress;
private readonly IBackfillPacer _pacer;
private readonly ILogger<BackfillService> _logger;
private readonly ConcurrentDictionary<(string TenantId, string DialogId), byte> _running = new();
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">Канал в ядро (PushSource).</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>
/// Перечитывает последние сообщения диалога в ядро
/// </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))
{
_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 _);
}
}
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())
{
PushSourceReply reply = await _ingress
.PushSourceAsync(tenantId, DialogProtoMapper.ToSourceRequest(message), cancellationToken)
.ConfigureAwait(false);
if (reply.Accepted && !reply.Duplicate)
{
processed++;
}
await _pacer.WaitAsync(MinPerMessageDelaySeconds, MaxPerMessageDelaySeconds, cancellationToken).ConfigureAwait(false);
}
await _sessionFarm.MarkReadAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false);
return processed;
}
}
@@ -1,195 +1,195 @@
using System.Collections.ObjectModel;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Зеркало каталога диалогов тенанта в памяти telegram-service.
/// </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>
/// Обновляет полный каталог диалогов тенанта
/// </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>
/// Включает/выключает мониторинг одного диалога.
/// </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.
/// </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>
/// Включает/выключает мониторинг всех диалогов каталога.
/// </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>
/// Проверяет, мониторится ли диалог.
/// </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>
/// Сколько диалогов знает каталог тенанта
/// </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>
/// Сбрасывает состояние тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public void Reset(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return;
}
lock (_gate)
{
_knownByTenant.Remove(tenantId);
_monitoredByTenant.Remove(tenantId);
}
}
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;
}
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;
}
}
using System.Collections.ObjectModel;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Зеркало каталога диалогов тенанта в памяти telegram-service.
/// </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>
/// Обновляет полный каталог диалогов тенанта
/// </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>
/// Включает/выключает мониторинг одного диалога.
/// </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.
/// </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>
/// Включает/выключает мониторинг всех диалогов каталога.
/// </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>
/// Проверяет, мониторится ли диалог.
/// </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>
/// Сколько диалогов знает каталог тенанта
/// </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>
/// Сбрасывает состояние тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public void Reset(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return;
}
lock (_gate)
{
_knownByTenant.Remove(tenantId);
_monitoredByTenant.Remove(tenantId);
}
}
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;
}
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;
}
}
@@ -1,38 +1,38 @@
using System.Text;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Детерминированный цвет диалога из палитры DIALOG_HUES.
/// </summary>
public static class DialogHue
{
private static readonly string[] Palette =
[
"#3b82f6",
"#38bdf8",
"#f472b6",
"#f59e0b",
"#a78bfa",
"#34d399",
"#fb7185",
"#22c55e",
];
/// <summary>
/// Цвет источника по id диалога и имени
/// </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];
}
}
using System.Text;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Детерминированный цвет диалога из палитры DIALOG_HUES.
/// </summary>
public static class DialogHue
{
private static readonly string[] Palette =
[
"#3b82f6",
"#38bdf8",
"#f472b6",
"#f59e0b",
"#a78bfa",
"#34d399",
"#fb7185",
"#22c55e",
];
/// <summary>
/// Цвет источника по id диалога и имени
/// </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];
}
}
@@ -1,69 +1,69 @@
using System.Globalization;
using Deal.Grpc.Sources;
using Deal.Grpc.Telegram;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Маппер нейтральных результатов сессии в protobuf-контракт.
/// </summary>
public static class DialogProtoMapper
{
private const string TelegramKind = "telegram";
private const string HueKey = "hue";
/// <summary>
/// Нейтральный диалог → DialogEntry контракта
/// </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 превью
/// </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>
/// Нейтральное сообщение → PushSourceRequest ингресса.
/// </summary>
/// <param name="message">Сообщение диалога (непустой текст).</param>
/// <returns>Запрос SourceIngress.PushSource с источником telegram и содержимым.</returns>
public static PushSourceRequest ToSourceRequest(TelegramMessage message)
=> new()
{
Source = new SourceRefProto
{
Kind = TelegramKind,
ExternalId = message.Id.ToString(CultureInfo.InvariantCulture),
OriginRef = message.DialogId,
DisplayName = message.DialogName,
Author = message.DialogName,
ReceivedAt = message.DateMs,
Extra = { [HueKey] = DialogHue.Compute(message.DialogId, message.DialogName) },
},
Content = new SourceContentProto
{
Text = message.Text,
Author = message.DialogName,
},
};
}
using System.Globalization;
using Deal.Grpc.Sources;
using Deal.Grpc.Telegram;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Маппер нейтральных результатов сессии в protobuf-контракт.
/// </summary>
public static class DialogProtoMapper
{
private const string TelegramKind = "telegram";
private const string HueKey = "hue";
/// <summary>
/// Нейтральный диалог → DialogEntry контракта
/// </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 превью
/// </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>
/// Нейтральное сообщение → PushSourceRequest ингресса.
/// </summary>
/// <param name="message">Сообщение диалога (непустой текст).</param>
/// <returns>Запрос SourceIngress.PushSource с источником telegram и содержимым.</returns>
public static PushSourceRequest ToSourceRequest(TelegramMessage message)
=> new()
{
Source = new SourceRefProto
{
Kind = TelegramKind,
ExternalId = message.Id.ToString(CultureInfo.InvariantCulture),
OriginRef = message.DialogId,
DisplayName = message.DialogName,
Author = message.DialogName,
ReceivedAt = message.DateMs,
Extra = { [HueKey] = DialogHue.Compute(message.DialogId, message.DialogName) },
},
Content = new SourceContentProto
{
Text = message.Text,
Author = message.DialogName,
},
};
}
@@ -1,19 +1,19 @@
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Seam анти-бан-пауз между сетевыми операциями каталога
/// </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);
}
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Seam анти-бан-пауз между сетевыми операциями каталога
/// </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);
}
@@ -1,29 +1,29 @@
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Реальная реализация <see cref="IBackfillPacer"/>
/// </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);
}
}
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Реальная реализация <see cref="IBackfillPacer"/>
/// </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);
}
}
@@ -1,92 +1,92 @@
using Deal.Grpc.Sources;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Realtime-listener мониторящихся диалогов одного тенанта.
/// </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">Зеркало мониторинга.</param>
/// <param name="ingress">Канал в ядро (PushSource).</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
/// </summary>
public void Stop()
{
_session.MessageReceived -= OnMessageReceivedAsync;
_session.SetListenerActive(false);
}
// Обработчик входящего сообщения: фильтр по зеркалу → PushSource в ядро → mark-as-read.
// Сбой отправки не роняет realtime: сообщение остаётся непрочитанным и догоняется realtime_sweep.
// message: Входящее сообщение аккаунта.
private async Task OnMessageReceivedAsync(TelegramMessage message)
{
try
{
if (!_catalog.IsMonitored(_session.TenantId, message.DialogId))
{
return;
}
PushSourceReply reply = await _ingress
.PushSourceAsync(_session.TenantId, DialogProtoMapper.ToSourceRequest(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)
{
_logger.LogWarning(
exception,
"Realtime {TenantId} {DialogId} msg {MessageId}: не доставлено в ядро — догонит sweep",
_session.TenantId,
message.DialogId,
message.Id);
}
}
}
using Deal.Grpc.Sources;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Realtime-listener мониторящихся диалогов одного тенанта.
/// </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">Зеркало мониторинга.</param>
/// <param name="ingress">Канал в ядро (PushSource).</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
/// </summary>
public void Stop()
{
_session.MessageReceived -= OnMessageReceivedAsync;
_session.SetListenerActive(false);
}
// Обработчик входящего сообщения: фильтр по зеркалу → PushSource в ядро → mark-as-read.
// Сбой отправки не роняет realtime: сообщение остаётся непрочитанным и догоняется realtime_sweep.
// message: Входящее сообщение аккаунта.
private async Task OnMessageReceivedAsync(TelegramMessage message)
{
try
{
if (!_catalog.IsMonitored(_session.TenantId, message.DialogId))
{
return;
}
PushSourceReply reply = await _ingress
.PushSourceAsync(_session.TenantId, DialogProtoMapper.ToSourceRequest(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)
{
_logger.LogWarning(
exception,
"Realtime {TenantId} {DialogId} msg {MessageId}: не доставлено в ядро — догонит sweep",
_session.TenantId,
message.DialogId,
message.Id);
}
}
}
@@ -1,178 +1,178 @@
using Deal.Grpc.Sources;
using Deal.Grpc.Telegram;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Страховочная догонялка realtime.
/// </summary>
public sealed class RealtimeSweep
{
/// <summary>
/// Период цикла догона.
/// </summary>
public const int SweepPeriodSeconds = 30;
/// <summary>
/// Верхняя граница списка диалогов за цикл.
/// </summary>
public const int DialogsLimit = 500;
/// <summary>
/// Запас сообщений сверх unread_count при чтении
/// </summary>
public const int UnreadSlackMessages = 2;
/// <summary>
/// Потолок сообщений одного диалога за цикл.
/// </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">Канал в ядро (PushSource/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>
/// Один цикл догона
/// </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;
}
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);
}
}
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())
{
PushSourceReply reply = await _ingress
.PushSourceAsync(tenantId, DialogProtoMapper.ToSourceRequest(message), cancellationToken)
.ConfigureAwait(false);
if (reply.Accepted && !reply.Duplicate)
{
added++;
}
}
if (added > 0)
{
_logger.LogInformation(
"realtime sweep {TenantId} {DialogId}: +{Added} в ядро (потерянные события)",
tenantId,
dialog.Id,
added);
}
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);
}
}
}
using Deal.Grpc.Sources;
using Deal.Grpc.Telegram;
using Deal.Telegram.Core;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Страховочная догонялка realtime.
/// </summary>
public sealed class RealtimeSweep
{
/// <summary>
/// Период цикла догона.
/// </summary>
public const int SweepPeriodSeconds = 30;
/// <summary>
/// Верхняя граница списка диалогов за цикл.
/// </summary>
public const int DialogsLimit = 500;
/// <summary>
/// Запас сообщений сверх unread_count при чтении
/// </summary>
public const int UnreadSlackMessages = 2;
/// <summary>
/// Потолок сообщений одного диалога за цикл.
/// </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">Канал в ядро (PushSource/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>
/// Один цикл догона
/// </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;
}
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);
}
}
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())
{
PushSourceReply reply = await _ingress
.PushSourceAsync(tenantId, DialogProtoMapper.ToSourceRequest(message), cancellationToken)
.ConfigureAwait(false);
if (reply.Accepted && !reply.Duplicate)
{
added++;
}
}
if (added > 0)
{
_logger.LogInformation(
"realtime sweep {TenantId} {DialogId}: +{Added} в ядро (потерянные события)",
tenantId,
dialog.Id,
added);
}
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);
}
}
}
@@ -1,165 +1,165 @@
using Deal.Telegram.Dialogs;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
using Grpc.Core;
namespace Deal.Telegram.Discovery;
/// <summary>
/// Discovery-операции telegram-service поверх пула сессий тенантов.
/// </summary>
public sealed class DiscoveryOps
{
/// <summary>
/// Верхняя граница поиска по умолчанию, если core не передал.
/// </summary>
public const int SearchDefaultLimit = 30;
/// <summary>
/// Нижняя граница анти-бан-паузы после поиска
/// </summary>
public const double SearchPauseMinSeconds = 2.0;
/// <summary>
/// Верхняя граница анти-бан-паузы после поиска
/// </summary>
public const double SearchPauseMaxSeconds = 4.0;
/// <summary>
/// Размер выборки чтения по умолчанию, если core не передал
/// </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>
/// Глобальный поиск источников по ключу.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
/// <param name="limit">Верхняя граница результата.</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);
await _pacer.WaitAsync(SearchPauseMinSeconds, SearchPauseMaxSeconds, cancellationToken).ConfigureAwait(false);
return DedupeAndCap(found, effectiveLimit);
}
/// <summary>
/// Инфо об источнике для оценки кандидата.
/// </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>
/// Выборка последних сообщений источника для оценки.
/// </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.
/// </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>
/// Выйти из канала/группы.
/// </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 вступления
/// </summary>
/// <param name="username">Username из запроса (может быть пуст/null).</param>
/// <returns>Нормализованный username (пустой — вступать не по чему).</returns>
public static string NormalizeUsername(string? username)
=> (username ?? string.Empty).Trim().TrimStart('@');
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;
}
}
using Deal.Telegram.Dialogs;
using Deal.Telegram.Sessions;
using Deal.Telegram.Telegram;
using Grpc.Core;
namespace Deal.Telegram.Discovery;
/// <summary>
/// Discovery-операции telegram-service поверх пула сессий тенантов.
/// </summary>
public sealed class DiscoveryOps
{
/// <summary>
/// Верхняя граница поиска по умолчанию, если core не передал.
/// </summary>
public const int SearchDefaultLimit = 30;
/// <summary>
/// Нижняя граница анти-бан-паузы после поиска
/// </summary>
public const double SearchPauseMinSeconds = 2.0;
/// <summary>
/// Верхняя граница анти-бан-паузы после поиска
/// </summary>
public const double SearchPauseMaxSeconds = 4.0;
/// <summary>
/// Размер выборки чтения по умолчанию, если core не передал
/// </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>
/// Глобальный поиск источников по ключу.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
/// <param name="limit">Верхняя граница результата.</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);
await _pacer.WaitAsync(SearchPauseMinSeconds, SearchPauseMaxSeconds, cancellationToken).ConfigureAwait(false);
return DedupeAndCap(found, effectiveLimit);
}
/// <summary>
/// Инфо об источнике для оценки кандидата.
/// </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>
/// Выборка последних сообщений источника для оценки.
/// </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.
/// </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>
/// Выйти из канала/группы.
/// </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 вступления
/// </summary>
/// <param name="username">Username из запроса (может быть пуст/null).</param>
/// <returns>Нормализованный username (пустой — вступать не по чему).</returns>
public static string NormalizeUsername(string? username)
=> (username ?? string.Empty).Trim().TrimStart('@');
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;
}
}
@@ -1,82 +1,82 @@
using Deal.Grpc.Telegram;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Discovery;
/// <summary>
/// Маппер нейтральных результатов discovery в protobuf-контракт.
/// </summary>
public static class DiscoveryProtoMapper
{
/// <summary>
/// Нейтральное инфо источника → ChannelInfo контракта
/// </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 контракта
/// </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 контракта
/// </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;
}
}
using Deal.Grpc.Telegram;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Telegram;
namespace Deal.Telegram.Discovery;
/// <summary>
/// Маппер нейтральных результатов discovery в protobuf-контракт.
/// </summary>
public static class DiscoveryProtoMapper
{
/// <summary>
/// Нейтральное инфо источника → ChannelInfo контракта
/// </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 контракта
/// </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 контракта
/// </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;
}
}
+45 -45
View File
@@ -1,45 +1,45 @@
# 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).
# Non-root: процесс работает пользователем deal (UID 10001); /data/sessions — каталог сессий (volume).
RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal \
&& mkdir -p /data/sessions \
&& chown -R deal:deal /data/sessions
ENV HOME=/tmp
USER deal
ENTRYPOINT ["dotnet", "Deal.Telegram.dll"]
# 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).
# Non-root: процесс работает пользователем deal (UID 10001); /data/sessions — каталог сессий (volume).
RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal \
&& mkdir -p /data/sessions \
&& chown -R deal:deal /data/sessions
ENV HOME=/tmp
USER deal
ENTRYPOINT ["dotnet", "Deal.Telegram.dll"]
@@ -1,127 +1,127 @@
using Deal.Telegram.Core;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Sessions;
namespace Deal.Telegram.Hosting;
/// <summary>
/// Фоновый reconcile realtime-listener'ов.
/// </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">Канал в ядро (PushSource).</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();
}
}
}
using Deal.Telegram.Core;
using Deal.Telegram.Dialogs;
using Deal.Telegram.Sessions;
namespace Deal.Telegram.Hosting;
/// <summary>
/// Фоновый reconcile realtime-listener'ов.
/// </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">Канал в ядро (PushSource).</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();
}
}
}
@@ -1,52 +1,52 @@
using Deal.Telegram.Dialogs;
namespace Deal.Telegram.Hosting;
/// <summary>
/// Фоновый цикл догона realtime.
/// </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;
}
}
}
}
using Deal.Telegram.Dialogs;
namespace Deal.Telegram.Hosting;
/// <summary>
/// Фоновый цикл догона realtime.
/// </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;
}
}
}
}
@@ -1,71 +1,71 @@
using Deal.Telegram.Sessions;
namespace Deal.Telegram.Hosting;
/// <summary>
/// Фоновый цикл сессий telegram-service.
/// </summary>
public sealed class SessionHeartbeatService : BackgroundService
{
/// <summary>
/// Период цикла сердцебиения.
/// </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 файлов сессий
/// </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>
/// При остановке хоста сохраняет живые сессии.
/// </summary>
/// <param name="cancellationToken">Токен остановки.</param>
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken).ConfigureAwait(false);
await _sessionFarm.ShutdownAsync(cancellationToken).ConfigureAwait(false);
}
}
using Deal.Telegram.Sessions;
namespace Deal.Telegram.Hosting;
/// <summary>
/// Фоновый цикл сессий telegram-service.
/// </summary>
public sealed class SessionHeartbeatService : BackgroundService
{
/// <summary>
/// Период цикла сердцебиения.
/// </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 файлов сессий
/// </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>
/// При остановке хоста сохраняет живые сессии.
/// </summary>
/// <param name="cancellationToken">Токен остановки.</param>
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken).ConfigureAwait(false);
await _sessionFarm.ShutdownAsync(cancellationToken).ConfigureAwait(false);
}
}
+36 -36
View File
@@ -1,36 +1,36 @@
//
using Deal.Grpc.Hosting.Interceptors;
using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
using Deal.Telegram;
const int defaultGrpcPort = 5101;
const string telegramProcessName = "telegram";
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
WebApplication app = TelegramServiceHost.Create(
grpcPort,
configureBuilder: builder =>
{
DealLogging.Configure(builder, telegramProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
});
DealMetricsHosting.MapDealMetrics(app);
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();
//
using Deal.Grpc.Hosting.Interceptors;
using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
using Deal.Telegram;
const int defaultGrpcPort = 5101;
const string telegramProcessName = "telegram";
int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort);
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
WebApplication app = TelegramServiceHost.Create(
grpcPort,
configureBuilder: builder =>
{
DealLogging.Configure(builder, telegramProcessName);
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
});
DealMetricsHosting.MapDealMetrics(app);
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();
@@ -1,37 +1,37 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Фаза входа/подключения аккаунта тенанта
/// </summary>
public enum AuthPhase
{
/// <summary>
/// Активного входа нет
/// </summary>
Idle,
/// <summary>
/// Фаза ввода номера телефона.
/// </summary>
Phone,
/// <summary>
/// SMS-код отправлен, ожидается код
/// </summary>
Code,
/// <summary>
/// Включена двухфакторная аутентификация — нужен облачный пароль
/// </summary>
Password,
/// <summary>
/// QR-вход запущен
/// </summary>
Qr,
/// <summary>
/// Аккаунт авторизован, сессия сохранена
/// </summary>
Ready,
}
namespace Deal.Telegram.Sessions;
/// <summary>
/// Фаза входа/подключения аккаунта тенанта
/// </summary>
public enum AuthPhase
{
/// <summary>
/// Активного входа нет
/// </summary>
Idle,
/// <summary>
/// Фаза ввода номера телефона.
/// </summary>
Phone,
/// <summary>
/// SMS-код отправлен, ожидается код
/// </summary>
Code,
/// <summary>
/// Включена двухфакторная аутентификация — нужен облачный пароль
/// </summary>
Password,
/// <summary>
/// QR-вход запущен
/// </summary>
Qr,
/// <summary>
/// Аккаунт авторизован, сессия сохранена
/// </summary>
Ready,
}
@@ -1,106 +1,106 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Канонические тексты причин сессионных ошибок
/// </summary>
public static class SessionErrorMessages
{
public const string NoApiKeys = "Сначала сохраните Telegram api_id и api_hash в настройках";
/// <summary>
/// Аккаунт тенанта не подключён — нет сессии
/// </summary>
public const string NotConnected = "Telegram не подключён";
/// <summary>
/// Неверный SMS-код входа
/// </summary>
public const string WrongCode = "Неверный код";
/// <summary>
/// SMS-код истёк, нужен новый
/// </summary>
public const string CodeExpired = "Код истёк — запросите новый";
/// <summary>
/// Неверный облачный пароль 2FA
/// </summary>
public const string WrongPassword = "Неверный облачный пароль";
/// <summary>
/// SendCode вызван вне фазы "code"
/// </summary>
public const string CodeNotRequested = "Код не запрашивался — начните вход по номеру телефона";
/// <summary>
/// SendPassword вызван вне фазы "password"
/// </summary>
public const string PasswordNotRequested = "2FA-пароль не запрашивался — сначала отправьте код";
/// <summary>
/// Metadata tenant-id отсутствует или пуст
/// </summary>
public const string TenantIdMissing = "tenant-id отсутствует в metadata";
/// <summary>
/// Некорректный tenant-id
/// </summary>
public const string InvalidTenantId = "Некорректный tenant-id в metadata";
/// <summary>
/// Telegram/сеть недоступны
/// </summary>
public const string TelegramUnavailable = "Telegram недоступен — повторите попытку позже";
/// <summary>
/// Ядро (gRPC-ингресс) недоступно — PushSource/SyncDialogs не доставлены (UNAVAILABLE).
/// </summary>
public const string IngressUnavailable = "Ядро недоступно — повторите попытку позже";
/// <summary>
/// Диалог не найден в аккаунте/кэше сущностей сессии
/// </summary>
public const string UnknownDialog = "Источник не найден в аккаунте — обновите список каналов";
/// <summary>
/// Пустой username вступления.
/// </summary>
public const string JoinUsernameMissing = "Не указан username для вступления";
/// <summary>
/// Поисковый запрос длиннее верхней границы
/// </summary>
public const string SearchQueryTooLong = "Слишком длинный поисковый запрос";
/// <summary>
/// Username вступления длиннее лимита Telegram
/// </summary>
public const string UsernameTooLong = "Слишком длинный username";
/// <summary>
/// Внутренняя ошибка сервиса
/// </summary>
public const string InternalError = "Внутренняя ошибка сервиса — повторите попытку позже";
/// <summary>
/// По username найден не канал/группа
/// </summary>
public const string JoinTargetNotChannel = "По этому username найден не канал/группа — вступить нельзя";
/// <summary>
/// Некорректный
/// </summary>
public const string InvalidDialogId = "Некорректный id источника";
/// <summary>
/// Неположительный id сообщения в запросе
/// </summary>
public const string InvalidMessageId = "Некорректный id сообщения";
/// <summary>
/// Телефон не зарегистрирован в Telegram
/// </summary>
public const string SignUpRequired = "Номер не зарегистрирован в Telegram — зарегистрируйте его в приложении Telegram";
public const string SessionKeyNotConfigured = "Ключ шифрования сессий не задан (DEAL_TELEGRAM_SESSION_KEY)";
}
namespace Deal.Telegram.Sessions;
/// <summary>
/// Канонические тексты причин сессионных ошибок
/// </summary>
public static class SessionErrorMessages
{
public const string NoApiKeys = "Сначала сохраните Telegram api_id и api_hash в настройках";
/// <summary>
/// Аккаунт тенанта не подключён — нет сессии
/// </summary>
public const string NotConnected = "Telegram не подключён";
/// <summary>
/// Неверный SMS-код входа
/// </summary>
public const string WrongCode = "Неверный код";
/// <summary>
/// SMS-код истёк, нужен новый
/// </summary>
public const string CodeExpired = "Код истёк — запросите новый";
/// <summary>
/// Неверный облачный пароль 2FA
/// </summary>
public const string WrongPassword = "Неверный облачный пароль";
/// <summary>
/// SendCode вызван вне фазы "code"
/// </summary>
public const string CodeNotRequested = "Код не запрашивался — начните вход по номеру телефона";
/// <summary>
/// SendPassword вызван вне фазы "password"
/// </summary>
public const string PasswordNotRequested = "2FA-пароль не запрашивался — сначала отправьте код";
/// <summary>
/// Metadata tenant-id отсутствует или пуст
/// </summary>
public const string TenantIdMissing = "tenant-id отсутствует в metadata";
/// <summary>
/// Некорректный tenant-id
/// </summary>
public const string InvalidTenantId = "Некорректный tenant-id в metadata";
/// <summary>
/// Telegram/сеть недоступны
/// </summary>
public const string TelegramUnavailable = "Telegram недоступен — повторите попытку позже";
/// <summary>
/// Ядро (gRPC-ингресс) недоступно — PushSource/SyncDialogs не доставлены (UNAVAILABLE).
/// </summary>
public const string IngressUnavailable = "Ядро недоступно — повторите попытку позже";
/// <summary>
/// Диалог не найден в аккаунте/кэше сущностей сессии
/// </summary>
public const string UnknownDialog = "Источник не найден в аккаунте — обновите список каналов";
/// <summary>
/// Пустой username вступления.
/// </summary>
public const string JoinUsernameMissing = "Не указан username для вступления";
/// <summary>
/// Поисковый запрос длиннее верхней границы
/// </summary>
public const string SearchQueryTooLong = "Слишком длинный поисковый запрос";
/// <summary>
/// Username вступления длиннее лимита Telegram
/// </summary>
public const string UsernameTooLong = "Слишком длинный username";
/// <summary>
/// Внутренняя ошибка сервиса
/// </summary>
public const string InternalError = "Внутренняя ошибка сервиса — повторите попытку позже";
/// <summary>
/// По username найден не канал/группа
/// </summary>
public const string JoinTargetNotChannel = "По этому username найден не канал/группа — вступить нельзя";
/// <summary>
/// Некорректный
/// </summary>
public const string InvalidDialogId = "Некорректный id источника";
/// <summary>
/// Неположительный id сообщения в запросе
/// </summary>
public const string InvalidMessageId = "Некорректный id сообщения";
/// <summary>
/// Телефон не зарегистрирован в Telegram
/// </summary>
public const string SignUpRequired = "Номер не зарегистрирован в Telegram — зарегистрируйте его в приложении Telegram";
public const string SessionKeyNotConfigured = "Ключ шифрования сессий не задан (DEAL_TELEGRAM_SESSION_KEY)";
}
@@ -1,29 +1,29 @@
using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Доменная ошибка сессий telegram-service.
/// </summary>
public sealed class SessionException : Exception
{
/// <summary>
/// Создаёт ошибку сессии с gRPC-кодом, в который она должна превратиться на границе.
/// </summary>
/// <param name="code">gRPC-статус ошибки (контракт telegram.proto, шапка файла).</param>
/// <param name="message">Текст причины — detail RPC.</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; }
}
using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Доменная ошибка сессий telegram-service.
/// </summary>
public sealed class SessionException : Exception
{
/// <summary>
/// Создаёт ошибку сессии с gRPC-кодом, в который она должна превратиться на границе.
/// </summary>
/// <param name="code">gRPC-статус ошибки (контракт telegram.proto, шапка файла).</param>
/// <param name="message">Текст причины — detail RPC.</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; }
}
@@ -1,349 +1,349 @@
using System.Collections.Concurrent;
using Deal.Telegram.Telegram;
using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Пул сессий тенантов «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>
/// Возвращает сессию тенанта, если она уже создана
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public TenantSession? FindSession(string tenantId)
=> _sessions.TryGetValue(tenantId, out TenantSession? session) ? session : null;
/// <summary>
/// Все сессии пула
/// </summary>
public IReadOnlyCollection<TenantSession> Sessions
=> _sessions.Values.ToArray();
/// <summary>
/// Список диалогов аккаунта тенанта.
/// </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>
/// Последние сообщения диалога тенанта.
/// </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>
/// Исходное сообщение диалога тенанта по id.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="msgId">Id сообщения в Telegram.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TelegramMessage?> GetMessageAsync(
string tenantId,
string dialogId,
long msgId,
CancellationToken cancellationToken)
=> RequireSession(tenantId).GetMessageAsync(dialogId, msgId, cancellationToken);
/// <summary>
/// Помечает диалог тенанта прочитанным.
/// </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>
/// Глобальный поиск каналов/групп по ключу.
/// </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>
/// Инфо об источнике для оценки кандидата.
/// </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>
/// Выборка сообщений источника для оценки.
/// </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.
/// </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>
/// Выйти из канала/группы.
/// </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>
/// Отключение аккаунта
/// </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>
/// Авто-возобновление на старте
/// </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>
/// Сердцебиение
/// </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>
/// Остановка хоста
/// </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;
}
}
using System.Collections.Concurrent;
using Deal.Telegram.Telegram;
using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Пул сессий тенантов «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>
/// Возвращает сессию тенанта, если она уже создана
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public TenantSession? FindSession(string tenantId)
=> _sessions.TryGetValue(tenantId, out TenantSession? session) ? session : null;
/// <summary>
/// Все сессии пула
/// </summary>
public IReadOnlyCollection<TenantSession> Sessions
=> _sessions.Values.ToArray();
/// <summary>
/// Список диалогов аккаунта тенанта.
/// </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>
/// Последние сообщения диалога тенанта.
/// </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>
/// Исходное сообщение диалога тенанта по id.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="msgId">Id сообщения в Telegram.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TelegramMessage?> GetMessageAsync(
string tenantId,
string dialogId,
long msgId,
CancellationToken cancellationToken)
=> RequireSession(tenantId).GetMessageAsync(dialogId, msgId, cancellationToken);
/// <summary>
/// Помечает диалог тенанта прочитанным.
/// </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>
/// Глобальный поиск каналов/групп по ключу.
/// </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>
/// Инфо об источнике для оценки кандидата.
/// </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>
/// Выборка сообщений источника для оценки.
/// </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.
/// </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>
/// Выйти из канала/группы.
/// </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>
/// Отключение аккаунта
/// </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>
/// Авто-возобновление на старте
/// </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>
/// Сердцебиение
/// </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>
/// Остановка хоста
/// </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;
}
}
@@ -1,101 +1,101 @@
using System.Security.Cryptography;
namespace Deal.Telegram.Sessions;
/// <summary>
/// AES-256-GCM-обёртка файла сессии тенанта.
/// </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>
/// Создаёт шифр с ключом из опций
/// </summary>
/// <param name="options">Опции хранения сессий.</param>
public SessionFileCipher(TgOptions options)
{
_key = options.SessionKey;
}
/// <summary>
/// Шифрует содержимое файла сессии
/// </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>
/// Расшифровывает значение файла сессии.
/// </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;
}
}
using System.Security.Cryptography;
namespace Deal.Telegram.Sessions;
/// <summary>
/// AES-256-GCM-обёртка файла сессии тенанта.
/// </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>
/// Создаёт шифр с ключом из опций
/// </summary>
/// <param name="options">Опции хранения сессий.</param>
public SessionFileCipher(TgOptions options)
{
_key = options.SessionKey;
}
/// <summary>
/// Шифрует содержимое файла сессии
/// </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>
/// Расшифровывает значение файла сессии.
/// </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;
}
}
@@ -1,169 +1,169 @@
using System.Security.Cryptography;
using System.Text.Json;
using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Файловое хранилище сессий тенантов — SessionManager/SessionStore задачи
/// </summary>
public sealed class SessionStore
{
/// <summary>
/// Расширение файла сессии
/// </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>
/// Читает и расшифровывает сессию тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</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>
/// Удаляет файл сессии тенанта
/// </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 тенантов, для которых на диске есть файл сессии
/// </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);
}
}
using System.Security.Cryptography;
using System.Text.Json;
using Grpc.Core;
namespace Deal.Telegram.Sessions;
/// <summary>
/// Файловое хранилище сессий тенантов — SessionManager/SessionStore задачи
/// </summary>
public sealed class SessionStore
{
/// <summary>
/// Расширение файла сессии
/// </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>
/// Читает и расшифровывает сессию тенанта.
/// </summary>
/// <param name="tenantId">Id тенанта.</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>
/// Удаляет файл сессии тенанта
/// </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 тенантов, для которых на диске есть файл сессии
/// </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);
}
}
@@ -1,32 +1,32 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Открытое содержимое файла сессии тенанта.
/// </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; } = [];
}
namespace Deal.Telegram.Sessions;
/// <summary>
/// Открытое содержимое файла сессии тенанта.
/// </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
@@ -1,62 +1,62 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Снимок состояния сессии тенанта для GetStatus/ответов RPC подключения.
/// </summary>
public sealed record TenantSessionSnapshot
{
/// <summary>
/// Создаёт снимок состояния сессии.
/// </summary>
/// <param name="phase">Текущая фаза входа.</param>
/// <param name="connected">Клиент Telegram соединён.</param>
/// <param name="listener">Жив ли realtime-listener.</param>
/// <param name="account">Аккаунт "@username" авторизованного пользователя (иначе null).</param>
/// <param name="error">Текст последней ошибки (иначе null).</param>
/// <param name="qrUrl">URL QR-входа (только при phase == Qr; иначе null).</param>
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 соединён.
/// </summary>
public bool Connected { get; }
/// <summary>
/// Realtime-listener жив.
/// </summary>
public bool Listener { get; }
/// <summary>
/// Аккаунт "@username"
/// </summary>
public string? Account { get; }
/// <summary>
/// Текст последней ошибки входа/соединения
/// </summary>
public string? Error { get; }
/// <summary>
/// URL QR-входа
/// </summary>
public string? QrUrl { get; }
}
namespace Deal.Telegram.Sessions;
/// <summary>
/// Снимок состояния сессии тенанта для GetStatus/ответов RPC подключения.
/// </summary>
public sealed record TenantSessionSnapshot
{
/// <summary>
/// Создаёт снимок состояния сессии.
/// </summary>
/// <param name="phase">Текущая фаза входа.</param>
/// <param name="connected">Клиент Telegram соединён.</param>
/// <param name="listener">Жив ли realtime-listener.</param>
/// <param name="account">Аккаунт "@username" авторизованного пользователя (иначе null).</param>
/// <param name="error">Текст последней ошибки (иначе null).</param>
/// <param name="qrUrl">URL QR-входа (только при phase == Qr; иначе null).</param>
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 соединён.
/// </summary>
public bool Connected { get; }
/// <summary>
/// Realtime-listener жив.
/// </summary>
public bool Listener { get; }
/// <summary>
/// Аккаунт "@username"
/// </summary>
public string? Account { get; }
/// <summary>
/// Текст последней ошибки входа/соединения
/// </summary>
public string? Error { get; }
/// <summary>
/// URL QR-входа
/// </summary>
public string? QrUrl { get; }
}
@@ -1,112 +1,112 @@
namespace Deal.Telegram.Sessions;
/// <summary>
/// Конфигурация хранения сессий telegram-service.
/// </summary>
public sealed class TgOptions
{
/// <summary>
/// Env-ключ ключа шифрования сессий.
/// </summary>
public const string SessionKeyEnvVarName = "DEAL_TELEGRAM_SESSION_KEY";
/// <summary>
/// Env-ключ каталога сессий
/// </summary>
public const string SessionDirEnvVarName = "DEAL_TELEGRAM_SESSION_DIR";
/// <summary>
/// Относительный каталог сессий по умолчанию
/// </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;
}
public byte[] SessionKey { get; }
/// <summary>
/// Абсолютный путь к каталогу файлов сессий data/sessions/&lt;tenant&gt.session.
/// </summary>
public string SessionsDirectory { get; }
/// <summary>
/// Создаёт опции с уже известными ключом и каталогом
/// </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.
/// </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);
}
}
namespace Deal.Telegram.Sessions;
/// <summary>
/// Конфигурация хранения сессий telegram-service.
/// </summary>
public sealed class TgOptions
{
/// <summary>
/// Env-ключ ключа шифрования сессий.
/// </summary>
public const string SessionKeyEnvVarName = "DEAL_TELEGRAM_SESSION_KEY";
/// <summary>
/// Env-ключ каталога сессий
/// </summary>
public const string SessionDirEnvVarName = "DEAL_TELEGRAM_SESSION_DIR";
/// <summary>
/// Относительный каталог сессий по умолчанию
/// </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;
}
public byte[] SessionKey { get; }
/// <summary>
/// Абсолютный путь к каталогу файлов сессий data/sessions/&lt;tenant&gt.session.
/// </summary>
public string SessionsDirectory { get; }
/// <summary>
/// Создаёт опции с уже известными ключом и каталогом
/// </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.
/// </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);
}
}
@@ -1,14 +1,14 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Фабрика реальных клиентов WTelegramClient.
/// </summary>
public sealed class ClientFactory : ITelegramClientFactory
{
/// <inheritdoc />
public ISessionClient Create(
int apiId,
string apiHash,
byte[]? storedSession)
=> new WTelegramSessionClient(apiId, apiHash, storedSession);
}
namespace Deal.Telegram.Telegram;
/// <summary>
/// Фабрика реальных клиентов WTelegramClient.
/// </summary>
public sealed class ClientFactory : ITelegramClientFactory
{
/// <inheritdoc />
public ISessionClient Create(
int apiId,
string apiHash,
byte[]? storedSession)
=> new WTelegramSessionClient(apiId, apiHash, storedSession);
}
@@ -1,27 +1,27 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Канон типов диалогов/источников контракта
/// </summary>
public static class DialogKinds
{
/// <summary>
/// Канал (broadcast)
/// </summary>
public const string Channel = "channel";
/// <summary>
/// Группа (базовая или супергруппа без тем)
/// </summary>
public const string Group = "group";
/// <summary>
/// Супергруппа с темами
/// </summary>
public const string Forum = "forum";
/// <summary>
/// Личный чат (пользователь)
/// </summary>
public const string Chat = "chat";
}
namespace Deal.Telegram.Telegram;
/// <summary>
/// Канон типов диалогов/источников контракта
/// </summary>
public static class DialogKinds
{
/// <summary>
/// Канал (broadcast)
/// </summary>
public const string Channel = "channel";
/// <summary>
/// Группа (базовая или супергруппа без тем)
/// </summary>
public const string Group = "group";
/// <summary>
/// Супергруппа с темами
/// </summary>
public const string Forum = "forum";
/// <summary>
/// Личный чат (пользователь)
/// </summary>
public const string Chat = "chat";
}
@@ -1,54 +1,54 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Сообщение выборки discovery-read для оценки кандидата в нейтральном для TL-слоя виде.
/// </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; }
}
namespace Deal.Telegram.Telegram;
/// <summary>
/// Сообщение выборки discovery-read для оценки кандидата в нейтральном для TL-слоя виде.
/// </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; }
}
@@ -1,54 +1,54 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Результат чтения выборки источника для оценки кандидата.
/// </summary>
public sealed record DiscoveryReadResult
{
/// <summary>
/// Код причины ok=false
/// </summary>
public const string NoHistoryError = "no_history";
/// <summary>
/// Пустой успешный результат.
/// </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>
/// Создаёт результат «история недоступна»
/// </summary>
public static DiscoveryReadResult NoHistory()
=> new(false, NoHistoryError, []);
/// <summary>
/// True — выборка получена; false — история недоступна без членства.
/// </summary>
public bool Ok { get; }
/// <summary>
/// Код причины при ok=false
/// </summary>
public string? Error { get; }
/// <summary>
/// Сообщения выборки
/// </summary>
public IReadOnlyList<DiscoveryMessage> Messages { get; }
}
namespace Deal.Telegram.Telegram;
/// <summary>
/// Результат чтения выборки источника для оценки кандидата.
/// </summary>
public sealed record DiscoveryReadResult
{
/// <summary>
/// Код причины ok=false
/// </summary>
public const string NoHistoryError = "no_history";
/// <summary>
/// Пустой успешный результат.
/// </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>
/// Создаёт результат «история недоступна»
/// </summary>
public static DiscoveryReadResult NoHistory()
=> new(false, NoHistoryError, []);
/// <summary>
/// True — выборка получена; false — история недоступна без членства.
/// </summary>
public bool Ok { get; }
/// <summary>
/// Код причины при ok=false
/// </summary>
public string? Error { get; }
/// <summary>
/// Сообщения выборки
/// </summary>
public IReadOnlyList<DiscoveryMessage> Messages { get; }
}
@@ -1,174 +1,174 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Абстракция клиента Telegram для сессии тенанта.
/// </summary>
public interface ISessionClient : IAsyncDisposable
{
/// <summary>
/// Авторизован ли клиент
/// </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
/// </summary>
public byte[]? SessionBytes { get; }
/// <summary>
/// Устанавливает соединение с Telegram
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public Task ConnectAsync(CancellationToken cancellationToken);
/// <summary>
/// Запрашивает SMS-код для номера.
/// </summary>
/// <param name="phone">Номер в международном формате (как ввёл пользователь).</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task RequestCodeAsync(string phone, CancellationToken cancellationToken);
/// <summary>
/// Отправляет SMS-код. Возвращает следующий запрашиваемый шаг
/// </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.
/// </summary>
/// <param name="password">Облачный пароль.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken);
/// <summary>
/// Выполняет QR-вход
/// </summary>
/// <param name="onQrUrl">Колбэк нового URL QR-входа (tg://login?token=...).</param>
/// <param name="cancellationToken">Отмена операции (прерывает ожидание сканирования).</param>
public Task StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken);
/// <summary>
/// Полный выход: отзывает авторизацию на стороне Telegram
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public Task LogOutAsync(CancellationToken cancellationToken);
/// <summary>
/// Возвращает строку аккаунта для статуса
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<string> GetAccountAsync(CancellationToken cancellationToken);
/// <summary>
/// Список диалогов аккаунта.
/// </summary>
/// <param name="limit">Верхняя граница числа диалогов.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Диалоги аккаунта в нейтральном виде.</returns>
public Task<IReadOnlyList<TelegramDialog>> GetDialogsAsync(int limit, CancellationToken cancellationToken);
/// <summary>
/// Последние сообщения диалога
/// </summary>
/// <param name="dialogId">Подписанный id диалога (каналы "-100…", группы "-…", личные "+…").</param>
/// <param name="limit">Сколько последних сообщений запросить.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Сообщения диалога (с канальными полями для PushSource).</returns>
public Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(
string dialogId,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Возвращает исходное сообщение диалога по id (remote-просмотр карточки).
/// </summary>
/// <param name="dialogId">Подписанный id диалога (каналы "-100…", группы "-…", личные "+…").</param>
/// <param name="msgId">Id сообщения в Telegram.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Сообщение с текстом либо null (медиа без текста/не найдено).</returns>
public Task<TelegramMessage?> GetMessageAsync(
string dialogId,
long msgId,
CancellationToken cancellationToken);
/// <summary>
/// Помечает весь диалог прочитанным.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Задача завершения.</returns>
public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken);
/// <summary>
/// Событие входящего текстового сообщения аккаунта.
/// </summary>
public event Func<TelegramMessage, Task>? MessageReceived;
/// <summary>
/// Глобальный поиск каналов/групп по ключу.
/// </summary>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
/// <param name="limit">Верхняя граница результата.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Найденные источники в нейтральном виде (каналы/группы/личные).</returns>
public Task<IReadOnlyList<TelegramDialog>> SearchAsync(
string query,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Инфо об источнике для оценки кандидата
/// </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>
/// Последние сообщения источника для оценки кандидата
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="limit">Размер выборки.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Результат чтения выборки (ok + сообщения либо no_history).</returns>
public Task<DiscoveryReadResult> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Вступить в канал/группу по username.
/// </summary>
/// <param name="username">Username канала/группы (без «@»).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Задача завершения (ok=true при успехе).</returns>
public Task JoinAsync(string username, CancellationToken cancellationToken);
/// <summary>
/// Выйти из канала/группы.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Задача завершения (ok=true при успехе).</returns>
public Task LeaveAsync(string dialogId, CancellationToken cancellationToken);
}
namespace Deal.Telegram.Telegram;
/// <summary>
/// Абстракция клиента Telegram для сессии тенанта.
/// </summary>
public interface ISessionClient : IAsyncDisposable
{
/// <summary>
/// Авторизован ли клиент
/// </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
/// </summary>
public byte[]? SessionBytes { get; }
/// <summary>
/// Устанавливает соединение с Telegram
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public Task ConnectAsync(CancellationToken cancellationToken);
/// <summary>
/// Запрашивает SMS-код для номера.
/// </summary>
/// <param name="phone">Номер в международном формате (как ввёл пользователь).</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task RequestCodeAsync(string phone, CancellationToken cancellationToken);
/// <summary>
/// Отправляет SMS-код. Возвращает следующий запрашиваемый шаг
/// </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.
/// </summary>
/// <param name="password">Облачный пароль.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken);
/// <summary>
/// Выполняет QR-вход
/// </summary>
/// <param name="onQrUrl">Колбэк нового URL QR-входа (tg://login?token=...).</param>
/// <param name="cancellationToken">Отмена операции (прерывает ожидание сканирования).</param>
public Task StartQrAsync(Action<string> onQrUrl, CancellationToken cancellationToken);
/// <summary>
/// Полный выход: отзывает авторизацию на стороне Telegram
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public Task LogOutAsync(CancellationToken cancellationToken);
/// <summary>
/// Возвращает строку аккаунта для статуса
/// </summary>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<string> GetAccountAsync(CancellationToken cancellationToken);
/// <summary>
/// Список диалогов аккаунта.
/// </summary>
/// <param name="limit">Верхняя граница числа диалогов.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Диалоги аккаунта в нейтральном виде.</returns>
public Task<IReadOnlyList<TelegramDialog>> GetDialogsAsync(int limit, CancellationToken cancellationToken);
/// <summary>
/// Последние сообщения диалога
/// </summary>
/// <param name="dialogId">Подписанный id диалога (каналы "-100…", группы "-…", личные "+…").</param>
/// <param name="limit">Сколько последних сообщений запросить.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Сообщения диалога (с канальными полями для PushSource).</returns>
public Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(
string dialogId,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Возвращает исходное сообщение диалога по id (remote-просмотр карточки).
/// </summary>
/// <param name="dialogId">Подписанный id диалога (каналы "-100…", группы "-…", личные "+…").</param>
/// <param name="msgId">Id сообщения в Telegram.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Сообщение с текстом либо null (медиа без текста/не найдено).</returns>
public Task<TelegramMessage?> GetMessageAsync(
string dialogId,
long msgId,
CancellationToken cancellationToken);
/// <summary>
/// Помечает весь диалог прочитанным.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Задача завершения.</returns>
public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken);
/// <summary>
/// Событие входящего текстового сообщения аккаунта.
/// </summary>
public event Func<TelegramMessage, Task>? MessageReceived;
/// <summary>
/// Глобальный поиск каналов/групп по ключу.
/// </summary>
/// <param name="query">Поисковый запрос (ключ задачи discovery).</param>
/// <param name="limit">Верхняя граница результата.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Найденные источники в нейтральном виде (каналы/группы/личные).</returns>
public Task<IReadOnlyList<TelegramDialog>> SearchAsync(
string query,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Инфо об источнике для оценки кандидата
/// </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>
/// Последние сообщения источника для оценки кандидата
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="limit">Размер выборки.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Результат чтения выборки (ok + сообщения либо no_history).</returns>
public Task<DiscoveryReadResult> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Вступить в канал/группу по username.
/// </summary>
/// <param name="username">Username канала/группы (без «@»).</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Задача завершения (ok=true при успехе).</returns>
public Task JoinAsync(string username, CancellationToken cancellationToken);
/// <summary>
/// Выйти из канала/группы.
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Задача завершения (ok=true при успехе).</returns>
public Task LeaveAsync(string dialogId, CancellationToken cancellationToken);
}
@@ -1,19 +1,19 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Фабрика клиентов 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);
}
namespace Deal.Telegram.Telegram;
/// <summary>
/// Фабрика клиентов 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);
}
@@ -1,62 +1,62 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Диалог (источник) аккаунта в нейтральном для TL-слоя виде.
/// </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 диалога
/// </summary>
public string Id { get; }
/// <summary>
/// Отображаемое имя
/// </summary>
public string Name { get; }
/// <summary>
/// Username (handle); пуст, если публичного username нет.
/// </summary>
public string Username { get; }
/// <summary>
/// Тип канона контракта
/// </summary>
public string Kind { get; }
/// <summary>
/// Число непрочитанных сообщений диалога
/// </summary>
public int UnreadCount { get; }
/// <summary>
/// Id самого свежего сообщения диалога.
/// </summary>
public int TopMessageId { get; }
}
namespace Deal.Telegram.Telegram;
/// <summary>
/// Диалог (источник) аккаунта в нейтральном для TL-слоя виде.
/// </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 диалога
/// </summary>
public string Id { get; }
/// <summary>
/// Отображаемое имя
/// </summary>
public string Name { get; }
/// <summary>
/// Username (handle); пуст, если публичного username нет.
/// </summary>
public string Username { get; }
/// <summary>
/// Тип канона контракта
/// </summary>
public string Kind { get; }
/// <summary>
/// Число непрочитанных сообщений диалога
/// </summary>
public int UnreadCount { get; }
/// <summary>
/// Id самого свежего сообщения диалога.
/// </summary>
public int TopMessageId { get; }
}
@@ -1,62 +1,62 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Текстовое сообщение диалога в нейтральном для TL-слоя виде.
/// </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) для source.display_name.</param>
/// <param name="dialogHandle">Username диалога.</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) — для source.display_name.
/// </summary>
public string DialogName { get; }
/// <summary>
/// Username диалога
/// </summary>
public string DialogHandle { get; }
}
namespace Deal.Telegram.Telegram;
/// <summary>
/// Текстовое сообщение диалога в нейтральном для TL-слоя виде.
/// </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) для source.display_name.</param>
/// <param name="dialogHandle">Username диалога.</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) — для source.display_name.
/// </summary>
public string DialogName { get; }
/// <summary>
/// Username диалога
/// </summary>
public string DialogHandle { get; }
}
@@ -1,62 +1,62 @@
namespace Deal.Telegram.Telegram;
/// <summary>
/// Инфо об источнике для оценки кандидата discovery в нейтральном для TL-слоя виде.
/// </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 диалога
/// </summary>
public string Id { get; }
/// <summary>
/// Отображаемое имя
/// </summary>
public string Name { get; }
/// <summary>
/// Username (handle); пуст, если публичного username нет.
/// </summary>
public string Username { get; }
/// <summary>
/// Тип канона контракта
/// </summary>
public string Kind { get; }
/// <summary>
/// Число участников
/// </summary>
public int? Participants { get; }
/// <summary>
/// True — мегагруппа с темами.
/// </summary>
public bool IsForum { get; }
}
namespace Deal.Telegram.Telegram;
/// <summary>
/// Инфо об источнике для оценки кандидата discovery в нейтральном для TL-слоя виде.
/// </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 диалога
/// </summary>
public string Id { get; }
/// <summary>
/// Отображаемое имя
/// </summary>
public string Name { get; }
/// <summary>
/// Username (handle); пуст, если публичного username нет.
/// </summary>
public string Username { get; }
/// <summary>
/// Тип канона контракта
/// </summary>
public string Kind { get; }
/// <summary>
/// Число участников
/// </summary>
public int? Participants { get; }
/// <summary>
/// True — мегагруппа с темами.
/// </summary>
public bool IsForum { get; }
}
@@ -1,205 +1,205 @@
using System.Globalization;
using Deal.Telegram.Sessions;
using Grpc.Core;
using TL;
namespace Deal.Telegram.Telegram;
/// <summary>
/// Чистый маппер TL-объектов каталога/сообщений в нейтральные типы сервиса.
/// </summary>
public static class TlMessageMapper
{
/// <summary>
/// Извлекает сообщение из «нового сообщения» обновления.
/// </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"/>
/// </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>
/// Превращает сообщение истории/обновления в нейтральное
/// </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.media is not null
|| (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);
}
/// <summary>
/// Сущность чата/канала результата contacts.SearchRequest → запись поиска.
/// </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 → запись поиска.
/// </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 → нейтральное
/// </summary>
/// <param name="message">Сообщение ленты/темы форума (MessageBase).</param>
/// <param name="topicId">Id темы форума (для обычных источников null).</param>
/// <param name="topicTitle">Название темы форума (для обычных источников null).</param>
/// <param name="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>
/// Тип диалога канона контракта по сущности чата/канала.
/// </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),
};
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;
private static long ToEpochMs(DateTime date)
=> new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)).ToUnixTimeMilliseconds();
}
using System.Globalization;
using Deal.Telegram.Sessions;
using Grpc.Core;
using TL;
namespace Deal.Telegram.Telegram;
/// <summary>
/// Чистый маппер TL-объектов каталога/сообщений в нейтральные типы сервиса.
/// </summary>
public static class TlMessageMapper
{
/// <summary>
/// Извлекает сообщение из «нового сообщения» обновления.
/// </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"/>
/// </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>
/// Превращает сообщение истории/обновления в нейтральное
/// </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.media is not null
|| (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);
}
/// <summary>
/// Сущность чата/канала результата contacts.SearchRequest → запись поиска.
/// </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 → запись поиска.
/// </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 → нейтральное
/// </summary>
/// <param name="message">Сообщение ленты/темы форума (MessageBase).</param>
/// <param name="topicId">Id темы форума (для обычных источников null).</param>
/// <param name="topicTitle">Название темы форума (для обычных источников null).</param>
/// <param name="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>
/// Тип диалога канона контракта по сущности чата/канала.
/// </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),
};
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;
private static long ToEpochMs(DateTime date)
=> new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)).ToUnixTimeMilliseconds();
}
File diff suppressed because it is too large Load Diff
@@ -1,72 +1,72 @@
using Deal.Grpc.Hosting.Interceptors;
using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
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.
/// </summary>
public static class TelegramServiceHost
{
/// <summary>
/// Создаёт (не запускает) хост
/// </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. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно.</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 ?? []);
MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
builder.Services.AddDealGrpcServer();
builder.Services.AddReadyHealthCheck("хост telegram-service готов");
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>();
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;
}
}
using Deal.Grpc.Hosting.Interceptors;
using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
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.
/// </summary>
public static class TelegramServiceHost
{
/// <summary>
/// Создаёт (не запускает) хост
/// </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. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно.</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 ?? []);
MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder);
GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates);
builder.Services.AddDealGrpcServer();
builder.Services.AddReadyHealthCheck("хост telegram-service готов");
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>();
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;
}
}
File diff suppressed because it is too large Load Diff