using System.Text.Json; using Deal.Api.Endpoints.RequestModels; using Deal.Api.Http; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Discovery.Application.Abstractions; using Deal.Modules.Discovery.Application.Exceptions; using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Registrars; using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; using Deal.Modules.Telegram.Application; using Deal.Api.Services; using Deal.Api.Dtos; namespace Deal.Api.Endpoints; /// /// Эндпоинты /api/discovery: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8). /// /// /// 13 эндпоинтов 1:1 с backend/app/routers/discovery_routes.py (prefix /api/discovery): tasks /// (list/create/patch/delete/start/pause), generate-keywords (мягкая ошибка HTTP 200 {keywords: [], error}, /// Ruling 11), candidates (статус-фильтр), join/reject (ручные, вне квот воркера), blacklist, log. Тела ответов — /// DTO модуля Discovery (camelCase, §4.8 L353–355) и {items: [...]} для списков (конвенция api-map §1); /// 404-семантика сервисов (null/KeyError python) — «Задача не найдена»/«Кандидат не найден», /// 400-семантика — (ValueError python) с текстом причины 1:1. /// Ручной join — как worker-авто-join (Task 18): RPC Join через → строка каталога /// Dialogs (монитор on) + зеркало через → фоновый первый /// разбор (, python-_spawn) → снятие чёрного списка → mark_joined(auto:false); /// ошибка Telegram → 400 с текстом причины. Все эндпоинты требуют сессию: 401 {detail} (Ruling 10); сервисы /// резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints). /// public static class DiscoveryEndpoints { // Префикс группы /api/discovery (python: router prefix, discovery_routes.py L26). private const string DiscoveryGroupPrefix = "/api/discovery"; // OpenAPI-тег группы (в прототипе роутер discovery — discovery_routes.py). private const string DiscoveryOpenApiTag = "discovery"; // Путь списка задач (GET). private const string TasksPath = "/tasks"; // Путь одной задачи: обновление (PATCH) / удаление (DELETE). private const string TaskPath = "/tasks/{task_id}"; // Путь запуска поиска (POST). private const string TaskStartPath = "/tasks/{task_id}/start"; // Путь паузы поиска (POST). private const string TaskPausePath = "/tasks/{task_id}/pause"; // Путь ИИ-генерации ключевых слов по описанию задачи (POST). private const string TaskGenerateKeywordsPath = "/tasks/{task_id}/generate-keywords"; // Путь списка кандидатов задачи со статус-фильтром (GET). private const string TaskCandidatesPath = "/tasks/{task_id}/candidates"; // Путь ручного вступления в кандидата (POST). private const string CandidateJoinPath = "/candidates/{dialog_id}/join"; // Путь отклонения кандидата в чёрный список (POST). private const string CandidateRejectPath = "/candidates/{dialog_id}/reject"; // Путь чёрного списка (GET) и снятия записи (DELETE). private const string BlacklistPath = "/blacklist"; // Путь записи чёрного списка (DELETE). private const string BlacklistItemPath = "/blacklist/{dialog_id}"; // Путь лога задачи (GET). private const string TaskLogPath = "/tasks/{task_id}/log"; // 404 create/start/patch/candidates/log: задачи нет (python _task_or_404 L83–87). private const string TaskNotFoundDetail = "Задача не найдена"; // 404 join/reject: кандидата нет (python _candidate_or_404 L90–94). private const string CandidateNotFoundDetail = "Кандидат не найден"; // 400 join: уже вступили (python L235–237). private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; // 400 reject: источник уже вступили (python L256–258). private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов"; // 400 join: ошибка Telegram при вступлении (python L240–242, текст с @username). private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}"; // Причина отклонения вручную для чёрного списка/лога (python L260: reason="отклонено вручную"). private const string ManualRejectReason = "отклонено вручную"; // Мягкая ошибка generate-keywords: ИИ выключен (python _ai_unavailable_reason L99–100). private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)"; // Мягкая ошибка generate-keywords: описания нет (python L201–202). private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи"; // Страховочный потолок числа сгенерированных ключей (python _KEYWORDS_LIMIT L31: промпт просит 10–16). private const int KeywordsLimit = 30; // Потолок длины одного ключа (python _KEYWORD_LENGTH_LIMIT L33: короткие фразы для поиска Telegram). private const int KeywordLengthLimit = 60; /// /// Регистрирует группу /api/discovery: 13 эндпоинтов (tasks + generate-keywords + candidates + join/reject + blacklist + log). /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. public static IEndpointRouteBuilder MapDiscoveryEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup(DiscoveryGroupPrefix).WithTags(DiscoveryOpenApiTag); group.MapGet(TasksPath, ListTasksAsync); group.MapPost(TasksPath, CreateTaskAsync); group.MapPatch(TaskPath, PatchTaskAsync); group.MapDelete(TaskPath, DeleteTaskAsync); group.MapPost(TaskStartPath, StartTaskAsync); group.MapPost(TaskPausePath, PauseTaskAsync); group.MapPost(TaskGenerateKeywordsPath, GenerateKeywordsAsync); group.MapGet(TaskCandidatesPath, ListCandidatesAsync); group.MapPost(CandidateJoinPath, JoinCandidateAsync); group.MapPost(CandidateRejectPath, RejectCandidateAsync); group.MapGet(BlacklistPath, ListBlacklistAsync); group.MapDelete(BlacklistItemPath, RemoveBlacklistAsync); group.MapGet(TaskLogPath, TaskLogAsync); return app; } // GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141–143). private static async Task ListTasksAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); IReadOnlyList items = await tasks.ListAsync(ct); return Results.Ok(new { items }); } // POST /api/discovery/tasks: создать задачу поиска (create_task L146–151; дефолты — в сервисе). private static async Task CreateTaskAsync(DiscoveryTaskCreateBody body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } try { DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); DiscoveryTaskDto task = await tasks.CreateAsync(ToDraft(body), ct); return Results.Ok(task); } catch (DiscoveryValidationException exception) { return EndpointResults.BadRequest(exception.Message); } } // PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154–161; 404/400). private static async Task PatchTaskAsync(string task_id, DiscoveryTaskPatchBody body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } try { DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct); return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); } catch (DiscoveryValidationException exception) { return EndpointResults.BadRequest(exception.Message); } } // DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164–168). private static async Task DeleteTaskAsync(string task_id, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); bool deleted = await tasks.DeleteAsync(task_id, ct); return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail); } // POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171–179; пустые ключи → 400). private static async Task StartTaskAsync(string task_id, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } try { DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct); return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); } catch (DiscoveryValidationException exception) { return EndpointResults.BadRequest(exception.Message); } } // POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181–187). private static async Task PauseTaskAsync(string task_id, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct); return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); } // POST /api/discovery/tasks/{task_id}/generate-keywords: ИИ-ключи по описанию задачи // (generate_keywords L189–211). ИИ выключен/недоступен/нет описания → HTTP 200 {keywords: [], error}. // Очистка ключей ответа — CleanKeywords (python _clean_keywords L111–128: ≤30, ≤60 // символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints). // Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины. private static async Task GenerateKeywordsAsync(string task_id, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); if (task is null) { return EndpointResults.NotFound(TaskNotFoundDetail); } ISettingsStore settings = context.RequestServices.GetRequiredService(); if (!await ReadAiEnabledAsync(settings, ct)) { return Results.Ok(new { keywords = Array.Empty(), error = AiDisabledDetail }); } string description = (task.Description ?? string.Empty).Trim(); if (description.Length == 0) { return Results.Ok(new { keywords = Array.Empty(), error = NoDescriptionDetail }); } try { IAiTools aiTools = context.RequestServices.GetRequiredService(); AiGenerateKeywordsResultDto result = await aiTools.GenerateKeywordsAsync(description, ct); if (result.Ok) { return Results.Ok(new { keywords = CleanKeywords(result.Keywords) }); } // Недоступность провайдера/сервиса — мягкая ошибка для UI (Ruling 11), HTTP 200. return Results.Ok(new { keywords = Array.Empty(), error = result.Error ?? ServiceUnavailableText }); } catch (NotSupportedException exception) { // Локальный режим: ai-service не подключён — инструменты недоступны (LocalAiTools, Task 15). return Results.Ok(new { keywords = Array.Empty(), error = exception.Message }); } } // GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром // new|review|joined|rejected (list_candidates L216–224; невалидный статус — пустой список). private static async Task ListCandidatesAsync(string task_id, string? status, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); if (task is null) { return EndpointResults.NotFound(TaskNotFoundDetail); } DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService(); IReadOnlyList items = await candidates.ListAsync(task_id, status, ct); return Results.Ok(new { items }); } // POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226–251). // RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка → // mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины. private static async Task JoinCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService(); DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); if (row is null) { return EndpointResults.NotFound(CandidateNotFoundDetail); } if (row.Status == DiscoveryCandidateStatuses.Joined) { return EndpointResults.BadRequest(AlreadyJoinedDetail); } string username = (row.Username ?? string.Empty).Trim().TrimStart('@'); try { ITelegramGateway gateway = context.RequestServices.GetRequiredService(); await gateway.JoinAsync(username, ct); } catch (Exception exception) when (exception is not OperationCanceledException) { string reason = TelegramEndpoints.GatewayErrorText(exception); return EndpointResults.BadRequest(string.Format(JoinFailedFormat, username, reason)); } // Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service // (python add_dialog_monitored L242; решение T18: локальную строку пишет Api-слой). DialogsService dialogs = context.RequestServices.GetRequiredService(); await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct); // Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill // (python _spawn(_backfill_quiet) L244–245; источник уже в каталоге и мониторится). context.RequestServices.GetRequiredService().ScheduleFirstBackfill(dialog_id); DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService(); await blacklist.RemoveAsync(dialog_id, ct); try { DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct); return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined); } catch (DiscoveryValidationException exception) { return EndpointResults.BadRequest(exception.Message); } } // POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список // (reject_candidate L253–264; уже вступившего — нельзя, 400). private static async Task RejectCandidateAsync(string dialog_id, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService(); DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); if (row is null) { return EndpointResults.NotFound(CandidateNotFoundDetail); } if (row.Status == DiscoveryCandidateStatuses.Joined) { return EndpointResults.BadRequest(JoinedRejectDetail); } try { DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct); return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected); } catch (DiscoveryValidationException exception) { return EndpointResults.BadRequest(exception.Message); } } // GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269–271). private static async Task ListBlacklistAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService(); IReadOnlyList items = await blacklist.ListAsync(ct); return Results.Ok(new { items }); } // DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274–277). private static async Task RemoveBlacklistAsync(string dialog_id, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService(); await blacklist.RemoveAsync(dialog_id, ct); return Results.Ok(new { ok = true }); } // GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282–285), события от новых к старым. private static async Task TaskLogAsync(string task_id, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); if (task is null) { return EndpointResults.NotFound(TaskNotFoundDetail); } DiscoveryLogService log = context.RequestServices.GetRequiredService(); IReadOnlyList items = await log.TaskLogAsync(task_id, ct); return Results.Ok(new { items }); } /// /// Ключи из ответа ИИ: строки без пустых/длинных и повторов (python _clean_keywords L111–128). /// /// Повтор считается по casefold python: здесь — регистронезависимое сравнение /// (RU/EN-ключи; StringComparer.OrdinalIgnoreCase). Потолок списка — . /// Сырые ключи ответа модели (null — пусто). /// Очищенные ключи (не более 30, каждый ≤60 символов). public static IReadOnlyList CleanKeywords(IEnumerable? raw) { if (raw is null) { return Array.Empty(); } var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var outList = new List(); foreach (string? item in raw) { if (item is null) { continue; } string keyword = item.Trim(); if (keyword.Length == 0 || keyword.Length > KeywordLengthLimit || !seen.Add(keyword)) { continue; } outList.Add(keyword); if (outList.Count >= KeywordsLimit) { break; } } return outList; } // Фолбэк-текст недоступного ИИ, если адаптер причину не вернул (мягкая ошибка, Ruling 11). private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд"; // Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults). // settings: KV-хранилище настроек тенанта. // ct: Токен отмены. // Возвращает: True — ИИ включён (ветки выключателя отрабатывает вызывающий, Ruling 10/11). private static async Task ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct) { SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct); if (row is null) { return SettingsDefaults.AiEnabled; } try { using JsonDocument document = JsonDocument.Parse(row.ValueJson); return document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False ? document.RootElement.GetBoolean() : SettingsDefaults.AiEnabled; } catch (JsonException) { return SettingsDefaults.AiEnabled; } } // Маппит тело создания в черновик сервиса (значения пограничные передаются как есть — дефолты в сервисе). // body: Тело запроса (wire-поля camelCase). // Возвращает: Черновик создания задачи (DiscoveryTaskDraft). private static DiscoveryTaskDraft ToDraft(DiscoveryTaskCreateBody body) { return new DiscoveryTaskDraft { Name = body.Name, Description = body.Description ?? string.Empty, Keywords = body.Keywords, MinSubscribers = body.MinSubscribers, Lang = body.Lang, Threshold = body.Threshold, SampleSize = body.SampleSize, PlanJoins = body.PlanJoins, AutoJoin = body.AutoJoin, }; } // Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)). // body: Тело запроса (wire-поля camelCase). // Возвращает: Патч задачи (DiscoveryTaskPatch). private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body) { return new DiscoveryTaskPatch { Name = body.Name, Description = body.Description, Keywords = body.Keywords, MinSubscribers = body.MinSubscribers, Lang = body.Lang, Threshold = body.Threshold, SampleSize = body.SampleSize, PlanJoins = body.PlanJoins, AutoJoin = body.AutoJoin, }; } }