using System.Text.Json; using Deal.Modules.Settings.Application; namespace Deal.Modules.Discovery.Application; /// /// Бан-гард авто-вступлений Discovery: суточный лимит, flood-день, стоп-кран (1:1 ban_guard.py L1–81, план Task 18, Ruling 10). /// /// /// Суточный лимит (discJoinLimit, дефолт 50) считается по DiscLog: число событий event='join_auto' за текущие /// UTC-сутки (дискретность совпадает с логом mark_joined авто-вступления — дискретный счётчик 1:1 с python /// L29–35). При FloodWait от Telegram воркер зовёт — блокировка авто-вступлений до /// конца суток (внутренний ключ SettingsKeys.DiscFloodDay, epoch-ms начала суток). Стоп-кран discPaused — /// ручная пауза вступлений. — общий вердикт «разрешено ли следующее авто- /// вступление». Настройки читаются типизированным снимком TenantSettingsSnapshot (C30): отсутствие /// строки/повреждение → SettingsDefaults. /// /// Паузу между вступлениями (discJoinDelayMin..Max, 50–70 с) гард НЕ держит — её делает /// (рандом недоступен тестам; 1:1 ban_guard.wait_join_delay L44–56). /// /// public sealed class DiscoveryBanGuard { private readonly IDiscoveryStore _store; private readonly ISettingsStore _settings; private readonly Func _utcNow; /// /// Создаёт бан-гард. /// /// Хранилище Discovery (счётчик DiscLog за сутки). /// KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused). /// Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон /// FakeDiscoveryStore). По умолчанию — . public DiscoveryBanGuard(IDiscoveryStore store, ISettingsStore settings, Func? utcNow = null) { ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(settings); _store = store; _settings = settings; _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); } /// /// Ручной стоп-кран авто-вступлений (setting discPaused; ban_guard.global_paused L69–71). /// /// Токен отмены. /// True — вступления на паузе (воркер не делает сетевых шагов). public async Task GlobalPausedAsync(CancellationToken ct) { TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); return settingsSnapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused); } /// /// Авто-вступления за текущие UTC-сутки (ban_guard.joins_today_auto L29–35). /// /// События DiscLog event='join_auto' с CreatedAt ≥ начала текущих UTC-суток (все тенанты держат /// времена UTC; счётчик учитывает и строки ручных вступлений с auto=false? — нет: фильтр по событию join_auto). /// Токен отмены. /// Число авто-вступлений сегодня (0 — вступлений нет). public Task JoinsTodayAutoAsync(CancellationToken ct) => _store.CountLogEventAsync(DiscoveryLogEvents.JoinAuto, StartOfDayUtc(), ct); /// /// Была ли flood-блокировка в текущие UTC-сутки (ban_guard.flood_today L64–66). /// /// Токен отмены. /// True — фиксировалась сегодня (стоп до конца суток). public async Task FloodTodayAsync(CancellationToken ct) { TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); return IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs()); } /// /// Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет стоп-крана /// (ban_guard.can_auto_join L38–41). /// /// Токен отмены. /// True — следующее авто-вступление допустимо. public async Task CanAutoJoinAsync(CancellationToken ct) { // Один типизированный снимок настроек на вердикт (C30): discJoinLimit/discFloodDay/discPaused // читаются одним GetAllAsync вместо трёх GetAsync. TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); int limit = snapshot.GetInt(SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit); int joined = await JoinsTodayAutoAsync(ct).ConfigureAwait(false); bool floodToday = IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs()); bool paused = snapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused); return joined < Math.Max(1, limit) && !floodToday && !paused; } /// /// Фиксирует FloodWait: блокировка авто-вступлений до конца суток (ban_guard.note_flood L59–62). /// /// Запись идемпотентна в течение суток: DiscFloodDay = epoch-ms начала текущих UTC-суток /// (повторный note_flood в тот же день перезаписывает тем же значением). /// Токен отмены. public Task NoteFloodAsync(CancellationToken ct) => _settings.SetAsync(SettingsKeys.DiscFloodDay, JsonSerializer.Serialize(StartOfDayMs()), ct); // Начало текущих UTC-суток (python _start_of_day_ms L23–26). private DateTimeOffset StartOfDayUtc() { DateTimeOffset now = _utcNow().ToUniversalTime(); return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero); } // Начало текущих UTC-суток в epoch-ms (python L25). private long StartOfDayMs() => StartOfDayUtc().ToUnixTimeMilliseconds(); // Flood-день совпал с текущими сутками (ban_guard.flood_today L64–66: 0 — блокировок не было). // floodDay: Epoch-ms начала суток flood-блокировки (0 — не было). // startOfDayMs: Начало текущих UTC-суток в epoch-ms. // Возвращает: True — flood фиксировался сегодня. private static bool IsFloodToday(long floodDay, long startOfDayMs) => floodDay != 0 && floodDay == startOfDayMs; }