Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,210 @@
using System.Collections.ObjectModel;
namespace Deal.Telegram.Dialogs;
/// <summary>
/// Зеркало каталога диалогов тенанта в памяти telegram-service (план Task 10, Dialogs/DialogCatalog.cs;
/// Ruling 7: «сервис держит зеркало мониторинга в памяти», ядро — владелец списка мониторинга в своей БД).
///
/// Тенант хранит два набора id диалогов: полный каталог (из refresh_dialogs/realtime_sweep, L468–519)
/// и подмножество с включённым мониторингом. Мониторинг актуализируется тремя путями:
/// * командой SetMonitor/SetMonitorAll ядра (обновление одной записи / всех сразу);
/// * ответом Ingress.SyncDialogs (ядро применило entries с autoMonitorNew — L244–246 «_reload_monitored»);
/// * очисткой после Logout/отключения аккаунта (прототип L203: `_monitored.clear()`).
/// Все операции потокобезопасны; зеркало чисто в памяти — потерю при рестарте догоняет realtime_sweep.
/// </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>
/// Обновляет полный каталог диалогов тенанта (entries refresh/realtime_sweep). Мониторинг при этом
/// не трогается — актуальный monitored-набор приходит ответом SyncDialogs (или командами SetMonitor).
/// </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>
/// Включает/выключает мониторинг одного диалога (SetMonitor RPC, set_monitor L536–546). Команда
/// приходит от ядра после обновления его БД; зеркало повторяет решение без собственной проверки
/// «известности» — запись может появиться раньше каталога (включение после рестарта сервиса).
/// </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 (актуальный список ядра после SyncFromTelegram —
/// авто-мониторинг новых/удаление отсутствующих, Ruling 7). Это авторитетный источник зеркала.
/// </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>
/// Включает/выключает мониторинг всех диалогов каталога (SetMonitorAll RPC, set_monitor_all
/// L548–567). При включении учитываются и уже мониторящиеся записи (каталог может отставать от БД
/// ядра после рестарта — «монитор» из ответа SyncDialogs приедет следующим циклом realtime_sweep).
/// </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>
/// Проверяет, мониторится ли диалог (фильтр realtime-событий L262 и догона sweep L429).
/// </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>
/// Сколько диалогов знает каталог тенанта (ответ monitor-all, count каталога).
/// </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>
/// Сбрасывает состояние тенанта (Logout/отключение аккаунта — прототип L203).
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
public void Reset(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return;
}
lock (_gate)
{
_knownByTenant.Remove(tenantId);
_monitoredByTenant.Remove(tenantId);
}
}
// Полный каталог тенанта (создаёт пустой при первом обращении). Вызывается под _gate.
// tenantId: Id тенанта.
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;
}
// Monitored-набор тенанта (создаёт пустой при первом обращении). Вызывается под _gate.
// tenantId: Id тенанта.
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;
}
}