SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
Контракты 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_ofL461–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_<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 L36–47); очистку _clean_keywords и мягкие ошибки делает ядро (Ruling 11) |
EvaluateFit |
EvaluateFitRequest{text, description, keywords[], provider_config} |
EvaluateFitReply{fit, reason?, usage} |
промпт discovery_eval L50–54; ядро зовёт при 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; ядро копит в KVaiTokenUsage).
Ошибки: провайдер не ответил корректно после ретраев → UNAVAILABLE с detail
«ИИ (имя) не ответил корректно — повторите попытку через несколько секунд».
ClassifyReply.ok=false — ответ без разбираемого JSON (не RPC-ошибка); ядро
трактует как «не разобрано» и использует локальный путь.