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

Первый коммит: модульный монолит ядра (.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
+46
View File
@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Deal.Proto — общий проект кодогенерации .proto-контрактов этапа 6 (Ruling 1).
Компилирует все три контракта (telegram/ml/ai.proto) через Grpc.Tools с
генерацией и клиентской, и серверной стороны (GrpcServices="Both"): сборка
этого проекта — первый прогон кодогенерации и валидации .proto до создания
сервисных проектов (Task 2–4). Сгенерированные типы живут в пространствах
имён из option csharp_namespace (Deal.Grpc.Telegram/Deal.Grpc.Ml/Deal.Grpc.Ai)
и переиспользуются core и сервисами через ProjectReference.
Сборка: 0 warnings / 0 errors (TreatWarningsAsErrors), как остальные sln.
-->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AssemblyName>Deal.Proto</AssemblyName>
<RootNamespace>Deal.Proto</RootNamespace>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<!-- Кодогенерация: protoc + плагин gRPC C# (только для сборки). -->
<PackageReference Include="Grpc.Tools" Version="2.83.0" PrivateAssets="All" />
<!-- Рантайм сгенерированных сообщений (MessageParser/ByteString и т.п.). -->
<PackageReference Include="Google.Protobuf" Version="3.35.1" />
<!-- Типы gRPC C#-стабов (Grpc.Core.*): нужны сгенерированному коду при
компиляции; транспорты (Grpc.Net.Client/Grpc.AspNetCore) подключают
проекты-потребители по месту (Ruling 1, список NuGet этапа). -->
<PackageReference Include="Grpc.Core.Api" Version="2.83.0" />
</ItemGroup>
<ItemGroup>
<!-- Контракты этапа 6; файлы лежат рядом с проектом (src/contracts). -->
<Protobuf Include="telegram.proto" GrpcServices="Both" />
<Protobuf Include="ml.proto" GrpcServices="Both" />
<Protobuf Include="ai.proto" GrpcServices="Both" />
</ItemGroup>
</Project>
+204
View File
@@ -0,0 +1,204 @@
# Контракты gRPC этапа 6 (`src/contracts`)
Контракты между ядром Deal и сервисами telegram/ml/ai (отдельные процессы).
Источники: дизайн-док §6.2 (L145–152), план «Дейл — Этап 6: Сервисы …»
(Task 1, Rulings 1/3/5/7), api-map §3.3/§3.7/§3.8/§4.8/§4.9/§4.10, прототип
`backend/app/services/*.py` и `mlservice/model.py`.
Контракты — **единственный «язык» между процессами** (Ruling 1): сервисы не
делят с ядром ничего, кроме этих `.proto` и NuGet.
| Файл | Пакет | `csharp_namespace` | Сервисы |
|---|---|---|---|
| `telegram.proto` | `deal.telegram.v1` | `Deal.Grpc.Telegram` | `TelegramService`, `IngressService` |
| `ml.proto` | `deal.ml.v1` | `Deal.Grpc.Ml` | `MlService` |
| `ai.proto` | `deal.ai.v1` | `Deal.Grpc.Ai` | `AiService` |
## Кодогенерация
- `Deal.Proto.csproj` — общий classlib: компилирует все три `.proto` через
`Grpc.Tools` (`<Protobuf Include=... GrpcServices="Both"/>`), т.е. генерирует
и клиент, и сервер каждого сервиса в одном проходе (неиспользуемая сторона
игнорируется потребителем). Сборка проекта = валидация `.proto` (protoc) и
первый прогон кодогенерации (Task 1); Task 2–4 подключают файлы к сервисам
`ProjectReference`-ом либо `<Protobuf Include="..\..\contracts\X.proto">`.
- Пакеты: `Grpc.Tools` (PrivateAssets), `Google.Protobuf`, `Grpc.Core.Api`.
Транспорты — по месту: `Grpc.AspNetCore` (серверы), `Grpc.Net.Client`/клиенты.
- Сгенерированные типы: `obj/…/{Telegram,TelegramGrpc,Ml,MlGrpc,Ai,AiGrpc}.cs`,
пространства имён из `option csharp_namespace`.
- Проекты-потребители: core (клиенты ML/AI; telegram-клиент-гейт + сервер
ингресса), telegram-service/ml-service/ai-service — добавляются в Task 2–4+.
## Metadata (все RPC, обязательны)
| Заголовок | Значение | Отказ |
|---|---|---|
| `tenant-id` | id тенанта (строка). **Единственный** источник принадлежности; полю в теле не доверяем | отсутствует → `UNAUTHENTICATED` (для ингресса core: `SetTenant` из metadata) |
| `service-token` | общий токен сервисов (env `DEAL_SERVICE_TOKEN`, общий в compose) | пустой/неверный → `UNAUTHENTICATED` |
Интерцептор `service-token` — общий шаблон в каждом процессе (Ruling 1/2);
каждый сервис дополнительно проверяет принадлежность по своей модели (сессия/
модель тенанта есть, иначе `NOT_FOUND`/`FAILED_PRECONDITION`).
## Коды ошибок (общие)
Ошибки домена — gRPC-статусы, `detail` = текст причины 1:1 с прототипом
(строки «Telegram не подключён», «Неверный код», «Код истёк — запросите новый»,
«Неверный облачный пароль», «ИИ (имя) не ответил корректно — …» и т.д.):
| Статус | Когда |
|---|---|
| `INVALID_ARGUMENT` | невалидный ввод/код/пароль/username, пустой текст |
| `NOT_FOUND` | диалог/сущность/сессия тенанта не найдены |
| `FAILED_PRECONDITION` | операция невозможна в текущей фазе (нет сессии и т.п.) |
| `RESOURCE_EXHAUSTED` | FloodWait Telegram (detail начинается с префикса `flood`) |
| `UNAVAILABLE` | недоступен Telegram/LLM-провайдер/хранилище модели (безопасный повтор/фолбэк) |
| `UNAUTHENTICATED` | неверный/отсутствующий `service-token` |
«Мягкие» сценарии НЕ являются ошибками RPC: Predict неготовой модели («не
уверен»), ReadForEval без истории (`ok=false, error="no_history"`), сброс с
`ok=false,error`, `filter` с решением pass/reason.
## Deadlines (клиент)
| Сфера | Рекомендация | Обоснование |
|---|---|---|
| telegram: GetStatus/SetMonitor/SetMonitorAll/Logout | 10 с | локальная сеть/статус |
| telegram: StartPhone/StartQr/SendCode/SendPassword/Search/GetInfo/ReadRecent/ReadForEval/Join/Leave | 60 с | сетевые операции Telegram (паузы анти-бана 2–4 с поиск) |
| telegram: RefreshDialogs/Backfill | 120 с | iter_dialogs 500; backfill 10 сообщ. × 1.5–3 с + 3–6 с/диалог |
| ingress: PushMessage/SyncDialogs/ReportStatus | 10 с | локальная сеть; упущенное догоняет realtime-sweep |
| ml: Predict | 5 с | локальная модель |
| ml: Status/Reset | 10 с | локально |
| ml: TrainBatch | 30 с | батч ≤100, 1 транзакция |
| ai: все RPC | 120 с | провайдер 90/60 с + ретраи 0.8/2 с |
---
## `telegram.proto`
Два сервиса: команды ядра → сервис (`TelegramService`) и поток сервис → ядро
(`IngressService`, gRPC-сервер в Deal.Api :5082, Ruling 7).
### Словари значений
- `phase`: `idle | phone | code | password | qr | ready` (status() L85).
- `kind`: `channel | group | forum | chat` (канон контракта; 1:1 `_kind_of`
L461466: broadcast → channel, megagroup/gigagroup/group → group, остальное →
chat; `forum` — отдельный флаг `is_forum` в GetInfo, в каталоге форум приходит
как group). Discovery-коды `channel/group/forum` ядро получает из kind+is_forum.
- `error`/`qr_url`/`account``optional` (presence): пустое = нет ошибки/URL.
### Маппинги на HTTP-контракт (заметка для T13/14, код не меняется)
Внутренний канон контракта `kind` — EN (`channel/group/forum/chat`). Граница
HTTP-эндпоинтов (api-map §4.8 L349, замороженный контракт фронта) НЕ 1:1:
- `GET /api/tg/dialogs``item.type` остаётся **русским** («канал»/«группа»/
«чат»), как в прототипе (`refresh_dialogs`/`list_dialogs`): на границе
эндпоинта каналов (T13/14) нужен обратный маппинг EN → RU
(channel→«канал», group/forum→«группа», chat→«чат»; forum в списке диалогов
не встречается — каталог приносит его как group).
- Discovery: `candidate.type` (`kind`) — **EN** (`channel/group/forum`), как в
прототипе (db.py L162166, `_kind_code`); маппинг на границе НЕ нужен.
### TelegramService (ядро — клиент, сервис — сервер)
| RPC | Запрос | Ответ | Ошибки / примечания |
|---|---|---|---|
| `GetStatus` | `GetStatusRequest` (пуст) | `GetStatusReply{phase,connected,listener,account,error?,qr_url?}` | нет сессии → FAILED_PRECONDITION «Telegram не подключён». live-поля для `GET /api/tg/status`; monitored/keysSet ядро считает само (Ruling 8) |
| `StartPhone` | `StartPhoneRequest{phone, api_id, api_hash}` | `StartPhoneReply{phase}` | ключи tgKeys передаёт ядро (Ruling 3); нет ключей → INVALID_ARGUMENT «Сначала сохраните…»; ответ фаза `code` |
| `StartQr` | `StartQrRequest{api_id, api_hash}` | `StartQrReply{phase, qr_url}` | фаза `qr` + url; уже авторизован → `ready`, url пуст |
| `SendCode` | `SendCodeRequest{code}` | `SendCodeReply{phase}` | «Неверный код»/«Код истёк…» → INVALID_ARGUMENT; 2FA → фаза `password` |
| `SendPassword` | `SendPasswordRequest{password}` | `SendPasswordReply{phase}` | «Неверный облачный пароль» → INVALID_ARGUMENT; ответ `ready` |
| `Logout` | `LogoutRequest` (пуст) | `LogoutReply{ok}` | отключение + удаление сессии тенанта |
| `RefreshDialogs` | `RefreshDialogsRequest` (пуст) | `RefreshDialogsReply{entries: DialogEntry[]}` | каталог диалогов; применять ядру через Ingress.SyncDialogs-семантику (`SyncFromTelegram`) |
| `SetMonitor` | `SetMonitorRequest{dialog_id, enabled}` | `SetMonitorReply{ok, enabled}` | зеркало monitored в сервисе; первый backfill запускает ядро |
| `SetMonitorAll` | `SetMonitorAllRequest{enabled}` | `SetMonitorAllReply{ok, count, enabled}` | count = диалогов в каталоге |
| `Backfill` | `BackfillRequest{dialog_id, force}` | `BackfillReply{processed}` | последние ~10 сообщений → поток PushMessage; паузы 1.53 с/сообщ. + mark-as-read; force = «Перечитать» |
| `ReadRecent` | `ReadRecentRequest{dialog_id, limit 1..50}` | `ReadRecentReply{messages: PreviewMessage[]}` | свежие из TG; lead и фолбэк на БД — в ядре |
| `Search` | `SearchRequest{query, limit}` | `SearchReply{results: DialogEntry[]}` | пауза анти-бана внутри сервиса; личные/ботов отсеивает ядро (Ruling 10) |
| `GetInfo` | `GetInfoRequest{dialog_id}` | `GetInfoReply{info: ChannelInfo}` | ChannelInfo{id,name,username,kind,hue,participants?,is_forum}; сбои full_chat не роняют RPC |
| `ReadForEval` | `ReadForEvalRequest{dialog_id, limit}` | `ReadForEvalReply{ok, error?, messages: EvalMessage[]}` | форумы — по темам; история скрыта → ok=false,error="no_history" (НЕ ошибка RPC) |
| `Join` | `JoinRequest{username}` | `JoinReply{ok}` | FloodWait → RESOURCE_EXHAUSTED (`flood`); ручной join вне квот |
| `Leave` | `LeaveRequest{dialog_id}` | `LeaveReply{ok}` | нет членства → NOT_FOUND |
Общие сообщения:
- `DialogEntry{id,name,username,kind,hue}` — каталог/поиск (id подписанный:
каналы `-100…`, группы `-…`, личные `+…`; hue — палитра DIALOG_HUES).
- `ChannelInfo` = DialogEntry + `participants?` + `is_forum`.
- `PreviewMessage{id(text), text, time(ms)}` — превью (api-map §4.8 L351; `id`
строкой: int-сообщения TG и фолбэк `m_<dialog>_<msg>`).
- `EvalMessage{id(int64), text, date_ms, topic_id?, topic_title?}` — выборка
оценки кандидата (`_discovery_message_item`).
### IngressService (сервис — клиент, ядро — сервер в Deal.Api, порт `GRPC_INGRESS_PORT` :5082)
| RPC | Запрос | Ответ | Примечания |
|---|---|---|---|
| `PushMessage` | `PushMessageRequest{dialog_id, channel_name, channel_handle, channel_hue, msg_id?, text, msg_at?}` | `PushMessageReply{accepted, duplicate}` | 1:1 QueuedMessage/demo-ingest: EnqueueAsync + превью в TgMessages; дубль dialog+msgId → duplicate=true, очередь не растёт; нет msg_at → ядро подставит now; hue считает сервис |
| `SyncDialogs` | `SyncDialogsRequest{entries: DialogEntry[]}` | `SyncDialogsReply{monitored_ids[]}` | ядро: SyncFromTelegram (autoMonitorNew/обновление/удаление); ответ — актуальный зеркальный список monitored сервиса |
| `ReportStatus` | `ReportStatusRequest{phase,connected,listener,account,error?,qr_url?}` | `ReportStatusReply{ok}` | ядро: KV tgStatus/tgAccount + SSE system_status/тосты на переходах фаз |
---
## `ml.proto`
`MlService` — пул инкрементальных наивно-байесовских моделей per-tenant
(файл `data/ml/<tenantId>.sqlite`). Поля 1:1 с `MlPredictResultDto`/
`MlServiceStatusDto` и model.py.
| RPC | Запрос | Ответ | Примечания |
|---|---|---|---|
| `Predict` | `PredictRequest{text}` | `PredictReply{take,label?,scores: map<string,double>,hits,ready,margin?,terms[],type?}` | «не уверен» при неготовой модели/пустом тексте — не ошибка; scores ≤5 лучших (round 3); margin адаптивный 0.9/0.7/0.5/0.35; terms ≤8; type — t:hire/t:order |
| `Status` | `StatusRequest` (пуст) | `StatusReply{ready,classes: map<string,double>,learned,eval: ModelEval}` | classes «label → вес» round 2; модель создаётся лениво |
| `Reset` | `ResetRequest` (пуст) | `ResetReply{ok, error?}` | очистка classes/terms/eval_log + пересоздание файла; ok=false — мягкая ошибка (ядро чистит ml_outbox только при ok) |
| `TrainBatch` | `TrainBatchRequest{items: TrainExample[]}` | `TrainBatchReply{learned}` | 1 транзакция + пакетные вставки терминов (= learn_batch); learned = применено примеров |
Сообщения:
- `TypeDecision{take,label("hire"|"order"),value("t:hire"|"t:order"),margin}`
решение о типе заявки.
- `ModelEval{count,correct,accuracy}` — окно самооценки (EVAL_WINDOW последних
подтверждённых решений).
- `TrainExample{text,label,delta}` — строка обучения (ml_outbox): delta 1.0
пользователь / −1.0 снять / 0.4–0.6 ИИ-правила.
Пороги и константы (Ruling 4): MIN_TOTAL 20, MIN_WINNER 6, MIN_WINNER_SPAM 4,
MIN_HITS 2, MARGIN 0.9, типы `t:*` с MIN_TYPE_WINNER 4 — живут в ml-service
(реализация Task 5/6), в контракт не входят.
---
## `ai.proto`
`AiService` — фасад LLM-провайдеров без БД (Ruling 5). Ядро передаёт в теле
каждого запроса заполненные промпты/контекст + конфиг активного провайдера
(`provider_config`); сервис возвращает ответ модели + оценку токенов.
| RPC | Запрос | Ответ | Примечания |
|---|---|---|---|
| `Filter` | `FilterRequest{prompt, text, provider_config}` | `FilterReply{pass, reason?, usage}` | prompt = заполненный aiFilterPrompt (ядро); «фильтр не применялся» обрабатывает ядро до вызова |
| `Classify` | `ClassifyRequest{system_prompt, user_context, provider_config}` | `ClassifyReply{ok, json?, usage}` | system_prompt = aiPrompt+cardPrompt, user_context = «Доски + примеры + Сообщение» (собирает ядро); json — сырой ответ модели строкой; строгий маппинг в карточку — ядро |
| `GenerateKeywords` | `GenerateKeywordsRequest{description, provider_config}` | `GenerateKeywordsReply{keywords[], usage}` | фикс. промпт (routes L3647); очистку `_clean_keywords` и мягкие ошибки делает ядро (Ruling 11) |
| `EvaluateFit` | `EvaluateFitRequest{text, description, keywords[], provider_config}` | `EvaluateFitReply{fit, reason?, usage}` | промпт discovery_eval L5054; ядро зовёт при aiEnabled, сбой → эвристика |
`provider_config` — конфиг активного провайдера на запрос (Ruling 5 «в теле
каждого запроса»): ядро собирает эффективный конфиг (настройка `aiConfigs`
тенанта хранит `{apiKey, baseUrl, model}` в camelCase, ключ шифруется AES-GCM;
`apiStyle` — из каталога `AiProviders`) и передаёт в теле; сервис настроек не
хранит. Форма — сообщение `ProviderConfig`:
| Поле | Обязательность | Описание |
|---|---|---|
| `provider_id` | да | Id провайдера (ключ `aiConfigs`/каталога: deepseek/openai/anthropic/ollama/lmstudio/custom…) |
| `base_url` | да | Эффективный базовый URL API (`aiConfigs.baseUrl` или дефолт каталога) |
| `api_key` | опц. | Ключ открытым текстом (расшифрован ядром); пуст у локальных провайдеров — заголовок не шлётся |
| `model` | да | Активная модель (`aiConfigs.model` или первая из каталога) |
| `api_style` | опц. | Стиль API: пуст — OpenAI-совместимый (`{base}/chat/completions`, Bearer); `"anthropic"` — Messages API (`{base}/v1/messages`, x-api-key + anthropic-version) |
Сообщения:
- `Usage{prompt, completion, total}` — оценка токенов в каждом reply (из usage
API-ответа; при отсутствии ≈chars/4; ядро копит в KV `aiTokenUsage`).
Ошибки: провайдер не ответил корректно после ретраев → `UNAVAILABLE` с detail
«ИИ (имя) не ответил корректно — повторите попытку через несколько секунд».
`ClassifyReply.ok=false` — ответ без разбираемого JSON (не RPC-ошибка); ядро
трактует как «не разобрано» и использует локальный путь.
+172
View File
@@ -0,0 +1,172 @@
// ai.proto — контракт между ядром Deal и ai-service (этап 6).
//
// ai-service — фасад LLM-провайдеров без БД (Ruling 5, дизайн-док §7.3):
// ядро передаёт в теле каждого запроса готовые (заполненные) промпты и/или
// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает
// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic
// POST {base}/v1/messages; temperature 0.2, таймауты 90/60 с, retry 2 с
// паузами 0.8/2 с) и возвращает ответ + оценку токенов. Конфиг провайдера
// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого
// запроса — сервис настроек тенанта не знает и не хранит.
//
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
// tenant-id — id тенанта (строка; учёт токенов в ядре по нему);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// Ошибки домена — gRPC-статусы (Ruling 1/5):
// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.);
// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail =
// «ИИ (имя) не ответил корректно — повторите попытку через
// несколько секунд» (ядро падает в локальный разбор).
//
// Учёт токенов (Ruling 5): каждый reply несёт usage{prompt/completion/total}.
// Берётся из usage API-ответа провайдера; при отсутствии оценивается по
// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage.
//
// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с;
// при недоступности ядро не ждёт повторно — Ruling 6 кэш/фолбэк).
syntax = "proto3";
package deal.ai.v1;
option csharp_namespace = "Deal.Grpc.Ai";
service AiService {
// ИИ-фильтр входящих сообщений (ai.py filter_incoming L188198, Ruling 5):
// ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст;
// решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/
// недоступность) обрабатывает ядро до вызова — сервис всегда отвечает.
rpc Filter(FilterRequest) returns (FilterReply);
// Полный разбор лида (ai.py classify L218258, Ruling 5): ядро собирает
// system_prompt = заполненные aiPrompt + cardPrompt и user-контекст
// «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый
// ответ модели как json-строку (типовую схему задаёт промпт). Строгий
// маппинг json → AiParsedCardDto делает ядро (1:1 normalize_stack/
// clean_budget/build_contacts).
rpc Classify(ClassifyRequest) returns (ClassifyReply);
// Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт
// discovery_routes L3647 + описание; Ruling 5): ответ {keywords}. Очистку
// (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкие ошибки делает ядро.
rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply);
// Оценка соответствия сообщения задаче поиска (промпт discovery_eval
// L5054; Ruling 5/10): текст + описание + ключи задачи → {fit, reason}.
// Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику.
rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply);
}
// --- Запросы/ответы AiService ---
// Конфиг активного LLM-провайдера на запрос (Ruling 5: ядро расшифровывает
// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек).
// Форма 1:1 с эффективным конфигом core: настройка aiConfigs тенанта хранит
// {apiKey, baseUrl, model} (camelCase; apiKey шифруется AES-GCM этапа 2),
// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера
// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова
// (OpenAI-совместимые chat/completions vs Anthropic Messages API).
message ProviderConfig {
// Id провайдера (ключ aiConfigs / каталога AiProviders: deepseek, openai,
// anthropic, ollama, lmstudio, custom…).
string provider_id = 1;
// Эффективный базовый URL API (aiConfigs.baseUrl или дефолт каталога).
string base_url = 2;
// API-ключ открытым текстом (расшифрован ядром); пуст для локальных
// провайдеров (ollama/lmstudio) — заголовок авторизации не шлётся.
optional string api_key = 3;
// Активная модель (aiConfigs.model или первая из каталога провайдера).
string model = 4;
// Стиль API: пуст — OpenAI-совместимый (POST {base}/chat/completions,
// Bearer); "anthropic" — Messages API (POST {base}/v1/messages,
// x-api-key + anthropic-version).
optional string api_style = 5;
}
message FilterRequest {
// Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой
// {domain}/{keywords} — делает ядро; Ruling 5).
string prompt = 1;
// Текст сообщения (ядро обрезает до 4000, как ai.py L193).
string text = 2;
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
ProviderConfig provider_config = 3;
}
message FilterReply {
// True — сообщение проходит фильтр (не спам/реклама/служебное).
bool pass = 1;
// Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске).
optional string reason = 2;
// Оценка токенов вызова (Ruling 5).
Usage usage = 3;
}
message ClassifyRequest {
// system_prompt = заполненные aiPrompt + cardPrompt (структура карточки,
// «О заявке»; собирает ядро — Ruling 5).
string system_prompt = 1;
// user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает
// ядро, 1:1 classify L243251).
string user_context = 2;
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
ProviderConfig provider_config = 3;
}
message ClassifyReply {
// True — модель вернула разбираемый JSON (ok=false — ответ без JSON после
// ретраев; ядро трактует как «не разобрано» и падает в локальный путь).
bool ok = 1;
// Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре).
optional string json = 2;
// Оценка токенов вызова (Ruling 5).
Usage usage = 3;
}
message GenerateKeywordsRequest {
// Описание ниши/задачи (ядро обрезает до 4000, discovery_routes L29).
string description = 1;
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
ProviderConfig provider_config = 2;
}
message GenerateKeywordsReply {
// Сгенерированные ключи (пустой список — модель не выделила ключи;
// чистку/дедуп и мягкую ошибку для UI делает ядро — Ruling 11).
repeated string keywords = 1;
// Оценка токенов вызова (Ruling 5).
Usage usage = 2;
}
message EvaluateFitRequest {
// Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000).
string text = 1;
// Описание задачи поиска (discovery_eval L51).
string description = 2;
// Ключи задачи (discovery_eval L52; подставляются в промпт сервисом).
repeated string keywords = 3;
// Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса).
ProviderConfig provider_config = 4;
}
message EvaluateFitReply {
// True — сообщение относится к сфере/задаче (JSON {"fit": 0|1}).
bool fit = 1;
// Краткая причина решения модели (пуст, если модель её не дала).
optional string reason = 2;
// Оценка токенов вызова (Ruling 5).
Usage usage = 3;
}
// Оценка токенов вызова провайдера (Ruling 5: usage{prompt/completion/total};
// из usage API-ответа, при отсутствии — по символам ≈chars/4).
message Usage {
// Токены запроса (system + user).
uint32 prompt = 1;
// Токены ответа модели.
uint32 completion = 2;
// Суммарно (prompt + completion; может отличаться от суммы при подсчёте
// провайдером — берём как есть).
uint32 total = 3;
}
+147
View File
@@ -0,0 +1,147 @@
// ml.proto — контракт между ядром Deal и ml-service (этап 6).
//
// Инкрементальная наивно-байесовская модель по терминам, 1:1 с python
// mlservice/model.py (predict L184293, status L325345, reset L348354,
// learn_batch L147173) и DTO ядра Deal.Contracts.Integrations.Models
// (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto).
// Модель per-tenant: пул в ml-service, файл SQLite data/ml/<tenantId>.sqlite
// (Ruling 4). Обучение ядро шлёт батчами из очереди ml_outbox
// (MlOutboxFlushScheduler, Ruling 6).
//
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 (Ruling 1):
// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.);
// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен»,
// Ruling 6 — кэш reachable 15 с).
//
// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает
// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0,
// ready=false, margin пуст, terms пуст, type пуст (Ruling 5 этапа 2, 1:1).
//
// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с
// (батч ≤100 примеров, одна транзакция).
syntax = "proto3";
package deal.ml.v1;
option csharp_namespace = "Deal.Grpc.Ml";
service MlService {
// Предсказание по тексту сообщения (model.py predict L184293).
// take/label/scores/hits/margin/terms/type осмысленны только при take=true;
// scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог
// (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины
// класса-победителя, type — решение о типе заявки (t:hire/t:order).
rpc Predict(PredictRequest) returns (PredictReply);
// Статус модели тенанта (model.py status L325345): ready/classes/learned/eval.
// classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну
// последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво
// по первому обращению (Ruling 4) — отсутствие опыта это НЕ ошибка.
rpc Status(StatusRequest) returns (StatusReply);
// Полный сброс модели тенанта (model.py reset L348354): очистка классов,
// терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая
// ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе).
rpc Reset(ResetRequest) returns (ResetReply);
// Пакетное обучение (model.py learn_batch L147173): одна транзакция +
// пакетные вставки терминов; самооценка по действиям пользователя (delta=1,
// не t:*) до применения. Ответ — число применённых примеров.
rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply);
}
message PredictRequest {
// Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как
// ml_routes.py L8690; пустой/пробельный — не ошибка: ответ «не уверен»).
string text = 1;
}
message PredictReply {
// True — модель уверена (take) и решение можно использовать без ИИ.
bool take = 1;
// Класс решения: id колонки канбана (b_…) или "spam"; пуст, если не уверена.
optional string label = 2;
// Веса классов: «label → вес» (до 5 лучших; пуст у неготовой модели).
map<string, double> scores = 3;
// Сколько терминов класса-победителя модель узнала в тексте.
int32 hits = 4;
// Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM) и может
// принимать решения.
bool ready = 5;
// Порог уверенности решения (адаптивный margin, 2 знака); пуст — нет решения.
optional double margin = 6;
// Узнанные термины класса-победителя (подсказка структуры карточки, ≤8).
repeated string terms = 7;
// Решение о типе заявки (hire/order); пуст — модель тип не определила.
TypeDecision type = 8;
}
// Решение ML о типе заявки (predict L233238; MlTypeDecisionDto).
message TypeDecision {
// True — модель уверена в типе.
bool take = 1;
// Тип: "hire" | "order".
string label = 2;
// Внутренний класс ML: "t:hire" | "t:order" (не показывается UI).
string value = 3;
// Запас уверенности (margin, 2 знака).
double margin = 4;
}
message StatusRequest {}
message StatusReply {
// Модель готова принимать решения.
bool ready = 1;
// Классы модели: «label → вес» (round 2; пуст, пока нет обучения).
map<string, double> classes = 2;
// Всего примеров, на которых модель обучалась (сумма по классам).
int32 learned = 3;
// Самооценка модели по последним подтверждённым решениям.
ModelEval eval = 4;
}
// Окно самооценки модели (model.py status L329339; MlEvalDto).
message ModelEval {
// Решений в окне самооценки (последние EVAL_WINDOW).
int32 count = 1;
// Из них совпавших с действием пользователя.
int32 correct = 2;
// Доля верных (correct/count, 0..1; 0 при пустом окне).
double accuracy = 3;
}
message ResetRequest {}
message ResetReply {
// True — модель сброшена (и ядро очищает свою очередь обучения).
bool ok = 1;
// Текст ошибки при сбое сброса (пуст при успехе) — мягкая ошибка.
optional string error = 2;
}
message TrainBatchRequest {
// Примеры обучения (1 транзакция на батч; ядро шлёт ≤100 за цикл, Ruling 6).
repeated TrainExample items = 1;
}
// Один обучающий пример (строка ml_outbox ядра: text/label/delta).
message TrainExample {
// Текст примера (source_msg карточки или title).
string text = 1;
// Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order".
string label = 2;
// Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку;
// 0.4/0.6 — сигналы ИИ/правил (этапы 4/6).
double delta = 3;
}
message TrainBatchReply {
// Число применённых примеров (= len(items) при успехе).
int32 learned = 1;
}
+453
View File
@@ -0,0 +1,453 @@
// telegram.proto — контракт между ядром Deal и telegram-service (этап 6).
//
// Два сервиса в одном файле (дизайн-док §6.2, план Task 1, Ruling 1/7):
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения
// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта
// (ReportStatus). Сервер ингресса живёт в Deal.Api (:5082, Ruling 7).
//
// Семантика методов 1:1 с python-прототипом backend/app/services/telegram.py
// (имена L134873) и api-map §3.3/§4.8/§4.9; хранение диалогов/статуса — только
// в ядре (модуль Deal.Modules.Telegram, Ruling 7), сервис БД тенантов не знает.
//
// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1):
// tenant-id — id тенанта (строка; единственный источник принадлежности,
// полю в теле не доверяем);
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
// пустой → UNAUTHENTICATED.
//
// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 с прототипом:
// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.;
// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.);
// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.);
// RESOURCE_EXHAUSTED — FloodWait Telegram (detail начинается с префикса "flood");
// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор).
//
// Значения строк (канон контракта, .NET-код обеих сторон — новый):
// * phase: idle|phone|code|password|qr|ready (как status() прототипа L85);
// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) |
// chat (личный чат/бот). 1:1 с _kind_of (L461466): broadcast →
// channel, megagroup/gigagroup/group → group, остальное → chat.
// Forum выставляется отдельным флагом is_forum (GetInfo); в
// каталоге (RefreshDialogs) форум приходит как group.
//
// Deadlines (клиент ядра; уточняются адаптерами T2+):
// * быстрые команды статуса/мониторинга — 10 с;
// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с;
// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с;
// * IngressService (локальная сеть core) — 10 с (сбой догоняет sweep).
syntax = "proto3";
package deal.telegram.v1;
option csharp_namespace = "Deal.Grpc.Telegram";
// ---------------------------------------------------------------------------
// TelegramService — команды ядра → telegram-service (клиентская сторона в core)
// ---------------------------------------------------------------------------
service TelegramService {
// Текущий статус аккаунта/фазы входа тенанта (status() прототипа L103119).
// live-поля для GET /api/tg/status (Ruling 8); monitored/keysSet ядро считает
// само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён».
rpc GetStatus(GetStatusRequest) returns (GetStatusReply);
// Вход по номеру телефона: запросить код (start_phone L134147).
// api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1),
// передаёт ядро в теле (Ruling 3); нет ключей — ядро отвечает 400 «Ключи Telegram
// не заданы оператором» до вызова. Ответ: новая фаза ("code").
rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply);
// Начать QR-вход (qr_start L286300). Ответ: фаза + qrUrl (t.me/qr/...);
// если аккаунт уже авторизован — фаза "ready", qrUrl пуст.
rpc StartQr(StartQrRequest) returns (StartQrReply);
// Отправить SMS-код (submit_code L149166). Ошибки: «Неверный код»,
// «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза
// "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION.
rpc SendCode(SendCodeRequest) returns (SendCodeReply);
// Облачный пароль 2FA (submit_password L168176). Ошибка «Неверный облачный
// пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready").
rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply);
// Отключить аккаунт, удалить сессию тенанта (disconnect L189207).
rpc Logout(LogoutRequest) returns (LogoutReply);
// Синхронизировать каталог диалогов из Telegram (refresh_dialogs L505519):
// актуальный список sources диалогов аккаунта (entries). Удаление/обновление
// каталога и авто-мониторинг новых делает ядро (SyncFromTelegram, Ruling 7).
rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply);
// Включить/выключить мониторинг диалога (set_monitor L536546): обновляет
// зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро
// отдельным RPC Backfill. Ответ: ok/enabled.
rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply);
// Мониторинг всех диалогов сразу (set_monitor_all L548567). Ответ:
// ok/count/enabled (count — сколько диалогов в каталоге тенанта).
rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply);
// Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком
// PushMessage (backfill_dialog L349390; паузы анти-бана 1.5–3 с/сообщение,
// mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных.
// Ответ: сколько сообщений отправлено (processed).
rpc Backfill(BackfillRequest) returns (BackfillReply);
// Последние сообщения диалога для превью (dialog_messages L583620):
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
// (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview).
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
// Глобальный поиск каналов/групп по ключу (discovery_search L624664).
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
// отсеивает само (Ruling 10). Результат — entries канала/группы.
rpc Search(SearchRequest) returns (SearchReply);
// Инфо об источнике для оценки (discovery_info L666716): имя/username/kind/
// hue + participants и is_forum (полный чат). Сбои определения не роняют
// RPC: participants пуст, остальные поля — из entity/каталога.
rpc GetInfo(GetInfoRequest) returns (GetInfoReply);
// Выборка последних сообщений источника для оценки кандидата
// (discovery_read L718760): форумы читаются по активным темам. История
// недоступна (приватный/закрытый источник) — ok=false, error="no_history",
// это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов.
rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply);
// Вступить в канал/группу по @username (discovery_join L818839; ручной
// join вне квот — паузу перед авто-join делает воркер ядра, Ruling 10).
// FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood").
rpc Join(JoinRequest) returns (JoinReply);
// Выйти из канала/группы (discovery_leave L841848). NOT_FOUND — нет
// диалога/членства.
rpc Leave(LeaveRequest) returns (LeaveReply);
}
// --- Запросы/ответы TelegramService ---
message GetStatusRequest {}
// Статус аккаунта/фазы входа (shape прототипа status() L110118; monitored и
// keysSet ядро добавляет само из своей БД/настроек — Ruling 8).
message GetStatusReply {
// Фаза входа: idle|phone|code|password|qr|ready.
string phase = 1;
// Клиент Telegram подключён и авторизован.
bool connected = 2;
// Жив ли realtime-listener (поток новых сообщений → PushMessage).
bool listener = 3;
// Аккаунт "@username" (для справки; источник истины — KV tgAccount по
// ReportStatus, ядро использует KV — Ruling 8).
string account = 4;
// Текст последней ошибки (null, если ошибки нет).
optional string error = 5;
// URL QR-входа (заполнен только при phase == "qr").
optional string qr_url = 6;
}
// Подключение по телефону: ключи API передаёт ядро (Ruling 3).
message StartPhoneRequest {
// Номер телефона в международном формате (как ввёл пользователь).
string phone = 1;
// api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр).
int32 api_id = 2;
// api_hash приложения Telegram (глобальные ключи, задаёт оператор).
string api_hash = 3;
}
message StartPhoneReply {
// Фаза после запроса кода ("code"); при ошибке — RPC-статус.
string phase = 1;
}
message StartQrRequest {
// api_id/api_hash приложения Telegram (см. StartPhoneRequest).
int32 api_id = 1;
string api_hash = 2;
}
message StartQrReply {
// Фаза после запуска: "qr" (ждём сканирования) либо "ready" (уже вошли).
string phase = 1;
// URL вида https://t.me/qr/... для отрисовки QR; пуст при phase != "qr".
string qr_url = 2;
}
message SendCodeRequest {
// Код из SMS/Telegram-сообщения.
string code = 1;
}
message SendCodeReply {
// Фаза после проверки кода: "password" (нужен 2FA) или "ready".
string phase = 1;
}
message SendPasswordRequest {
// Облачный пароль 2FA.
string password = 1;
}
message SendPasswordReply {
// Фаза после входа ("ready").
string phase = 1;
}
message LogoutRequest {}
message LogoutReply {
// True — аккаунт отключён, сессия тенанта удалена.
bool ok = 1;
}
message RefreshDialogsRequest {}
message RefreshDialogsReply {
// Актуальный каталог диалогов аккаунта (id/name/username/kind/hue).
// Ядро применяет его через SyncFromTelegram (Ruling 7).
repeated DialogEntry entries = 1;
}
// Один диалог/канал каталога или результат поиска (shape refresh L516 и
// discovery_search L653660: tuple id/name/handle/kind/hue; handle == username).
message DialogEntry {
// Подписанный id диалога: каналы "-100…", группы "-…", личные "+…".
string id = 1;
// Отображаемое имя (title/first_name) или id, если имени нет.
string name = 2;
// Username (handle) источника; пуст, если нет публичного username.
string username = 3;
// Тип: channel|group|forum|chat (канон контракта, см. шапку файла).
string kind = 4;
// Цвет источника из палитры DIALOG_HUES (hex, "#rrggbb") — считает сервис.
string hue = 5;
}
message SetMonitorRequest {
// Id диалога каталога.
string dialog_id = 1;
// True — мониторить (сообщения → PushMessage в ядро), false — выключить.
bool enabled = 2;
}
message SetMonitorReply {
bool ok = 1;
// Зеркальное значение enabled (для ответов эндпоинтов {ok, enabled}).
bool enabled = 2;
}
message SetMonitorAllRequest {
// True — мониторить все диалоги каталога, false — снять мониторинг со всех.
bool enabled = 1;
}
message SetMonitorAllReply {
bool ok = 1;
// Сколько диалогов в каталоге тенанта (api-map /monitor-all → count).
int32 count = 2;
bool enabled = 3;
}
message BackfillRequest {
// Id диалога для перечитывания.
string dialog_id = 1;
// True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»).
bool force = 2;
}
message BackfillReply {
// Сколько сообщений отправлено в ядро потоком PushMessage.
int32 processed = 1;
}
message ReadRecentRequest {
// Id диалога.
string dialog_id = 1;
// Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50).
int32 limit = 2;
}
message ReadRecentReply {
// Последние сообщения (от новых к старым). lead/фолбэк на БД — в ядре.
repeated PreviewMessage messages = 1;
}
// Сообщение превью диалога (api-map §4.8 L351: {id, text, time, lead}).
message PreviewMessage {
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
// "m_<dialog>_<msg>", поэтому значение передаётся строкой.
string id = 1;
// Текст сообщения.
string text = 2;
// Время сообщения, epoch-ms.
int64 time = 3;
}
message SearchRequest {
// Поисковый запрос (ключ задачи discovery).
string query = 1;
// Верхняя граница результатов (прототип: default 30).
int32 limit = 2;
}
message SearchReply {
// Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро).
repeated DialogEntry results = 1;
}
message GetInfoRequest {
// Id источника (подписанный; из каталога или результата поиска).
string dialog_id = 1;
}
// Инфо об источнике для оценки кандидата discovery (discovery_info L674682).
message ChannelInfo {
string id = 1;
string name = 2;
string username = 3;
// Тип: channel|group|forum|chat.
string kind = 4;
string hue = 5;
// Число участников (full_chat); пусто — определить не удалось.
optional int32 participants = 6;
// True — мегагруппа-форум (темы); ядро трактует kind как "forum" (Ruling 10).
bool is_forum = 7;
}
message GetInfoReply {
ChannelInfo info = 1;
}
message ReadForEvalRequest {
// Id источника.
string dialog_id = 1;
// Размер выборки (прототип discovery_read: limit сообщений/тем).
int32 limit = 2;
}
message ReadForEvalReply {
// True — выборка получена; false — история недоступна без членства.
bool ok = 1;
// Код причины при ok=false: "no_history" (остальные поля пусты).
optional string error = 2;
// Сообщения выборки (форумы — по активным темам, topic_id/topic_title
// заполнены; для обычных источников — null).
repeated EvalMessage messages = 3;
}
// Сообщение выборки discovery_read (_discovery_message_item L803816).
message EvalMessage {
// Id сообщения в Telegram.
int64 id = 1;
// Текст сообщения (непустой; пустые тексты отбрасывает сервис).
string text = 2;
// Время сообщения, epoch-ms.
int64 date_ms = 3;
// Id темы форума (для обычных источников пусто).
optional int64 topic_id = 4;
// Название темы форума (для обычных источников пусто).
optional string topic_title = 5;
}
message JoinRequest {
// @username источника (без "@"; пусто → INVALID_ARGUMENT).
string username = 1;
}
message JoinReply {
bool ok = 1;
}
message LeaveRequest {
// Id диалога для выхода.
string dialog_id = 1;
}
message LeaveReply {
bool ok = 1;
}
// ---------------------------------------------------------------------------
// IngressService — исходящий поток telegram-service → ядро
// (gRPC-сервер в Deal.Api :5082; Ruling 7; интерцептор service-token;
// tenantId из metadata → собственный scope с ITenantContext.SetTenant)
// ---------------------------------------------------------------------------
service IngressService {
// Новое/догоняющее сообщение мониторящегося диалога → очередь пайплайна
// ядра (PipelineIngestService.EnqueueAsync, контракт demo-ingest; + превью в
// TgMessages). Дубль dialog+msgId уже в очереди — не растёт (duplicate=true).
rpc PushMessage(PushMessageRequest) returns (PushMessageReply);
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
// отвечает актуальным списком monitored id — сервис держит зеркало
// мониторинга в памяти (Ruling 7), по нему фильтрует события realtime.
rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply);
// Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount
// и публикует SSE system_status + тосты на переходах фаз (Ruling 7).
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
}
// Сообщение из потока в ядро. Поля 1:1 с QueuedMessage/PipelineIngestRequest
// (Ruling 7, demo-ingest L759): dialog_id + канальные поля плоские; msg_id —
// дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now.
message PushMessageRequest {
// Id диалога-источника (подписанный; пуст — приём no-op).
string dialog_id = 1;
// Имя канала/диалога (title/first_name или id).
string channel_name = 2;
// Username канала/диалога (пуст, если нет).
string channel_handle = 3;
// Цвет канала из палитры DIALOG_HUES (hex; считает сервис — Ruling 7).
string channel_hue = 4;
// Id исходного сообщения в Telegram (дубль-гвард dialog+msgId).
optional int64 msg_id = 5;
// Текст сообщения (сервис шлёт как есть; приём обрежет до 6000).
string text = 6;
// Время исходного сообщения, epoch-ms; пусто — ядро подставит now.
optional int64 msg_at = 7;
}
message PushMessageReply {
// True — сообщение принято (no-op с пустым текстом/диалогом — accepted=false).
bool accepted = 1;
// True — дубль dialog_id+msg_id уже в очереди (очередь не выросла).
bool duplicate = 2;
}
message SyncDialogsRequest {
// Актуальный каталог диалогов (собирает сервис, как refresh_dialogs).
repeated DialogEntry entries = 1;
}
message SyncDialogsReply {
// Id диалогов с включённым мониторингом (зеркало сервиса после синка).
repeated string monitored_ids = 1;
}
// Статус аккаунта для ядра (shape прототипа _publish_status L315316/status()).
message ReportStatusRequest {
// Фаза: idle|phone|code|password|qr|ready.
string phase = 1;
// Клиент подключён и авторизован.
bool connected = 2;
// Realtime-listener жив.
bool listener = 3;
// Аккаунт "@username" (пуст после выхода) → KV tgAccount.
string account = 4;
// Текст ошибки (пуст, если нет) → KV tgStatus.error.
optional string error = 5;
// URL QR-входа при phase == "qr".
optional string qr_url = 6;
}
message ReportStatusReply {
// True — статус принят и сохранён ядром.
bool ok = 1;
}