using Deal.Grpc.Hosting; using Deal.Grpc.Telegram; using Deal.Telegram.Sessions; using Grpc.Core; using Grpc.Net.Client; namespace Deal.Telegram.Core; /// /// Исходящий gRPC-канал в ядро: клиент Deal.Grpc.Telegram.IngressService (план Task 10, Core/ /// CoreIngressClient.cs; Ruling 1/7). Каждый RPC несёт metadata tenant-id + service-token (Ruling 1); /// принадлежность сообщений тенанту — только по metadata (ядро не доверяет полю). Сбой связи → /// UNAVAILABLE «Ядро недоступно…» с логом: буфер недоставленных /// сообщений не ведётся — неподтверждённые сообщения не помечаются прочитанными и догоняются /// realtime_sweep (упущенное после рестарта/разрыва, Ruling 7/план Task 10). /// public sealed class CoreIngressClient : ICoreIngressClient { /// /// Ключ gRPC-metadata с id тенанта (зеркало интерцепторов, Ruling 1). /// public const string TenantIdMetadataKey = "tenant-id"; /// /// Ключ gRPC-metadata с service-token (зеркало интерцепторов, Ruling 1). /// public const string ServiceTokenMetadataKey = "service-token"; private readonly CoreIngressOptions _options; private readonly ILogger _logger; private readonly MtlsCertificates? _mtlsCertificates; private readonly object _channelGate = new(); private IngressService.IngressServiceClient? _client; private GrpcChannel? _channel; /// /// Создаёт клиент ингресса ядра. /// /// Конфигурация (адрес из env, service-token). /// Логгер. /// Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал (dev). public CoreIngressClient(CoreIngressOptions options, ILogger logger, MtlsCertificates? mtlsCertificates = null) { _options = options; _logger = logger; _mtlsCertificates = mtlsCertificates; } /// public async Task PushMessageAsync(string tenantId, PushMessageRequest message, CancellationToken cancellationToken) { IngressService.IngressServiceClient client = GetClient(); try { return await client.PushMessageAsync(message, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is not OperationCanceledException) { throw Fail("PushMessage", exception); } } /// public async Task> SyncDialogsAsync(string tenantId, IReadOnlyList entries, CancellationToken cancellationToken) { IngressService.IngressServiceClient client = GetClient(); var request = new SyncDialogsRequest(); request.Entries.AddRange(entries); try { SyncDialogsReply reply = await client.SyncDialogsAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false); return reply.MonitoredIds; } catch (Exception exception) when (exception is not OperationCanceledException) { throw Fail("SyncDialogs", exception); } } // Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса). private IngressService.IngressServiceClient GetClient() { lock (_channelGate) { if (_client is null) { // mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским // сертификатом и проверяет CA ядра; dev — plaintext-канал (Ruling 2 этапа 6). if (_mtlsCertificates is not null) { _channel = GrpcChannel.ForAddress( _options.IngressEndpoint, new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() }); } else { _channel = GrpcChannel.ForAddress(_options.IngressEndpoint); } _client = new IngressService.IngressServiceClient(_channel); } return _client; } } // Metadata вызова (tenant-id + service-token) и deadline (Ruling 1). // tenantId: Id тенанта. private CallOptions CallOptions(string tenantId) { var metadata = new Metadata { { TenantIdMetadataKey, tenantId }, { ServiceTokenMetadataKey, _options.ServiceToken }, }; return new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(CoreIngressOptions.RpcTimeoutSeconds)); } // Переводит сбой вызова в SessionException (UNAVAILABLE) со структурированным логом. // action: Действие (PushMessage/SyncDialogs) для лога аудита (Ruling 13). // exception: Исключение вызова. private SessionException Fail(string action, Exception exception) { _logger.LogWarning(exception, "Аудит: ингресс ядра {Action} → сбой ({Endpoint})", action, _options.IngressEndpoint); return new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable, exception); } }