# Контракты 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` (``), т.е. генерирует
и клиент, и сервер каждого сервиса в одном проходе (неиспользуемая сторона
игнорируется потребителем). Сборка проекта = валидация `.proto` (protoc) и
первый прогон кодогенерации (Task 1); Task 2–4 подключают файлы к сервисам
`ProjectReference`-ом либо ``.
- Пакеты: `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`
L461–466: 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 L162–166, `_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.5–3 с/сообщ. + 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_