using System.Text.Json;
using Deal.Api.Events;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Telegram;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Grpc.Core;
namespace Deal.Api.Telegram;
///
/// gRPC-сервер входящего потока telegram-service → ядро.
///
public sealed class TelegramIngressService(
IServiceScopeFactory scopeFactory,
SseBroker broker,
ILogger logger) : IngressService.IngressServiceBase
{
public const string TenantIdMetadataKey = "tenant-id";
private const string SystemStatusEventType = "system_status";
private const string ToastEventType = "toast";
private const string ConnectedToastText = "Telegram подключён, сессия сохранена";
private const string DisconnectedToastText = "Telegram отключён";
// Иконка тоста подключения (из набора Icon.vue фронта).
private const string ConnectedToastIcon = "send";
// Иконка тоста отключения (из набора Icon.vue фронта).
private const string DisconnectedToastIcon = "logout";
// Деталь отказа: metadata tenant-id отсутствует (UNAUTHENTICATED, README src/contracts).
private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata";
private static readonly JsonSerializerOptions StatusJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
///
/// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью.
///
/// Сообщение из потока telegram-service.
/// Контекст вызова (metadata tenant-id + service-token).
/// accepted — сообщение принято (либо дубль), duplicate — уже было в очереди.
public override async Task PushMessage(PushMessageRequest request, ServerCallContext context)
{
TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
if (tenant is null)
{
return new PushMessageReply();
}
await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
try
{
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService();
PipelineIngestResultDto result = await ingest.EnqueueAsync(
new QueuedMessage
{
DialogId = request.DialogId,
ChannelName = request.ChannelName,
ChannelHandle = request.ChannelHandle,
ChannelHue = request.ChannelHue,
MsgId = request.HasMsgId ? request.MsgId : null,
Text = request.Text,
MsgAtMs = request.HasMsgAt ? request.MsgAt : null,
},
context.CancellationToken).ConfigureAwait(false);
await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Аудит: PushMessage {TenantId} диалог {DialogId} msg {MsgId} → {Outcome}",
tenant.Id,
request.DialogId,
request.HasMsgId ? request.MsgId.ToString() : "-",
result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued");
return new PushMessageReply
{
Accepted = result.Id is not null || result.Duplicate,
Duplicate = result.Duplicate,
};
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id);
return new PushMessageReply();
}
finally
{
tenantContext.Reset();
}
}
///
/// SyncDialogs — синхронизация каталога диалогов аккаунта.
///
/// Актуальный каталог диалогов (entries).
/// Контекст вызова.
/// monitored_ids — диалоги с включённым мониторингом после применения каталога.
public override async Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context)
{
TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
if (tenant is null)
{
return new SyncDialogsReply();
}
await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService();
List entries = new(request.Entries.Count);
foreach (DialogEntry entry in request.Entries)
{
entries.Add(new TelegramDialogEntryDto(entry.Id, entry.Name, entry.Username, entry.Kind, entry.Hue));
}
int synced = await dialogs.SyncFromTelegramAsync(entries, context.CancellationToken).ConfigureAwait(false);
IReadOnlyCollection monitoredIds =
await dialogs.ListMonitoredIdsAsync(context.CancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Аудит: SyncDialogs {TenantId}: каталог {Count} → применено {Synced}, monitored {Monitored}",
tenant.Id,
request.Entries.Count,
synced,
monitoredIds.Count);
var reply = new SyncDialogsReply();
reply.MonitoredIds.AddRange(monitoredIds);
return reply;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Аудит: SyncDialogs {TenantId} → каталог не применён (сбой схемы/БД)", tenant.Id);
return new SyncDialogsReply();
}
finally
{
tenantContext.Reset();
}
}
///
/// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз.
///
/// Статус аккаунта из.
/// Контекст вызова.
/// ok — статус принят и сохранён.
public override async Task ReportStatus(ReportStatusRequest request, ServerCallContext context)
{
TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
if (tenant is null)
{
return new ReportStatusReply();
}
await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService();
TgReportedStatus current = ToReportedStatus(request);
TgReportedStatus? previous = await ReadPreviousStatusAsync(settings, context.CancellationToken).ConfigureAwait(false);
// SSE до записи KV: канал тенанта обновляется и при сбое записи (следующий репорт перепишет KV).
PublishStatusEvents(tenant.Id, previous, current);
await settings.SetAsync(SettingsKeys.TgStatus, ToJson(current), context.CancellationToken).ConfigureAwait(false);
await settings.SetAsync(SettingsKeys.TgAccount, JsonSerializer.Serialize(request.Account), context.CancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Аудит: ReportStatus {TenantId} → фаза {Phase}, connected {Connected}",
tenant.Id,
request.Phase,
request.Connected);
return new ReportStatusReply { Ok = true };
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Аудит: ReportStatus {TenantId} → не сохранён (сбой схемы/БД)", tenant.Id);
return new ReportStatusReply();
}
finally
{
tenantContext.Reset();
}
}
private async Task ResolveTenantAsync(ServerCallContext context)
{
string tenantId = RequireTenantIdMetadata(context);
if (!Guid.TryParse(tenantId, out Guid tenantGuid))
{
logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId);
return null;
}
await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope();
ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService();
TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false);
if (tenant is null)
{
logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId);
}
return tenant;
}
// Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, README).
// context: Контекст вызова.
// Возвращает: Значение tenant-id.
private static string RequireTenantIdMetadata(ServerCallContext context)
{
string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail));
}
return tenantId;
}
private async Task SavePreviewSafelyAsync(
AsyncServiceScope tenantScope,
TenantRecordDto tenant,
PushMessageRequest request,
CancellationToken ct)
{
try
{
DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService();
DateTimeOffset? msgAt = request.HasMsgAt ? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt) : null;
await dialogs.SavePreviewAsync(
request.DialogId,
request.HasMsgId ? request.MsgId : null,
request.Text,
msgAt,
ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
// Превью — вторичная запись: приём не затронут (лог дебага, не ошибка RPC).
logger.LogDebug(exception, "PushMessage {TenantId}: превью не сохранено (приём не затронут)", tenant.Id);
}
}
// Публикует SSE system_status (каждый репорт) и тосты на переходах connected.
// tenantId: Тенант канала (реестровый Guid).
// previous: Предыдущий снимок из KV (null — первый репорт).
// current: Текущий снимок репорта.
private void PublishStatusEvents(
Guid tenantId,
TgReportedStatus? previous,
TgReportedStatus current)
{
broker.Publish(tenantId, SystemStatusEventType, current);
if (previous is null)
{
// Первый репорт после старта сервиса: переходов нет, статус фронт получит по system_status.
return;
}
if (!previous.Connected && current.Connected)
{
PublishToast(tenantId, ConnectedToastText, ConnectedToastIcon);
}
else if (previous.Connected && !current.Connected)
{
PublishToast(tenantId, DisconnectedToastText, DisconnectedToastIcon);
}
}
private void PublishToast(
Guid tenantId,
string text,
string icon)
{
broker.Publish(tenantId, ToastEventType, new { text, icon });
}
// Снимок предыдущего статуса из KV tgStatus (нет записи/битый JSON — null).
// settings: KV-хранилище настроек схемы тенанта.
// ct: Токен отмены.
// Возвращает: Предыдущий снимок либо null.
private async Task ReadPreviousStatusAsync(ISettingsStore settings, CancellationToken ct)
{
SettingValue? stored = await settings.GetAsync(SettingsKeys.TgStatus, ct).ConfigureAwait(false);
if (stored is null)
{
return null;
}
try
{
return JsonSerializer.Deserialize(stored.ValueJson, StatusJsonOptions);
}
catch (JsonException exception)
{
logger.LogWarning(exception, "Аудит: KV tgStatus повреждён — переходы фаз не определяются");
return null;
}
}
// Маппит запрос ReportStatus в снимок KV (account живёт отдельным ключом tgAccount).
// request: Запрос ReportStatus.
// Возвращает: Снимок статуса.
private static TgReportedStatus ToReportedStatus(ReportStatusRequest request) => new()
{
Phase = request.Phase,
Connected = request.Connected,
Listener = request.Listener,
Error = request.HasError ? request.Error : null,
QrUrl = request.HasQrUrl ? request.QrUrl : null,
};
// Сериализует снимок в JSON (camelCase, конвенция value_json).
// status: Снимок статуса.
// Возвращает: JSON-строка.
private static string ToJson(TgReportedStatus status) => JsonSerializer.Serialize(status, StatusJsonOptions);
}