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:
@@ -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_<dialog>_<msg>»).</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_<dialog>_<msg>» для фолбэка БД).</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
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user