Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,258 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Telegram.Application.Models;
using Microsoft.Extensions.Logging;
namespace Deal.Modules.Telegram.Application;
/// <summary>
/// Сервис каталога диалогов/каналов тенанта — владелец зеркала мониторинга ядра.
/// </summary>
/// <param name="store">Хранилище каталога (Dialogs/TgMessages схемы тенанта).</param>
/// <param name="settings">KV-настройки тенанта (autoMonitorNew).</param>
/// <param name="gateway">Порт-гейт к telegram-service (SetMonitor/SetMonitorAll/Backfill наружу).</param>
/// <param name="logger">Логгер (сбои зеркала/превью — наблюдаемость, Security review).</param>
public sealed class DialogsService(
ITelegramStore store,
ISettingsStore settings,
ITelegramGateway gateway,
ILogger<DialogsService> logger)
{
private const int PreviewTextMaxLength = 4000;
private const int DialogLastTextMaxLength = 200;
/// <summary>
/// Список диалогов каталога для вкладки «Каналы».
/// </summary>
/// <returns>Диалоги (включённые мониторингом первыми) в форме.</returns>
public Task<IReadOnlyList<TelegramDialogDto>> ListAsync(CancellationToken ct) => store.ListAsync(ct);
/// <summary>
/// Id диалогов с включённым мониторингом.
/// </summary>
/// <returns>Id мониторящихся диалогов каталога.</returns>
public Task<IReadOnlyCollection<string>> ListMonitoredIdsAsync(CancellationToken ct) =>
store.ListMonitoredIdsAsync(ct);
/// <summary>
/// Применяет каталог диалогов telegram-service
/// </summary>
/// <param name="entries">Актуальный каталог диалогов аккаунта (id/name/handle/kind/hue).</param>
/// <returns>Число применённых записей (= entries.Count; 0 — пустой каталог).</returns>
public async Task<int> SyncFromTelegramAsync(IReadOnlyCollection<TelegramDialogEntryDto> entries, CancellationToken ct)
{
bool autoMonitorNew = (await TenantSettingsSnapshot.LoadAsync(settings, ct))
.GetBool(SettingsKeys.AutoMonitorNew, SettingsDefaults.AutoMonitorNew);
return await store.SyncFromTelegramAsync(entries, autoMonitorNew, ct).ConfigureAwait(false);
}
/// <summary>
/// Включает/выключает мониторинг диалога и синхронизирует зеркало telegram-service.
/// </summary>
/// <param name="dialogId">Id диалога каталога.</param>
/// <param name="enabled">True — мониторить, false — выключить.</param>
/// <returns>Результат: зеркальное enabled + признак «нужен первый фоновый Backfill».</returns>
public async Task<TelegramMonitorToggleDto> SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct)
{
bool? backfilled = await store.GetBackfilledAsync(dialogId, ct).ConfigureAwait(false);
if (backfilled is null)
{
return new TelegramMonitorToggleDto(enabled, BackfillNeeded: false);
}
await store.SetMonitorAsync(dialogId, enabled, ct).ConfigureAwait(false);
await gateway.SetMonitorAsync(dialogId, enabled, ct).ConfigureAwait(false);
return new TelegramMonitorToggleDto(enabled, enabled && !backfilled.Value);
}
/// <summary>
/// Включает/выключает мониторинг всех диалогов + зеркало сервиса.
/// </summary>
/// <param name="enabled">True — мониторить все диалоги каталога, false — снять со всех.</param>
/// <returns>Число диалогов каталога + список неразобранных при включении (фоновый Backfill — Api-слой).</returns>
public async Task<TelegramMonitorAllDto> SetMonitorAllAsync(bool enabled, CancellationToken ct)
{
int count = await store.SetMonitorAllAsync(enabled, ct).ConfigureAwait(false);
await gateway.SetMonitorAllAsync(enabled, ct).ConfigureAwait(false);
IReadOnlyCollection<string> notBackfilled = enabled
? await store.ListNotBackfilledIdsAsync(ct).ConfigureAwait(false)
: [];
return new TelegramMonitorAllDto(count, notBackfilled.ToList());
}
/// <summary>
/// Помечает диалог разобранным — первый backfill завершён.
/// </summary>
/// <param name="dialogId">Id диалога.</param>
/// <returns>Завершается после обновления строки (нет строки — no-op).</returns>
public Task MarkBackfilledAsync(string dialogId, CancellationToken ct) =>
store.SetBackfilledAsync(dialogId, ct);
/// <summary>
/// Добавляет источник после discovery-вступления
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
/// <param name="name">Имя источника (пустое → dialogId).</param>
/// <param name="handle">Username источника (пусто — нет публичного username).</param>
/// <param name="kind">Тип источника.</param>
/// <param name="hue">Цвет источника (пустое → дефолт «#666»).</param>
/// <returns>Завершается после записи каталога и команды зеркалу.</returns>
public async Task AddDiscoveredMonitoredAsync(
string dialogId,
string name,
string handle,
string kind,
string hue,
CancellationToken ct)
{
await store.UpsertDiscoveredMonitoredAsync(
dialogId,
string.IsNullOrWhiteSpace(name) ? dialogId : name.Trim(),
handle ?? string.Empty,
kind ?? string.Empty,
string.IsNullOrWhiteSpace(hue) ? SourceDefaults.DefaultHue : hue,
ct).ConfigureAwait(false);
try
{
await gateway.SetMonitorAsync(dialogId, true, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
// Вступление уже состоялось, строка каталога записана: сбой зеркала — не ошибка шага
// (в worker-авто-join та же семантика — зеркало обновится ближайшей синхронизацией),
// но фиксируется в логе, иначе регулярные сбои зеркала невидимы (Security review).
logger.LogWarning(exception, "Сбой зеркала SetMonitor({DialogId}) после вступления", dialogId);
}
}
/// <summary>
/// «Перечитать»: последние сообщения всех включённых каналов.
/// </summary>
/// <returns>Сколько включённых каналов отправлено на перечитывание (0 — мониторящихся нет).</returns>
public async Task<int> ReadRecentAsync(CancellationToken ct)
{
IReadOnlyCollection<string> monitoredIds = await store.ListMonitoredIdsAsync(ct).ConfigureAwait(false);
foreach (string dialogId in monitoredIds)
{
try
{
await BackfillOneAsync(dialogId, force: true, ct).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested)
{
logger.LogWarning(exception, "Перечитывание канала {DialogId} не удалось", dialogId);
}
}
return monitoredIds.Count;
}
/// <summary>
/// Разбор последних ~10 сообщений одного диалога.
/// </summary>
/// <param name="dialogId">Id диалога каталога.</param>
/// <param name="force">True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»).</param>
/// <returns>Сколько сообщений отправлено в ядро потоком PushMessage (0 — выхода нет/сообщений нет).</returns>
public async Task<int> BackfillOneAsync(
string dialogId,
bool force,
CancellationToken ct)
{
bool? backfilled = await store.GetBackfilledAsync(dialogId, ct).ConfigureAwait(false);
if (backfilled is null || (!force && backfilled.Value))
{
return 0;
}
int processed = await gateway.BackfillAsync(dialogId, force, ct).ConfigureAwait(false);
await store.SetBackfilledAsync(dialogId, ct).ConfigureAwait(false);
return processed;
}
/// <summary>
/// Последние сообщения диалога для превью.
/// </summary>
/// <param name="dialogId">Id диалога.</param>
/// <param name="limit">Сколько последних сообщений.</param>
/// <returns>Сообщения от новых к старым в форме ({id, text, time, lead}).</returns>
public async Task<IReadOnlyList<TelegramMessageDto>> PreviewAsync(
string dialogId,
int limit,
CancellationToken ct)
{
IReadOnlyList<TelegramRecentMessageDto> fresh;
try
{
fresh = await gateway.ReadRecentAsync(dialogId, limit, ct).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested)
{
logger.LogWarning(exception, "Чтение свежих сообщений диалога {DialogId} не удалось — превью из БД", dialogId);
fresh = [];
}
if (fresh.Count == 0)
{
return await store.ListMessagesAsync(dialogId, limit, ct).ConfigureAwait(false);
}
Dictionary<string, TelegramMessageDto> existingRows = (await store.ListMessagesAsync(dialogId, limit, ct).ConfigureAwait(false))
.ToDictionary(message => message.Id, StringComparer.Ordinal);
var items = new List<TelegramMessageDto>(fresh.Count);
foreach (TelegramRecentMessageDto message in fresh)
{
string rowId = $"m_{dialogId}_{message.Id}";
bool lead = existingRows.TryGetValue(rowId, out TelegramMessageDto? row) && row.Lead;
items.Add(new TelegramMessageDto(message.Id, message.Text, message.TimeMs, lead));
}
return items;
}
/// <summary>
/// Сохраняет превью принятого сообщения
/// </summary>
/// <param name="dialogId">Id диалога-источника.</param>
/// <param name="msgId">Id сообщения в Telegram (null — только «последнее сообщение» каталога).</param>
/// <param name="text">Текст сообщения (непустой).</param>
/// <param name="msgAt">Время исходного сообщения (UTC; null — сейчас).</param>
/// <returns>Завершается после обновления каталога/TgMessages (дубли превью — no-op).</returns>
public async Task SavePreviewAsync(
string dialogId,
long? msgId,
string text,
DateTimeOffset? msgAt,
CancellationToken ct)
{
string trimmed = text.Trim();
if (trimmed.Length == 0)
{
return;
}
DateTimeOffset at = msgAt ?? DateTimeOffset.UtcNow;
await store.TouchDialogLastAsync(dialogId, Truncate(trimmed, DialogLastTextMaxLength), at, ct).ConfigureAwait(false);
if (msgId is null)
{
return;
}
string previewId = $"m_{dialogId}_{msgId}";
await store.SavePreviewAsync(previewId, dialogId, Truncate(text, PreviewTextMaxLength), at, ct).ConfigureAwait(false);
}
private static string Truncate(string value, int maxLength)
=> value.Length <= maxLength ? value : value[..maxLength];
}
@@ -0,0 +1,127 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Telegram.Application.Models;
namespace Deal.Modules.Telegram.Application;
/// <summary>
/// Порт хранилища каталога диалогов тенанта
/// </summary>
public interface ITelegramStore
{
/// <summary>
/// Применяет каталог диалогов telegram-service.
/// </summary>
/// <param name="entries">Актуальный каталог (id/name/handle/kind/hue).</param>
/// <param name="autoMonitorNew">Мониторить ли новые диалоги (настройка autoMonitorNew, дефолт true).</param>
/// <returns>Число применённых записей каталога (= entries.Count; 0 — пустой каталог).</returns>
public Task<int> SyncFromTelegramAsync(
IReadOnlyCollection<TelegramDialogEntryDto> entries,
bool autoMonitorNew,
CancellationToken ct);
/// <summary>
/// Список диалогов каталога для GET /api/tg/dialogs.
/// </summary>
/// <returns>Диалоги каталога (включённые мониторингом — первыми, далее по имени).</returns>
public Task<IReadOnlyList<TelegramDialogDto>> ListAsync(CancellationToken ct);
/// <summary>
/// Id диалогов с включённым мониторингом.
/// </summary>
/// <returns>Id строк Dialogs, где Monitor=true.</returns>
public Task<IReadOnlyCollection<string>> ListMonitoredIdsAsync(CancellationToken ct);
/// <summary>
/// Включает/выключает мониторинг диалога.
/// </summary>
/// <param name="dialogId">Id диалога каталога.</param>
/// <param name="enabled">True — мониторить, false — выключить.</param>
/// <returns>Завершается после обновления строки (нет строки — no-op).</returns>
public Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct);
/// <summary>
/// Включает/выключает мониторинг всех диалогов каталога.
/// </summary>
/// <param name="enabled">True — мониторить все, false — снять мониторинг со всех.</param>
/// <returns>Сколько диалогов в каталоге.</returns>
public Task<int> SetMonitorAllAsync(bool enabled, CancellationToken ct);
/// <summary>
/// Флаг разобранности диалога
/// </summary>
/// <param name="dialogId">Id диалога.</param>
/// <returns>Backfilled диалога либо null, если строки нет.</returns>
public Task<bool?> GetBackfilledAsync(string dialogId, CancellationToken ct);
/// <summary>
/// Id неразобранных диалогов каталога.
/// </summary>
/// <returns>Id строк Dialogs с Backfilled=false.</returns>
public Task<IReadOnlyCollection<string>> ListNotBackfilledIdsAsync(CancellationToken ct);
/// <summary>
/// Помечает диалог разобранным.
/// </summary>
/// <param name="dialogId">Id диалога.</param>
/// <returns>Завершается после обновления строки (нет строки — no-op).</returns>
public Task SetBackfilledAsync(string dialogId, CancellationToken ct);
/// <summary>
/// Пишет/обновляет строку каталога после discovery-вступления.
/// </summary>
/// <param name="dialogId">Подписанный id источника (первичный ключ).</param>
/// <param name="name">Имя источника (уже с фолбэком на dialogId).</param>
/// <param name="handle">Username источника (пуст, если нет публичного username).</param>
/// <param name="kind">Тип источника.</param>
/// <param name="hue">Цвет источника (уже с дефолтом «#666»).</param>
/// <returns>Завершается после записи.</returns>
public Task UpsertDiscoveredMonitoredAsync(
string dialogId,
string name,
string handle,
string kind,
string hue,
CancellationToken ct);
/// <summary>
/// Пишет строку превью сообщения в TgMessages.
/// </summary>
/// <param name="messageId">Id строки превью («m_&lt;dialog&gt;_&lt;msg&gt;»).</param>
/// <param name="dialogId">Id диалога-источника.</param>
/// <param name="text">Текст сообщения.</param>
/// <param name="msgAt">Время сообщения (UTC).</param>
/// <returns>Завершается после вставки (дубль — no-op).</returns>
public Task SavePreviewAsync(
string messageId,
string dialogId,
string text,
DateTimeOffset msgAt,
CancellationToken ct);
/// <summary>
/// Обновляет «последнее сообщение» диалога в каталоге.
/// </summary>
/// <param name="dialogId">Id диалога.</param>
/// <param name="text">Текст последнего сообщения.</param>
/// <param name="at">Момент приёма сообщения (UTC).</param>
/// <returns>Завершается после обновления строки (нет строки — no-op).</returns>
public Task TouchDialogLastAsync(
string dialogId,
string text,
DateTimeOffset at,
CancellationToken ct);
/// <summary>
/// Фолбэк превью из БД
/// </summary>
/// <param name="dialogId">Id диалога.</param>
/// <param name="limit">Сколько последних сообщений (≤50).</param>
/// <returns>Сообщения TgMessages диалога от новых к старым (lead — по LeadId).</returns>
public Task<IReadOnlyList<TelegramMessageDto>> ListMessagesAsync(
string dialogId,
int limit,
CancellationToken ct);
}
@@ -0,0 +1,20 @@
namespace Deal.Modules.Telegram.Application.Models;
/// <summary>
/// Диалог/канал каталога тенанта — элемент GET /api/tg/dialogs.
/// </summary>
/// <param name="Id">Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»).</param>
/// <param name="Name">Отображаемое имя диалога.</param>
/// <param name="Handle">Username (handle) источника; пуст, если нет публичного username.</param>
/// <param name="Type">Тип источника: channel|group|forum|chat (EN-канон telegram.proto).</param>
/// <param name="Hue">Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»).</param>
/// <param name="On">Признак мониторинга (фронт по нему рисует переключатель канала).</param>
/// <param name="Last">Последнее сообщение диалога (после последнего реального PushMessage).</param>
public sealed record TelegramDialogDto(
string Id,
string Name,
string Handle,
string Type,
string Hue,
bool On,
TelegramDialogLastDto? Last);
@@ -0,0 +1,8 @@
namespace Deal.Modules.Telegram.Application.Models;
/// <summary>
/// Последнее сообщение диалога — поле <c>last</c> элемента списка каналов.
/// </summary>
/// <param name="Text">Текст последнего принятого сообщения.</param>
/// <param name="TimeMs">Время последнего сообщения, epoch-ms (null — сообщений ещё не было).</param>
public sealed record TelegramDialogLastDto(string Text, long? TimeMs);
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace Deal.Modules.Telegram.Application.Models;
/// <summary>
/// Сообщение превью диалога — элемент POST /api/tg/dialogs/preview.
/// </summary>
/// <param name="Id">Id сообщения (строка; «m_&lt;dialog&gt;_&lt;msg&gt;» для фолбэка БД).</param>
/// <param name="Text">Текст сообщения.</param>
/// <param name="TimeMs">Время сообщения, epoch-ms (JSON «time»).</param>
/// <param name="Lead">True — по сообщению уже создана карточка (LeadId не пуст).</param>
public sealed record TelegramMessageDto(
string Id,
string Text,
[property: JsonPropertyName("time")] long TimeMs,
bool Lead);
@@ -0,0 +1,8 @@
namespace Deal.Modules.Telegram.Application.Models;
/// <summary>
/// Результат включения/выключения мониторинга всех диалогов — ответ SetMonitorAllAsync.
/// </summary>
/// <param name="Count">Сколько диалогов в каталоге тенанта.</param>
/// <param name="BackfillNeededIds">Id неразобранных диалогов при enabled=true (пусто при выключении).</param>
public sealed record TelegramMonitorAllDto(int Count, IReadOnlyList<string> BackfillNeededIds);
@@ -0,0 +1,8 @@
namespace Deal.Modules.Telegram.Application.Models;
/// <summary>
/// Результат переключения мониторинга одного диалога — ответ SetMonitorAsync.
/// </summary>
/// <param name="Enabled">Зеркальное значение включения (для ответов эндпоинтов {ok, enabled}).</param>
/// <param name="BackfillNeeded">True — включение первого раза: диалог не был разобран (нужен фоновый Backfill).</param>
public sealed record TelegramMonitorToggleDto(bool Enabled, bool BackfillNeeded);
@@ -0,0 +1,22 @@
namespace Deal.Modules.Telegram.Application.Models;
/// <summary>
/// Статус вкладки Telegram — GET /api/tg/status и payload SSE system_status.
/// </summary>
/// <param name="Phase">Фаза входа: idle|phone|code|password|qr|ready.</param>
/// <param name="Connected">Клиент Telegram подключён и авторизован.</param>
/// <param name="Listener">Жив ли realtime-listener (поток новых сообщений → PushMessage).</param>
/// <param name="Account">Аккаунт «@username» из KV tgAccount (источник истины — ReportStatus).</param>
/// <param name="Monitored">Число каналов с мониторингом: count(Dialogs WHERE Monitor).</param>
/// <param name="KeysSet">Сохранены ли ключи приложения (api_id/api_hash) в настройках tgKeys.</param>
/// <param name="Error">Текст последней ошибки, либо null.</param>
/// <param name="QrUrl">URL QR-входа, заполнен только при phase == "qr", либо null.</param>
public sealed record TgStatusDto(
string Phase,
bool Connected,
bool Listener,
string Account,
int Monitored,
bool KeysSet,
string? Error,
string? QrUrl);
@@ -0,0 +1,20 @@
using Deal.Modules.Cards.Application.Sources;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Modules.Telegram.Application;
/// <summary>
/// DI-регистрация модуля Telegram.
/// </summary>
public static class TelegramModuleRegistrar
{
/// <summary>
/// Регистрирует сервисы модуля Telegram в контейнере.
/// </summary>
public static IServiceCollection AddTelegramModule(this IServiceCollection services)
{
services.AddScoped<DialogsService>();
services.AddScoped<ISourceIngestObserver, TelegramSourceIngestObserver>();
return services;
}
}
@@ -0,0 +1,47 @@
using Deal.Modules.Cards.Application.Sources;
using Microsoft.Extensions.Logging;
namespace Deal.Modules.Telegram.Application;
/// <summary>
/// Превью принятых сообщений источника telegram: последнее сообщение каталога и TgMessages.
/// </summary>
/// <param name="dialogs">Каталог диалогов тенанта.</param>
/// <param name="logger">Логгер сбоев записи.</param>
public sealed class TelegramSourceIngestObserver(
DialogsService dialogs,
ILogger<TelegramSourceIngestObserver> logger) : ISourceIngestObserver
{
private const string TelegramKind = "telegram";
/// <inheritdoc />
public async Task OnIngestedAsync(SourceItem item, CancellationToken ct)
{
if (!string.Equals(item.Source.Kind, TelegramKind, StringComparison.OrdinalIgnoreCase))
{
return;
}
string dialogId = item.Source.OriginRef ?? string.Empty;
if (dialogId.Length == 0)
{
return;
}
long? msgId = long.TryParse(item.Source.ExternalId, out long parsed) ? parsed : null;
try
{
await dialogs
.SavePreviewAsync(dialogId, msgId, item.Content.Text ?? string.Empty, item.Source.ReceivedAt, ct)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
logger.LogDebug(exception, "Превью {DialogId} не сохранено (приём не затронут)", dialogId);
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
<ProjectReference Include="..\Deal.Contracts\Deal.Contracts.csproj" />
<ProjectReference Include="..\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
<ProjectReference Include="..\Deal.Modules.Cards\Deal.Modules.Cards.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,8 @@
namespace Deal.Modules.Telegram;
/// <summary>
/// Маркер модуля Telegram
/// </summary>
public interface ITelegramModule
{
}