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,
};
}
}