Перевести входящий поток источников на generic-контракт
Добавлен sources.proto с PushSource; приём в ядре вынесен в SourceIngressGrpcService с SourceProtoMapper и ISourceIngestObserver, тенант определяется IngressTenantResolver. Из telegram.proto удалён PushMessage, telegram-сервис шлёт generic-записи, превью сохраняет TelegramSourceIngestObserver.
This commit is contained in:
+1
-1
@@ -59,7 +59,7 @@
|
|||||||
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | P3 | TECHDEBT |
|
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | P3 | TECHDEBT |
|
||||||
| TD-SOURCE-PROVIDER | `ISourceContentProvider`/`SourceContentResolver` и `GET /api/cards/{id}/source` добавлены; осталось — реализовать провайдеры источников (telegram/local/file) с ленивой догрузкой | generic source 2026-09-11 | P1 | BACKLOG |
|
| TD-SOURCE-PROVIDER | `ISourceContentProvider`/`SourceContentResolver` и `GET /api/cards/{id}/source` добавлены; осталось — реализовать провайдеры источников (telegram/local/file) с ленивой догрузкой | generic source 2026-09-11 | P1 | BACKLOG |
|
||||||
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером + `TelegramSourceContentProvider` (только telegram-сервис) + рендер `DataRef` в UI | generic source 2026-09-11 | P1 | BACKLOG |
|
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером + `TelegramSourceContentProvider` (только telegram-сервис) + рендер `DataRef` в UI | generic source 2026-09-11 | P1 | BACKLOG |
|
||||||
| TD-TG-CORE-SPLIT | Перенести оставшуюся Telegram-специфику ядра (`TelegramStore`, таблицы `Dialogs`/`TgMessages`, Discovery, ingress-proto) в telegram-сервис; generic ingress-контракт источников | generic source 2026-09-11 | P2 | TECHDEBT |
|
| TD-TG-CORE-SPLIT | Перенести оставшуюся Telegram-специфику ядра (`TelegramStore`, таблицы `Dialogs`/`TgMessages`, Discovery) в telegram-сервис. **Сделано (2026-09-11):** входящий поток переведён на generic `sources.proto`/`PushSource`, `PushMessage` удалён, приём в ядре generic (`SourceIngressGrpcService`). Осталось: каталог (`SyncDialogs`/`ReportStatus`), `TelegramStore`, Discovery | generic source 2026-09-11 | P2 | TECHDEBT |
|
||||||
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG |
|
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG |
|
||||||
| TD-APIMAP-COUNT | Ручной подсчёт числа операторских ручек в `api-map` (может расходиться с группировкой) | docs sweep | P3 | TECHDEBT |
|
| TD-APIMAP-COUNT | Ручной подсчёт числа операторских ручек в `api-map` (может расходиться с группировкой) | docs sweep | P3 | TECHDEBT |
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
> `SourceItem` (`SourceRef` + `SourceContent`, вложения — `DataRef` → общий Storage). Дашборды/канбан/конвейер
|
> `SourceItem` (`SourceRef` + `SourceContent`, вложения — `DataRef` → общий Storage). Дашборды/канбан/конвейер
|
||||||
> больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции
|
> больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции
|
||||||
> пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и
|
> пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и
|
||||||
> `GET /api/cards/{id}/source`. Ядро: build 4 sln 0/0, `Deal.Tests.Unit` **1280/1280 PASS**, фронт `build` +
|
> `GET /api/cards/{id}/source`. Входящий поток источников — generic (`sources.proto`/`PushSource`,
|
||||||
> `lint:i18n` зелёные. Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Ядро: build 5 sln 0/0,
|
||||||
|
> `Deal.Tests.Unit` **1289/1289 PASS**, telegram **125/125**, фронт `build` + `lint:i18n` зелёные.
|
||||||
|
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
||||||
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
||||||
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
||||||
> (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис.
|
> (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис.
|
||||||
|
|||||||
@@ -165,7 +165,19 @@ API ядра: `GET /api/cards/{id}/source` → generic контент.
|
|||||||
|
|
||||||
### Маппинг источника
|
### Маппинг источника
|
||||||
|
|
||||||
- Адаптер Telegram (в ядре — тонкий край приёма): `Kind="telegram"`, `ExternalId=MsgId`,
|
- Telegram-адаптер (в ядре — тонкий край приёма): `Kind="telegram"`, `ExternalId=MsgId`,
|
||||||
`OriginRef=DialogId`, `DisplayName=ChannelName`, `Extra["hue"]=ChannelHue` (иначе дефолт),
|
`OriginRef=DialogId`, `DisplayName=ChannelName`, `Extra["hue"]=ChannelHue` (иначе дефолт),
|
||||||
`ReceivedAt=msgAt`, `Content.Text=Text`, `Content.Author=ChannelName`.
|
`ReceivedAt=msgAt`, `Content.Text=Text`, `Content.Author=ChannelName`.
|
||||||
- Дашборды/карточки/конвейер работают только с `SourceRef`/`SourceContent`; Telegram-поля не проходят дальше адаптера.
|
- Дашборды/карточки/конвейер работают только с `SourceRef`/`SourceContent`; Telegram-поля не проходят дальше адаптера.
|
||||||
|
|
||||||
|
### Входящий поток (generic, 2026-09-11)
|
||||||
|
|
||||||
|
- `src/contracts/sources.proto` → сервис `SourceIngressService.PushSource` с generic-типами
|
||||||
|
`SourceRefProto`/`SourceContentProto`/`DataRefProto`/`ContactRefProto`; вложения — ссылки на Storage.
|
||||||
|
- Ядро: `Deal.Api/Sources/SourceIngressGrpcService` (приём) + `SourceProtoMapper` (proto → домен) +
|
||||||
|
`ISourceIngestObserver` (вторичная обработка принятой записи, сбой наблюдателя не влияет на приём) +
|
||||||
|
`IngressTenantResolver` (тенант по metadata).
|
||||||
|
- Из `telegram.proto` удалён `IngressService.PushMessage` (остались `SyncDialogs`/`ReportStatus`);
|
||||||
|
telegram-сервис шлёт записи через `PushSource` (`kind="telegram"`). Превью каталога/TgMessages сохраняет
|
||||||
|
`TelegramSourceIngestObserver` (ядро, telegram-модуль — единственное место с telegram-спецификой приёма).
|
||||||
|
- Любой другой источник (whatsapp/avito/файл/excel) шлёт тот же `PushSource` со своим `source.kind`.
|
||||||
|
|||||||
@@ -163,7 +163,8 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
- `telegram.proto` (пакет `deal.telegram.v1`) — два сервиса: `TelegramService` — команды ядра к
|
- `telegram.proto` (пакет `deal.telegram.v1`) — два сервиса: `TelegramService` — команды ядра к
|
||||||
telegram-service (GetStatus, StartPhone, StartQr, SendCode, SendPassword, Logout, RefreshDialogs,
|
telegram-service (GetStatus, StartPhone, StartQr, SendCode, SendPassword, Logout, RefreshDialogs,
|
||||||
SetMonitor, SetMonitorAll, Backfill, ReadRecent, Search, GetInfo, ReadForEval, Join, Leave);
|
SetMonitor, SetMonitorAll, Backfill, ReadRecent, Search, GetInfo, ReadForEval, Join, Leave);
|
||||||
`IngressService` — исходящий поток telegram-service → core (PushMessage, SyncDialogs, ReportStatus;
|
`SourceIngressService.PushSource` (generic-контракт источников, `sources.proto`) и `IngressService`
|
||||||
|
(SyncDialogs, ReportStatus) — исходящий поток telegram-service → core;
|
||||||
сервер — gRPC-ингресс core :5082).
|
сервер — gRPC-ингресс core :5082).
|
||||||
- `ml.proto` (пакет `deal.ml.v1`) — `MlService`: Predict (text → {take,label,scores,margin,type}),
|
- `ml.proto` (пакет `deal.ml.v1`) — `MlService`: Predict (text → {take,label,scores,margin,type}),
|
||||||
Status, Reset, TrainBatch; всё с metadata `tenant-id`.
|
Status, Reset, TrainBatch; всё с metadata `tenant-id`.
|
||||||
@@ -959,7 +960,7 @@ ml 36/36 PASS; build 0/0 всех четырёх sln; `docker compose -f deploy/
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `deal-postgres` | **5433** | БД (host-порт; внутри — 5432) |
|
| `deal-postgres` | **5433** | БД (host-порт; внутри — 5432) |
|
||||||
| `deal-minio` | **9000/9001** | S3-API / консоль (файлы вложений; в Local-режиме необязателен) |
|
| `deal-minio` | **9000/9001** | S3-API / консоль (файлы вложений; в Local-режиме необязателен) |
|
||||||
| `deal-core` (Deal.Api) | HTTP **5080**, gRPC-ингресс **5082** | портал `/api` + приём PushMessage/SyncDialogs/ReportStatus |
|
| `deal-core` (Deal.Api) | HTTP **5080**, gRPC-ингресс **5082** | портал `/api` + приём PushSource/SyncDialogs/ReportStatus |
|
||||||
| `deal-telegram-service` | **5101** | Telegram: сессии/QR/диалоги/мониторинг/backfill/discovery-операции |
|
| `deal-telegram-service` | **5101** | Telegram: сессии/QR/диалоги/мониторинг/backfill/discovery-операции |
|
||||||
| `deal-ai-service` | **5102** | LLM-фасад: Filter/Classify/GenerateKeywords/EvaluateFit |
|
| `deal-ai-service` | **5102** | LLM-фасад: Filter/Classify/GenerateKeywords/EvaluateFit |
|
||||||
| `deal-ml-service` | **5103** | инкрементальная модель per-tenant: predict/train/status/reset |
|
| `deal-ml-service` | **5103** | инкрементальная модель per-tenant: predict/train/status/reset |
|
||||||
@@ -1032,6 +1033,11 @@ docker compose -f deploy/compose.dev.yml down # погасить ст
|
|||||||
`PipelineIngestService.EnqueueAsync` (тот же контракт, что demo-ingest) + превью в `TgMessages`;
|
`PipelineIngestService.EnqueueAsync` (тот же контракт, что demo-ingest) + превью в `TgMessages`;
|
||||||
`SyncDialogs` → синхронизация каталога/мониторинга; `ReportStatus` → KV `tgStatus`/`tgAccount` + SSE
|
`SyncDialogs` → синхронизация каталога/мониторинга; `ReportStatus` → KV `tgStatus`/`tgAccount` + SSE
|
||||||
`system_status`/тосты переходов.
|
`system_status`/тосты переходов.
|
||||||
|
|
||||||
|
> **Актуально с 2026-09-11:** приём записей вынесен из `TelegramIngressService` в generic
|
||||||
|
> `Deal.Api/Sources/SourceIngressGrpcService` (`sources.proto` → `PushSource`, proto → домен через
|
||||||
|
> `SourceProtoMapper`, тенант — `IngressTenantResolver`, превью — `TelegramSourceIngestObserver`).
|
||||||
|
> `TelegramIngressService` обслуживает только `SyncDialogs`/`ReportStatus`.
|
||||||
- Эндпоинты 1:1 api-map §3.3 (14 шт.): статус (§4.9 — live-поля фазы, `account` из KV, `monitored` из
|
- Эндпоинты 1:1 api-map §3.3 (14 шт.): статус (§4.9 — live-поля фазы, `account` из KV, `monitored` из
|
||||||
`count(Dialogs WHERE Monitor)`, `keysSet`), start-phone/start-qr/send-code/send-password/logout,
|
`count(Dialogs WHERE Monitor)`, `keysSet`), start-phone/start-qr/send-code/send-password/logout,
|
||||||
QR-image (SVG, Net.Codecrete.QrCodeGenerator; 404 «QR не активен — начните вход по QR»), dialogs/refresh/
|
QR-image (SVG, Net.Codecrete.QrCodeGenerator; 404 «QR не активен — начните вход по QR»), dialogs/refresh/
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Контракты этапа 6; файлы лежат рядом с проектом (src/contracts). -->
|
<!-- Контракты этапа 6; файлы лежат рядом с проектом (src/contracts). -->
|
||||||
<Protobuf Include="telegram.proto" GrpcServices="Both" />
|
<Protobuf Include="telegram.proto" GrpcServices="Both" />
|
||||||
|
<Protobuf Include="sources.proto" GrpcServices="Both" />
|
||||||
<Protobuf Include="ml.proto" GrpcServices="Both" />
|
<Protobuf Include="ml.proto" GrpcServices="Both" />
|
||||||
<Protobuf Include="ai.proto" GrpcServices="Both" />
|
<Protobuf Include="ai.proto" GrpcServices="Both" />
|
||||||
<Protobuf Include="storage.proto" GrpcServices="Both" />
|
<Protobuf Include="storage.proto" GrpcServices="Both" />
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//
|
||||||
|
// Контракт входящего потока источников: сервис-источник → ядро.
|
||||||
|
//
|
||||||
|
// Любой источник (telegram, whatsapp, avito, сайт, файл, excel) приводит свои
|
||||||
|
// данные к единому контракту SourceItem и шлёт их одним вызовом PushSource.
|
||||||
|
// Ядро не знает о природе источника: вид задаётся полем source.kind.
|
||||||
|
//
|
||||||
|
// tenant-id — id тенанта (metadata; единственный источник принадлежности);
|
||||||
|
// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/
|
||||||
|
// пустой → UNAUTHENTICATED.
|
||||||
|
//
|
||||||
|
// Файлы вложений источник сам выгружает в сервис данных (storage.proto) и
|
||||||
|
// передаёт здесь ссылкой в content.data.
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package deal.sources.v1;
|
||||||
|
|
||||||
|
option csharp_namespace = "Deal.Grpc.Sources";
|
||||||
|
|
||||||
|
service SourceIngressService {
|
||||||
|
// Запись источника → очередь конвейера ядра. Дубль (вид+оригинал+внешний id)
|
||||||
|
// уже в очереди — очередь не растёт (duplicate=true).
|
||||||
|
rpc PushSource(PushSourceRequest) returns (PushSourceReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
message PushSourceRequest {
|
||||||
|
SourceRefProto source = 1;
|
||||||
|
SourceContentProto content = 2;
|
||||||
|
// True — вернуть из отсева: правила/устарелость/ИИ-фильтр пропускаются.
|
||||||
|
bool force = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SourceRefProto {
|
||||||
|
// Дискриминатор источника, задаёт владелец (telegram/file/local/...).
|
||||||
|
string kind = 1;
|
||||||
|
// Идентификатор записи в источнике (сообщение/строка/файл).
|
||||||
|
optional string external_id = 2;
|
||||||
|
// Подпись источника для интерфейса.
|
||||||
|
optional string display_name = 3;
|
||||||
|
// Ссылка на оригинал (url, deep-link, путь).
|
||||||
|
optional string origin_ref = 4;
|
||||||
|
optional string author = 5;
|
||||||
|
// Время получения записи, epoch-ms.
|
||||||
|
int64 received_at = 6;
|
||||||
|
// Прочие метаданные источника (в т.ч. цвет интерфейса — hue).
|
||||||
|
map<string, string> extra = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SourceContentProto {
|
||||||
|
optional string text = 1;
|
||||||
|
optional string html = 2;
|
||||||
|
optional string author = 3;
|
||||||
|
optional string subject = 4;
|
||||||
|
// Вложения: ссылки на объекты сервиса данных.
|
||||||
|
repeated DataRefProto data = 5;
|
||||||
|
// Ссылки, не являющиеся файлами.
|
||||||
|
repeated string links = 6;
|
||||||
|
repeated ContactRefProto contacts = 7;
|
||||||
|
map<string, string> extra = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DataRefProto {
|
||||||
|
string id = 1;
|
||||||
|
string ref = 2;
|
||||||
|
optional string kind = 3;
|
||||||
|
optional string mime_type = 4;
|
||||||
|
optional string file_name = 5;
|
||||||
|
optional int64 size = 6;
|
||||||
|
optional int32 width = 7;
|
||||||
|
optional int32 height = 8;
|
||||||
|
optional double duration_sec = 9;
|
||||||
|
optional string preview_ref = 10;
|
||||||
|
optional string caption = 11;
|
||||||
|
optional int32 order = 12;
|
||||||
|
map<string, string> meta = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContactRefProto {
|
||||||
|
optional string kind = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
optional string phone = 3;
|
||||||
|
optional string email = 4;
|
||||||
|
optional string url = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PushSourceReply {
|
||||||
|
// True — запись принята (пустой текст и пустой контент — accepted=false).
|
||||||
|
bool accepted = 1;
|
||||||
|
// True — дубль уже в очереди (очередь не выросла).
|
||||||
|
bool duplicate = 2;
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
|
// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway):
|
||||||
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
|
// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill,
|
||||||
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
|
// превью, discovery-операции (поиск/инфо/чтение/вступление/выход);
|
||||||
// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения
|
// * IngressService — исходящий поток telegram-service → ядро: синхронизация
|
||||||
// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта
|
// каталога (SyncDialogs), статус аккаунта (ReportStatus).
|
||||||
|
// * Сообщения мониторящихся диалогов приходят в ядро generic-контрактом
|
||||||
|
// sources.proto (PushSource) — ядро не привязано к природе источника.
|
||||||
//
|
//
|
||||||
//
|
//
|
||||||
// tenant-id — id тенанта (строка; единственный источник принадлежности,
|
// tenant-id — id тенанта (строка; единственный источник принадлежности,
|
||||||
@@ -328,11 +330,6 @@ message LeaveReply {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
service IngressService {
|
service IngressService {
|
||||||
// Новое/догоняющее сообщение мониторящегося диалога → очередь пайплайна
|
|
||||||
// ядра (PipelineIngestService.EnqueueAsync, контракт demo-ingest; + превью в
|
|
||||||
// TgMessages). Дубль dialog+msgId уже в очереди — не растёт (duplicate=true).
|
|
||||||
rpc PushMessage(PushMessageRequest) returns (PushMessageReply);
|
|
||||||
|
|
||||||
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
|
// Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram:
|
||||||
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
|
// авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и
|
||||||
// отвечает актуальным списком monitored id — сервис держит зеркало
|
// отвечает актуальным списком monitored id — сервис держит зеркало
|
||||||
@@ -342,30 +339,6 @@ service IngressService {
|
|||||||
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
|
rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
// дубль-гвард; 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;
|
|
||||||
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 {
|
message SyncDialogsRequest {
|
||||||
// Актуальный каталог диалогов (собирает сервис, как refresh_dialogs).
|
// Актуальный каталог диалогов (собирает сервис, как refresh_dialogs).
|
||||||
repeated DialogEntry entries = 1;
|
repeated DialogEntry entries = 1;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using Deal.Api.Logging;
|
|||||||
using Deal.Api.Middleware;
|
using Deal.Api.Middleware;
|
||||||
using Deal.Api.Observability;
|
using Deal.Api.Observability;
|
||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
|
using Deal.Api.Sources;
|
||||||
using Deal.Api.Telegram;
|
using Deal.Api.Telegram;
|
||||||
using Deal.Contracts.Integrations.Abstractions;
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
using Deal.Infrastructure;
|
using Deal.Infrastructure;
|
||||||
@@ -199,6 +200,8 @@ if (rateLimitOptions.Enabled)
|
|||||||
}
|
}
|
||||||
|
|
||||||
builder.Services.AddScoped<TelegramIngressService>();
|
builder.Services.AddScoped<TelegramIngressService>();
|
||||||
|
builder.Services.AddScoped<IngressTenantResolver>();
|
||||||
|
builder.Services.AddScoped<SourceIngressGrpcService>();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddGrpcHealthChecks()
|
.AddGrpcHealthChecks()
|
||||||
@@ -361,6 +364,7 @@ app.MapTelegramEndpoints();
|
|||||||
app.MapTelegramQrImageEndpoint();
|
app.MapTelegramQrImageEndpoint();
|
||||||
app.MapDiscoveryEndpoints();
|
app.MapDiscoveryEndpoints();
|
||||||
app.MapGrpcService<TelegramIngressService>().DisableRateLimiting();
|
app.MapGrpcService<TelegramIngressService>().DisableRateLimiting();
|
||||||
|
app.MapGrpcService<SourceIngressGrpcService>().DisableRateLimiting();
|
||||||
app.MapGrpcHealthChecksService().DisableRateLimiting();
|
app.MapGrpcHealthChecksService().DisableRateLimiting();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Grpc.Core;
|
||||||
|
|
||||||
|
namespace Deal.Api.Sources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Определение тенанта gRPC-вызова входящего потока по metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scopeFactory">Фабрика scope для чтения реестра тенантов.</param>
|
||||||
|
/// <param name="logger">Логгер отказов.</param>
|
||||||
|
public sealed class IngressTenantResolver(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<IngressTenantResolver> logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Ключ metadata с id тенанта.
|
||||||
|
/// </summary>
|
||||||
|
public const string TenantIdMetadataKey = "tenant-id";
|
||||||
|
|
||||||
|
private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Читает обязательный id тенанта из metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">Контекст вызова.</param>
|
||||||
|
/// <returns>Значение tenant-id.</returns>
|
||||||
|
/// <exception cref="RpcException">tenant-id отсутствует (UNAUTHENTICATED).</exception>
|
||||||
|
public static string RequireTenantIdMetadata(ServerCallContext context)
|
||||||
|
{
|
||||||
|
string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(tenantId))
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Находит тенанта вызова в реестре.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">Контекст вызова.</param>
|
||||||
|
/// <returns>Запись тенанта либо null — id неизвестен/не Guid/нет в реестре.</returns>
|
||||||
|
public async Task<TenantRecordDto?> ResolveAsync(ServerCallContext context)
|
||||||
|
{
|
||||||
|
string tenantId = RequireTenantIdMetadata(context);
|
||||||
|
if (!Guid.TryParse(tenantId, out Guid tenantGuid))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope();
|
||||||
|
ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||||
|
TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false);
|
||||||
|
if (tenant is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using Deal.Grpc.Sources;
|
||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
|
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||||
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
|
using Deal.Modules.Pipeline.Application.Services;
|
||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
|
using Grpc.Core;
|
||||||
|
|
||||||
|
namespace Deal.Api.Sources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// gRPC-сервер входящего потока источников (generic-контракт).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scopeFactory">Фабрика tenant-scope приёма.</param>
|
||||||
|
/// <param name="tenants">Определение тенанта вызова.</param>
|
||||||
|
/// <param name="logger">Логгер приёма.</param>
|
||||||
|
public sealed class SourceIngressGrpcService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IngressTenantResolver tenants,
|
||||||
|
ILogger<SourceIngressGrpcService> logger) : SourceIngressService.SourceIngressServiceBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// PushSource — запись источника в очередь пайплайна тенанта.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Запись источника (source + content + force).</param>
|
||||||
|
/// <param name="context">Контекст вызова (metadata tenant-id + service-token).</param>
|
||||||
|
/// <returns>accepted — запись принята (либо дубль), duplicate — уже была в очереди.</returns>
|
||||||
|
public override async Task<PushSourceReply> PushSource(PushSourceRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
TenantRecordDto? tenant = await tenants.ResolveAsync(context).ConfigureAwait(false);
|
||||||
|
if (tenant is null)
|
||||||
|
{
|
||||||
|
return new PushSourceReply();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
|
||||||
|
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
||||||
|
SourceItem item = SourceProtoMapper.ToItem(request);
|
||||||
|
PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService<PipelineIngestService>();
|
||||||
|
|
||||||
|
PipelineIngestResultDto result = await ingest.EnqueueAsync(
|
||||||
|
new QueuedMessage { Item = item, Force = request.Force },
|
||||||
|
context.CancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (ISourceIngestObserver observer in tenantScope.ServiceProvider.GetServices<ISourceIngestObserver>())
|
||||||
|
{
|
||||||
|
await NotifySafelyAsync(observer, item, tenant.Id, context.CancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Аудит: PushSource {TenantId} {Kind} {ExternalId} → {Outcome}",
|
||||||
|
tenant.Id,
|
||||||
|
item.Source.Kind,
|
||||||
|
item.Source.ExternalId ?? "-",
|
||||||
|
result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued");
|
||||||
|
|
||||||
|
return new PushSourceReply
|
||||||
|
{
|
||||||
|
Accepted = result.Id is not null || result.Duplicate,
|
||||||
|
Duplicate = result.Duplicate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Аудит: PushSource {TenantId} → не принято (сбой схемы/БД)", tenant.Id);
|
||||||
|
return new PushSourceReply();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
tenantContext.Reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Наблюдатель — вторичная обработка: его сбой не влияет на приём записи.
|
||||||
|
private async Task NotifySafelyAsync(
|
||||||
|
ISourceIngestObserver observer,
|
||||||
|
SourceItem item,
|
||||||
|
Guid tenantId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await observer.OnIngestedAsync(item, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogDebug(exception, "PushSource {TenantId}: наблюдатель {Observer} не отработал (приём не затронут)", tenantId, observer.GetType().Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using Deal.Grpc.Sources;
|
||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
|
|
||||||
|
namespace Deal.Api.Sources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Маппинг generic-контракта источника в доменную модель.
|
||||||
|
/// </summary>
|
||||||
|
public static class SourceProtoMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Преобразует protobuf-запись источника в доменную.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Запрос PushSource.</param>
|
||||||
|
/// <returns>Запись источника домена.</returns>
|
||||||
|
public static SourceItem ToItem(PushSourceRequest request) => new()
|
||||||
|
{
|
||||||
|
Source = ToRef(request.Source),
|
||||||
|
Content = ToContent(request.Content),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SourceRef ToRef(SourceRefProto proto) => new()
|
||||||
|
{
|
||||||
|
Kind = proto.Kind,
|
||||||
|
ExternalId = proto.HasExternalId ? proto.ExternalId : null,
|
||||||
|
DisplayName = proto.HasDisplayName ? proto.DisplayName : null,
|
||||||
|
OriginRef = proto.HasOriginRef ? proto.OriginRef : null,
|
||||||
|
Author = proto.HasAuthor ? proto.Author : null,
|
||||||
|
ReceivedAt = proto.ReceivedAt != 0 ? DateTimeOffset.FromUnixTimeMilliseconds(proto.ReceivedAt) : DateTimeOffset.UtcNow,
|
||||||
|
Extra = proto.Extra.Count > 0 ? new Dictionary<string, string>(proto.Extra) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SourceContent ToContent(SourceContentProto proto)
|
||||||
|
{
|
||||||
|
var data = new List<DataRef>(proto.Data.Count);
|
||||||
|
foreach (DataRefProto item in proto.Data)
|
||||||
|
{
|
||||||
|
data.Add(ToData(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
var contacts = new List<ContactRef>(proto.Contacts.Count);
|
||||||
|
foreach (ContactRefProto contact in proto.Contacts)
|
||||||
|
{
|
||||||
|
contacts.Add(ToContact(contact));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SourceContent
|
||||||
|
{
|
||||||
|
Text = proto.HasText ? proto.Text : null,
|
||||||
|
Html = proto.HasHtml ? proto.Html : null,
|
||||||
|
Author = proto.HasAuthor ? proto.Author : null,
|
||||||
|
Subject = proto.HasSubject ? proto.Subject : null,
|
||||||
|
Data = data,
|
||||||
|
Links = proto.Links.Count > 0 ? proto.Links.ToList() : null,
|
||||||
|
Contacts = contacts.Count > 0 ? contacts : null,
|
||||||
|
Extra = proto.Extra.Count > 0 ? new Dictionary<string, string>(proto.Extra) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataRef ToData(DataRefProto proto) => new()
|
||||||
|
{
|
||||||
|
Id = proto.Id,
|
||||||
|
Ref = proto.Ref,
|
||||||
|
Kind = proto.HasKind ? proto.Kind : null,
|
||||||
|
MimeType = proto.HasMimeType ? proto.MimeType : null,
|
||||||
|
FileName = proto.HasFileName ? proto.FileName : null,
|
||||||
|
Size = proto.HasSize ? proto.Size : null,
|
||||||
|
Width = proto.HasWidth ? proto.Width : null,
|
||||||
|
Height = proto.HasHeight ? proto.Height : null,
|
||||||
|
DurationSec = proto.HasDurationSec ? proto.DurationSec : null,
|
||||||
|
PreviewRef = proto.HasPreviewRef ? proto.PreviewRef : null,
|
||||||
|
Caption = proto.HasCaption ? proto.Caption : null,
|
||||||
|
Order = proto.HasOrder ? proto.Order : null,
|
||||||
|
Meta = proto.Meta.Count > 0 ? new Dictionary<string, string>(proto.Meta) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ContactRef ToContact(ContactRefProto proto) => new()
|
||||||
|
{
|
||||||
|
Kind = proto.HasKind ? proto.Kind : null,
|
||||||
|
Name = proto.HasName ? proto.Name : null,
|
||||||
|
Phone = proto.HasPhone ? proto.Phone : null,
|
||||||
|
Email = proto.HasEmail ? proto.Email : null,
|
||||||
|
Url = proto.HasUrl ? proto.Url : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Deal.Api.Events;
|
using Deal.Api.Events;
|
||||||
|
using Deal.Api.Sources;
|
||||||
using Deal.Contracts.Integrations.Models;
|
using Deal.Contracts.Integrations.Models;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Telegram;
|
||||||
using Deal.Modules.Cards.Application.Sources;
|
|
||||||
using Deal.Modules.Pipeline.Application.Models;
|
|
||||||
using Deal.Modules.Pipeline.Application.Services;
|
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Modules.Telegram.Application;
|
using Deal.Modules.Telegram.Application;
|
||||||
using Deal.Modules.Tenants.Application.Abstractions;
|
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.SharedKernel.Tenants.Abstractions;
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
using Deal.SharedKernel.Tenants.Models;
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
@@ -18,14 +14,18 @@ using Grpc.Core;
|
|||||||
namespace Deal.Api.Telegram;
|
namespace Deal.Api.Telegram;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// gRPC-сервер входящего потока telegram-service → ядро.
|
/// gRPC-сервер служебного потока telegram-service → ядро: каталог диалогов и статус аккаунта.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TelegramIngressService(
|
public sealed class TelegramIngressService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
SseBroker broker,
|
SseBroker broker,
|
||||||
|
IngressTenantResolver tenants,
|
||||||
ILogger<TelegramIngressService> logger) : IngressService.IngressServiceBase
|
ILogger<TelegramIngressService> logger) : IngressService.IngressServiceBase
|
||||||
{
|
{
|
||||||
public const string TenantIdMetadataKey = "tenant-id";
|
/// <summary>
|
||||||
|
/// Ключ metadata с id тенанта.
|
||||||
|
/// </summary>
|
||||||
|
public const string TenantIdMetadataKey = IngressTenantResolver.TenantIdMetadataKey;
|
||||||
|
|
||||||
private const string SystemStatusEventType = "system_status";
|
private const string SystemStatusEventType = "system_status";
|
||||||
|
|
||||||
@@ -41,93 +41,12 @@ public sealed class TelegramIngressService(
|
|||||||
// Иконка тоста отключения (из набора Icon.vue фронта).
|
// Иконка тоста отключения (из набора Icon.vue фронта).
|
||||||
private const string DisconnectedToastIcon = "logout";
|
private const string DisconnectedToastIcon = "logout";
|
||||||
|
|
||||||
// Деталь отказа: metadata tenant-id отсутствует (UNAUTHENTICATED, README src/contracts).
|
|
||||||
private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata";
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions StatusJsonOptions = new()
|
private static readonly JsonSerializerOptions StatusJsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Сообщение из потока telegram-service.</param>
|
|
||||||
/// <param name="context">Контекст вызова (metadata tenant-id + service-token).</param>
|
|
||||||
/// <returns>accepted — сообщение принято (либо дубль), duplicate — уже было в очереди.</returns>
|
|
||||||
public override async Task<PushMessageReply> PushMessage(PushMessageRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
|
|
||||||
if (tenant is null)
|
|
||||||
{
|
|
||||||
return new PushMessageReply();
|
|
||||||
}
|
|
||||||
|
|
||||||
await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope();
|
|
||||||
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
|
|
||||||
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
|
||||||
PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService<PipelineIngestService>();
|
|
||||||
|
|
||||||
string hue = string.IsNullOrWhiteSpace(request.ChannelHue) ? SourceRefs.DefaultHue : request.ChannelHue;
|
|
||||||
DateTimeOffset receivedAt = request.HasMsgAt
|
|
||||||
? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt)
|
|
||||||
: DateTimeOffset.UtcNow;
|
|
||||||
var item = new SourceItem
|
|
||||||
{
|
|
||||||
Source = new SourceRef
|
|
||||||
{
|
|
||||||
Kind = "telegram",
|
|
||||||
ExternalId = request.HasMsgId ? request.MsgId.ToString(CultureInfo.InvariantCulture) : null,
|
|
||||||
OriginRef = request.DialogId,
|
|
||||||
DisplayName = request.ChannelName,
|
|
||||||
ReceivedAt = receivedAt,
|
|
||||||
Extra = new Dictionary<string, string> { [SourceRefs.HueKey] = hue },
|
|
||||||
},
|
|
||||||
Content = new SourceContent
|
|
||||||
{
|
|
||||||
Text = request.Text,
|
|
||||||
Author = request.ChannelName,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
PipelineIngestResultDto result = await ingest.EnqueueAsync(
|
|
||||||
new QueuedMessage { Item = item },
|
|
||||||
context.CancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
logger.LogInformation(
|
|
||||||
"Аудит: PushMessage {TenantId} диалог {DialogId} msg {MsgId} → {Outcome}",
|
|
||||||
tenant.Id,
|
|
||||||
request.DialogId,
|
|
||||||
request.HasMsgId ? request.MsgId.ToString() : "-",
|
|
||||||
result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued");
|
|
||||||
|
|
||||||
return new PushMessageReply
|
|
||||||
{
|
|
||||||
Accepted = result.Id is not null || result.Duplicate,
|
|
||||||
Duplicate = result.Duplicate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id);
|
|
||||||
return new PushMessageReply();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
tenantContext.Reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SyncDialogs — синхронизация каталога диалогов аккаунта.
|
/// SyncDialogs — синхронизация каталога диалогов аккаунта.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -136,7 +55,7 @@ public sealed class TelegramIngressService(
|
|||||||
/// <returns>monitored_ids — диалоги с включённым мониторингом после применения каталога.</returns>
|
/// <returns>monitored_ids — диалоги с включённым мониторингом после применения каталога.</returns>
|
||||||
public override async Task<SyncDialogsReply> SyncDialogs(SyncDialogsRequest request, ServerCallContext context)
|
public override async Task<SyncDialogsReply> SyncDialogs(SyncDialogsRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
|
TenantRecordDto? tenant = await tenants.ResolveAsync(context).ConfigureAwait(false);
|
||||||
if (tenant is null)
|
if (tenant is null)
|
||||||
{
|
{
|
||||||
return new SyncDialogsReply();
|
return new SyncDialogsReply();
|
||||||
@@ -193,7 +112,7 @@ public sealed class TelegramIngressService(
|
|||||||
/// <returns>ok — статус принят и сохранён.</returns>
|
/// <returns>ok — статус принят и сохранён.</returns>
|
||||||
public override async Task<ReportStatusReply> ReportStatus(ReportStatusRequest request, ServerCallContext context)
|
public override async Task<ReportStatusReply> ReportStatus(ReportStatusRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false);
|
TenantRecordDto? tenant = await tenants.ResolveAsync(context).ConfigureAwait(false);
|
||||||
if (tenant is null)
|
if (tenant is null)
|
||||||
{
|
{
|
||||||
return new ReportStatusReply();
|
return new ReportStatusReply();
|
||||||
@@ -237,68 +156,6 @@ public sealed class TelegramIngressService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<TenantRecordDto?> ResolveTenantAsync(ServerCallContext context)
|
|
||||||
{
|
|
||||||
string tenantId = RequireTenantIdMetadata(context);
|
|
||||||
if (!Guid.TryParse(tenantId, out Guid tenantGuid))
|
|
||||||
{
|
|
||||||
logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope();
|
|
||||||
ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
|
||||||
TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false);
|
|
||||||
if (tenant is null)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, README).
|
|
||||||
// context: Контекст вызова.
|
|
||||||
// Возвращает: Значение tenant-id.
|
|
||||||
private static string RequireTenantIdMetadata(ServerCallContext context)
|
|
||||||
{
|
|
||||||
string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey);
|
|
||||||
if (string.IsNullOrWhiteSpace(tenantId))
|
|
||||||
{
|
|
||||||
throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail));
|
|
||||||
}
|
|
||||||
|
|
||||||
return tenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SavePreviewSafelyAsync(
|
|
||||||
AsyncServiceScope tenantScope,
|
|
||||||
TenantRecordDto tenant,
|
|
||||||
PushMessageRequest request,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService<DialogsService>();
|
|
||||||
DateTimeOffset? msgAt = request.HasMsgAt ? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt) : null;
|
|
||||||
await dialogs.SavePreviewAsync(
|
|
||||||
request.DialogId,
|
|
||||||
request.HasMsgId ? request.MsgId : null,
|
|
||||||
request.Text,
|
|
||||||
msgAt,
|
|
||||||
ct).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
// Превью — вторичная запись: приём не затронут (лог дебага, не ошибка RPC).
|
|
||||||
logger.LogDebug(exception, "PushMessage {TenantId}: превью не сохранено (приём не затронут)", tenant.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Публикует SSE system_status (каждый репорт) и тосты на переходах connected.
|
// Публикует SSE system_status (каждый репорт) и тосты на переходах connected.
|
||||||
// tenantId: Тенант канала (реестровый Guid).
|
// tenantId: Тенант канала (реестровый Guid).
|
||||||
// previous: Предыдущий снимок из KV (null — первый репорт).
|
// previous: Предыдущий снимок из KV (null — первый репорт).
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Deal.Modules.Cards.Application.Sources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Уведомление о принятой записи источника.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISourceIngestObserver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Обрабатывает принятую запись источника.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">Принятая запись.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <returns>Завершается после обработки; сбой наблюдателя не влияет на приём.</returns>
|
||||||
|
public Task OnIngestedAsync(SourceItem item, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Deal.Modules.Telegram.Application;
|
namespace Deal.Modules.Telegram.Application;
|
||||||
@@ -13,6 +14,7 @@ public static class TelegramModuleRegistrar
|
|||||||
public static IServiceCollection AddTelegramModule(this IServiceCollection services)
|
public static IServiceCollection AddTelegramModule(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<DialogsService>();
|
services.AddScoped<DialogsService>();
|
||||||
|
services.AddScoped<ISourceIngestObserver, TelegramSourceIngestObserver>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Deal.Modules.Telegram.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Превью принятых сообщений источника telegram: последнее сообщение каталога и TgMessages.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dialogs">Каталог диалогов тенанта.</param>
|
||||||
|
/// <param name="logger">Логгер сбоев записи.</param>
|
||||||
|
public sealed class TelegramSourceIngestObserver(
|
||||||
|
DialogsService dialogs,
|
||||||
|
ILogger<TelegramSourceIngestObserver> logger) : ISourceIngestObserver
|
||||||
|
{
|
||||||
|
private const string TelegramKind = "telegram";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task OnIngestedAsync(SourceItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!string.Equals(item.Source.Kind, TelegramKind, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string dialogId = item.Source.OriginRef ?? string.Empty;
|
||||||
|
if (dialogId.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long? msgId = long.TryParse(item.Source.ExternalId, out long parsed) ? parsed : null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await dialogs
|
||||||
|
.SavePreviewAsync(dialogId, msgId, item.Content.Text ?? string.Empty, item.Source.ReceivedAt, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogDebug(exception, "Превью {DialogId} не сохранено (приём не затронут)", dialogId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<ProjectReference Include="..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
|
<ProjectReference Include="..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
|
||||||
<ProjectReference Include="..\Deal.Contracts\Deal.Contracts.csproj" />
|
<ProjectReference Include="..\Deal.Contracts\Deal.Contracts.csproj" />
|
||||||
<ProjectReference Include="..\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
|
<ProjectReference Include="..\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
|
||||||
|
<ProjectReference Include="..\Deal.Modules.Cards\Deal.Modules.Cards.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using Deal.Api.Configuration;
|
using Deal.Api.Configuration;
|
||||||
using Deal.Api.Middleware;
|
using Deal.Api.Middleware;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Abstractions;
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
@@ -28,7 +28,7 @@ public sealed class IngressRateLimitInterceptorTests
|
|||||||
// Тенант B сценариев (в реестре) — проверка изоляции окон.
|
// Тенант B сценариев (в реестре) — проверка изоляции окон.
|
||||||
private static readonly Guid TenantB = Guid.NewGuid();
|
private static readonly Guid TenantB = Guid.NewGuid();
|
||||||
|
|
||||||
// Id диалога сценариев PushMessage.
|
// Id диалога-оригинала сценариев PushSource.
|
||||||
private const string DialogId = "d_channel_rate_limit";
|
private const string DialogId = "d_channel_rate_limit";
|
||||||
|
|
||||||
// Окно сценария по умолчанию: 2/мин — быстрый тест без ожидания реального окна.
|
// Окно сценария по умолчанию: 2/мин — быстрый тест без ожидания реального окна.
|
||||||
@@ -43,7 +43,7 @@ public sealed class IngressRateLimitInterceptorTests
|
|||||||
/// 3-й вызов тенанта в минуту
|
/// 3-й вызов тенанта в минуту
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushMessage_ExceedingTenantWindow_ThirdRejectedOtherTenantPasses()
|
public async Task PushSource_ExceedingTenantWindow_ThirdRejectedOtherTenantPasses()
|
||||||
{
|
{
|
||||||
var registry = new FakeTenantRegistry(Tenant(TenantA), Tenant(TenantB));
|
var registry = new FakeTenantRegistry(Tenant(TenantA), Tenant(TenantB));
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@ public sealed class IngressRateLimitInterceptorTests
|
|||||||
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
|
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
|
||||||
async channel =>
|
async channel =>
|
||||||
{
|
{
|
||||||
PushMessageReply first = await PushAsync(channel, TenantA);
|
PushSourceReply first = await PushAsync(channel, TenantA);
|
||||||
PushMessageReply second = await PushAsync(channel, TenantA);
|
PushSourceReply second = await PushAsync(channel, TenantA);
|
||||||
Assert.True(first.Accepted);
|
Assert.True(first.Accepted);
|
||||||
Assert.True(second.Accepted);
|
Assert.True(second.Accepted);
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ public sealed class IngressRateLimitInterceptorTests
|
|||||||
Assert.Equal(RateLimitPolicies.RejectedDetail, rejection.Status.Detail);
|
Assert.Equal(RateLimitPolicies.RejectedDetail, rejection.Status.Detail);
|
||||||
|
|
||||||
// Окно TenantB — отдельная партиция: вызов проходит.
|
// Окно TenantB — отдельная партиция: вызов проходит.
|
||||||
PushMessageReply tenantB = await PushAsync(channel, TenantB);
|
PushSourceReply tenantB = await PushAsync(channel, TenantB);
|
||||||
Assert.True(tenantB.Accepted);
|
Assert.True(tenantB.Accepted);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ public sealed class IngressRateLimitInterceptorTests
|
|||||||
async channel =>
|
async channel =>
|
||||||
{
|
{
|
||||||
// Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит.
|
// Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит.
|
||||||
PushMessageReply push = await PushAsync(channel, TenantA);
|
PushSourceReply push = await PushAsync(channel, TenantA);
|
||||||
Assert.True(push.Accepted);
|
Assert.True(push.Accepted);
|
||||||
|
|
||||||
var healthClient = new Health.HealthClient(channel);
|
var healthClient = new Health.HealthClient(channel);
|
||||||
@@ -128,22 +128,25 @@ public sealed class IngressRateLimitInterceptorTests
|
|||||||
GrpcIngressPerMinute = permitsPerMinute,
|
GrpcIngressPerMinute = permitsPerMinute,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Вызывает PushMessage с metadata сценария (service-token + tenant-id, deadline 10 с).
|
// Вызывает PushSource с metadata сценария (service-token + tenant-id, deadline 10 с).
|
||||||
// channel: Канал к хосту.
|
// channel: Канал к хосту.
|
||||||
// tenantId: Id тенанта в metadata.
|
// tenantId: Id тенанта в metadata.
|
||||||
private static Task<PushMessageReply> PushAsync(GrpcChannel channel, Guid tenantId)
|
private static Task<PushSourceReply> PushAsync(GrpcChannel channel, Guid tenantId)
|
||||||
{
|
{
|
||||||
var client = new IngressService.IngressServiceClient(channel);
|
var client = new SourceIngressService.SourceIngressServiceClient(channel);
|
||||||
var request = new PushMessageRequest
|
var request = new PushSourceRequest
|
||||||
{
|
{
|
||||||
DialogId = DialogId,
|
Source = new SourceRefProto
|
||||||
ChannelName = "Канал",
|
{
|
||||||
ChannelHandle = "kanal_handle",
|
Kind = "telegram",
|
||||||
ChannelHue = "#a33",
|
OriginRef = DialogId,
|
||||||
Text = "Сообщение сценария лимита",
|
ExternalId = "1",
|
||||||
MsgId = 1,
|
DisplayName = "Канал",
|
||||||
|
},
|
||||||
|
Content = new SourceContentProto { Text = "Сообщение сценария лимита" },
|
||||||
};
|
};
|
||||||
AsyncUnaryCall<PushMessageReply> call = client.PushMessageAsync(
|
request.Source.Extra["hue"] = "#a33";
|
||||||
|
AsyncUnaryCall<PushSourceReply> call = client.PushSourceAsync(
|
||||||
request,
|
request,
|
||||||
new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline()));
|
new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline()));
|
||||||
return call.ResponseAsync;
|
return call.ResponseAsync;
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
using Deal.Grpc.Sources;
|
||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
|
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||||
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Modules.Kanban;
|
||||||
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-proc gRPC-тесты приёма источников (PushSource).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SourceIngressGrpcServiceTests
|
||||||
|
{
|
||||||
|
// Токен сценариев теста.
|
||||||
|
private const string ValidToken = TelegramIngressTestHost.DefaultToken;
|
||||||
|
|
||||||
|
// Тенант A сценариев (в реестре).
|
||||||
|
private static readonly Guid TenantA = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Вид источника сценариев.
|
||||||
|
private const string Kind = "telegram";
|
||||||
|
|
||||||
|
// Id диалога-оригинала сценариев.
|
||||||
|
private const string OriginRef = "d_channel_100";
|
||||||
|
|
||||||
|
// Подпись источника сценариев.
|
||||||
|
private const string DisplayName = "Канал";
|
||||||
|
|
||||||
|
// Цвет источника сценариев.
|
||||||
|
private const string Hue = "#a33";
|
||||||
|
|
||||||
|
// Время получения записи сценариев (epoch-ms).
|
||||||
|
private const long ReceivedAtMs = 1_700_000_000_000;
|
||||||
|
|
||||||
|
// ─── PushSource: приём, дубль, несуществующий тенант ──────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PushSource кладёт строку очереди тенанта и принимает запись
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts()
|
||||||
|
{
|
||||||
|
var store = new FakePipelineStore();
|
||||||
|
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
||||||
|
|
||||||
|
await RunAsync(
|
||||||
|
registry,
|
||||||
|
services => services.AddScoped<IPipelineStore>(_ => store),
|
||||||
|
async channel =>
|
||||||
|
{
|
||||||
|
PushSourceReply reply = await PushAsync(
|
||||||
|
channel,
|
||||||
|
Request("Вакансия Python-разработчика", externalId: "7"),
|
||||||
|
TenantA,
|
||||||
|
ValidToken);
|
||||||
|
|
||||||
|
Assert.True(reply.Accepted);
|
||||||
|
Assert.False(reply.Duplicate);
|
||||||
|
|
||||||
|
QueueItemDto row = Assert.Single(store.Queue);
|
||||||
|
Assert.Equal(Kind, row.Source.Kind);
|
||||||
|
Assert.Equal(OriginRef, row.Source.OriginRef);
|
||||||
|
Assert.Equal("7", row.Source.ExternalId);
|
||||||
|
Assert.Equal(DisplayName, row.Source.DisplayName);
|
||||||
|
Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(ReceivedAtMs), row.Source.ReceivedAt);
|
||||||
|
Assert.Equal(Hue, row.Source.ResolveHue());
|
||||||
|
Assert.Equal("Вакансия Python-разработчика", row.Text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Повтор PushSource того же вида+оригинала+внешнего id — duplicate=true, очередь не растёт
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown()
|
||||||
|
{
|
||||||
|
var store = new FakePipelineStore();
|
||||||
|
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
||||||
|
|
||||||
|
await RunAsync(
|
||||||
|
registry,
|
||||||
|
services => services.AddScoped<IPipelineStore>(_ => store),
|
||||||
|
async channel =>
|
||||||
|
{
|
||||||
|
PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken);
|
||||||
|
PushSourceReply second = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken);
|
||||||
|
|
||||||
|
Assert.True(first.Accepted);
|
||||||
|
Assert.True(second.Accepted);
|
||||||
|
Assert.True(second.Duplicate);
|
||||||
|
Assert.Single(store.Queue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PushSource без текста не принимается и очередь не растёт
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown()
|
||||||
|
{
|
||||||
|
var store = new FakePipelineStore();
|
||||||
|
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
||||||
|
|
||||||
|
await RunAsync(
|
||||||
|
registry,
|
||||||
|
services => services.AddScoped<IPipelineStore>(_ => store),
|
||||||
|
async channel =>
|
||||||
|
{
|
||||||
|
PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken);
|
||||||
|
|
||||||
|
Assert.False(reply.Accepted);
|
||||||
|
Assert.False(reply.Duplicate);
|
||||||
|
Assert.Empty(store.Queue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PushSource для несуществующего тенанта не падает
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError()
|
||||||
|
{
|
||||||
|
var store = new FakePipelineStore();
|
||||||
|
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
||||||
|
|
||||||
|
await RunAsync(
|
||||||
|
registry,
|
||||||
|
services => services.AddScoped<IPipelineStore>(_ => store),
|
||||||
|
async channel =>
|
||||||
|
{
|
||||||
|
PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken);
|
||||||
|
|
||||||
|
Assert.False(reply.Accepted);
|
||||||
|
Assert.False(reply.Duplicate);
|
||||||
|
Assert.Empty(store.Queue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Интерцептор service-token и metadata tenant-id ─────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запрос без metadata «service-token» → UNAUTHENTICATED.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PushSource_WithoutToken_IsUnauthenticated()
|
||||||
|
{
|
||||||
|
var store = new FakePipelineStore();
|
||||||
|
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
||||||
|
|
||||||
|
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
|
||||||
|
{
|
||||||
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
() => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null));
|
||||||
|
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запрос с неверным токеном → UNAUTHENTICATED.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PushSource_WithWrongToken_IsUnauthenticated()
|
||||||
|
{
|
||||||
|
var store = new FakePipelineStore();
|
||||||
|
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
||||||
|
|
||||||
|
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
|
||||||
|
{
|
||||||
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
() => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token"));
|
||||||
|
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fail-closed: DEAL_SERVICE_TOKEN не задан — RPC ингресса отклоняется даже с «каким-то» токеном.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PushSource_UnsetEnvToken_FailsClosed()
|
||||||
|
{
|
||||||
|
var store = new FakePipelineStore();
|
||||||
|
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
||||||
|
|
||||||
|
await TelegramIngressTestHost.RunAsync(
|
||||||
|
serviceToken: null,
|
||||||
|
configureServices: services =>
|
||||||
|
{
|
||||||
|
services.AddSingleton<ITenantRepository>(registry);
|
||||||
|
services.AddScoped<IPipelineStore>(_ => store);
|
||||||
|
},
|
||||||
|
scenario: async channel =>
|
||||||
|
{
|
||||||
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
() => PushAsync(channel, Request("текст", externalId: "3"), TenantA, ValidToken));
|
||||||
|
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata tenant-id отсутствует → UNAUTHENTICATED
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated()
|
||||||
|
{
|
||||||
|
var store = new FakePipelineStore();
|
||||||
|
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
||||||
|
|
||||||
|
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
|
||||||
|
{
|
||||||
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
() => PushAsync(channel, Request("текст", externalId: "4"), null, ValidToken));
|
||||||
|
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Контекст и хелперы ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Запись реестра тенанта (как строка public.tenants).
|
||||||
|
// id: Идентификатор тенанта.
|
||||||
|
private static TenantRecordDto Tenant(Guid id) =>
|
||||||
|
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
// Поднимает хост ингресса: реестр тенантов + tenant-адаптеры сценария (общий харнесс).
|
||||||
|
// registry: Реестр тенантов сценария.
|
||||||
|
// registerTenantServices: Дополнительные tenant-scoped адаптеры сценария (IPipelineStore).
|
||||||
|
// scenario: Сценарий с gRPC-каналом.
|
||||||
|
private static Task RunAsync(
|
||||||
|
FakeTenantRegistry registry,
|
||||||
|
Action<IServiceCollection> registerTenantServices,
|
||||||
|
Func<GrpcChannel, Task> scenario)
|
||||||
|
=> TelegramIngressTestHost.RunAsync(
|
||||||
|
ValidToken,
|
||||||
|
services =>
|
||||||
|
{
|
||||||
|
services.AddSingleton<ITenantRepository>(registry);
|
||||||
|
registerTenantServices(services);
|
||||||
|
},
|
||||||
|
scenario);
|
||||||
|
|
||||||
|
// Вызывает PushSource с metadata сценария (deadline 10 с — контракт README).
|
||||||
|
// channel: Канал к хосту.
|
||||||
|
// request: Запрос PushSource.
|
||||||
|
// tenantId: Id тенанта в metadata (null — без заголовка tenant-id).
|
||||||
|
// tokenHeader: Значение metadata «service-token» (null — без заголовка).
|
||||||
|
private static Task<PushSourceReply> PushAsync(
|
||||||
|
GrpcChannel channel,
|
||||||
|
PushSourceRequest request,
|
||||||
|
Guid? tenantId,
|
||||||
|
string? tokenHeader)
|
||||||
|
{
|
||||||
|
var client = new SourceIngressService.SourceIngressServiceClient(channel);
|
||||||
|
AsyncUnaryCall<PushSourceReply> call = client.PushSourceAsync(
|
||||||
|
request,
|
||||||
|
new CallOptions(TelegramIngressTestHost.CallMetadata(tokenHeader, tenantId), deadline: Deadline()));
|
||||||
|
return call.ResponseAsync;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрос PushSource сценария (вид/оригинал/подпись/hue фиксированы).
|
||||||
|
// text: Текст сообщения.
|
||||||
|
// externalId: Id записи в источнике.
|
||||||
|
private static PushSourceRequest Request(string text, string externalId)
|
||||||
|
{
|
||||||
|
var request = new PushSourceRequest
|
||||||
|
{
|
||||||
|
Source = new SourceRefProto
|
||||||
|
{
|
||||||
|
Kind = Kind,
|
||||||
|
ExternalId = externalId,
|
||||||
|
DisplayName = DisplayName,
|
||||||
|
OriginRef = OriginRef,
|
||||||
|
ReceivedAt = ReceivedAtMs,
|
||||||
|
},
|
||||||
|
Content = new SourceContentProto { Text = text },
|
||||||
|
};
|
||||||
|
request.Source.Extra["hue"] = Hue;
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline вызовов теста (контракт ингресса — 10 с).
|
||||||
|
private static DateTime Deadline()
|
||||||
|
=> DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using Deal.Api.Sources;
|
||||||
|
using Deal.Grpc.Sources;
|
||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тесты маппинга generic-контракта источника в доменную модель.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SourceProtoMapperTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Заполненный запрос переносится в источник и содержимое целиком
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ToItem_FullyPopulatedRequest_MapsSourceAndContent()
|
||||||
|
{
|
||||||
|
var request = new PushSourceRequest
|
||||||
|
{
|
||||||
|
Source = new SourceRefProto
|
||||||
|
{
|
||||||
|
Kind = "telegram",
|
||||||
|
ExternalId = "42",
|
||||||
|
DisplayName = "Канал",
|
||||||
|
OriginRef = "d_1",
|
||||||
|
Author = "@author",
|
||||||
|
ReceivedAt = 1_700_000_000_000,
|
||||||
|
},
|
||||||
|
Content = new SourceContentProto
|
||||||
|
{
|
||||||
|
Text = "Вакансия",
|
||||||
|
Html = "<b>Вакансия</b>",
|
||||||
|
Author = "Иван",
|
||||||
|
Subject = "Тема",
|
||||||
|
Links = { "https://a", "https://b" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
request.Source.Extra["hue"] = "#a33";
|
||||||
|
request.Content.Extra["lang"] = "ru";
|
||||||
|
|
||||||
|
SourceItem item = SourceProtoMapper.ToItem(request);
|
||||||
|
|
||||||
|
Assert.Equal("telegram", item.Source.Kind);
|
||||||
|
Assert.Equal("42", item.Source.ExternalId);
|
||||||
|
Assert.Equal("Канал", item.Source.DisplayName);
|
||||||
|
Assert.Equal("d_1", item.Source.OriginRef);
|
||||||
|
Assert.Equal("@author", item.Source.Author);
|
||||||
|
Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000), item.Source.ReceivedAt);
|
||||||
|
Assert.Equal("#a33", item.Source.Extra!["hue"]);
|
||||||
|
|
||||||
|
Assert.Equal("Вакансия", item.Content.Text);
|
||||||
|
Assert.Equal("<b>Вакансия</b>", item.Content.Html);
|
||||||
|
Assert.Equal("Иван", item.Content.Author);
|
||||||
|
Assert.Equal("Тема", item.Content.Subject);
|
||||||
|
Assert.Equal(["https://a", "https://b"], item.Content.Links);
|
||||||
|
Assert.Equal("ru", item.Content.Extra!["lang"]);
|
||||||
|
Assert.Empty(item.Content.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вложения и контакты переносятся со всеми необязательными полями
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ToItem_DataAndContacts_MapsAllFields()
|
||||||
|
{
|
||||||
|
var request = new PushSourceRequest
|
||||||
|
{
|
||||||
|
Source = new SourceRefProto { Kind = "file" },
|
||||||
|
Content = new SourceContentProto
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
{
|
||||||
|
new DataRefProto
|
||||||
|
{
|
||||||
|
Id = "obj_1",
|
||||||
|
Ref = "storage://obj_1",
|
||||||
|
Kind = "image",
|
||||||
|
MimeType = "image/png",
|
||||||
|
FileName = "screen.png",
|
||||||
|
Size = 2048,
|
||||||
|
Width = 800,
|
||||||
|
Height = 600,
|
||||||
|
DurationSec = 1.5,
|
||||||
|
PreviewRef = "storage://obj_1/thumb",
|
||||||
|
Caption = "Подпись",
|
||||||
|
Order = 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Contacts =
|
||||||
|
{
|
||||||
|
new ContactRefProto
|
||||||
|
{
|
||||||
|
Kind = "phone",
|
||||||
|
Name = "Иван",
|
||||||
|
Phone = "+79990000000",
|
||||||
|
Email = "ivan@example.com",
|
||||||
|
Url = "https://example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
request.Content.Data[0].Meta["source"] = "upload";
|
||||||
|
|
||||||
|
SourceItem item = SourceProtoMapper.ToItem(request);
|
||||||
|
|
||||||
|
DataRef data = Assert.Single(item.Content.Data);
|
||||||
|
Assert.Equal("obj_1", data.Id);
|
||||||
|
Assert.Equal("storage://obj_1", data.Ref);
|
||||||
|
Assert.Equal("image", data.Kind);
|
||||||
|
Assert.Equal("image/png", data.MimeType);
|
||||||
|
Assert.Equal("screen.png", data.FileName);
|
||||||
|
Assert.Equal(2048, data.Size);
|
||||||
|
Assert.Equal(800, data.Width);
|
||||||
|
Assert.Equal(600, data.Height);
|
||||||
|
Assert.Equal(1.5, data.DurationSec);
|
||||||
|
Assert.Equal("storage://obj_1/thumb", data.PreviewRef);
|
||||||
|
Assert.Equal("Подпись", data.Caption);
|
||||||
|
Assert.Equal(2, data.Order);
|
||||||
|
Assert.Equal("upload", data.Meta!["source"]);
|
||||||
|
|
||||||
|
ContactRef contact = Assert.Single(item.Content.Contacts!);
|
||||||
|
Assert.Equal("phone", contact.Kind);
|
||||||
|
Assert.Equal("Иван", contact.Name);
|
||||||
|
Assert.Equal("+79990000000", contact.Phone);
|
||||||
|
Assert.Equal("ivan@example.com", contact.Email);
|
||||||
|
Assert.Equal("https://example.com", contact.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Незаполненные необязательные поля источника/содержимого дают null
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ToItem_MissingOptionals_MapToNull()
|
||||||
|
{
|
||||||
|
var request = new PushSourceRequest
|
||||||
|
{
|
||||||
|
Source = new SourceRefProto { Kind = "local" },
|
||||||
|
Content = new SourceContentProto { Text = "x" },
|
||||||
|
};
|
||||||
|
|
||||||
|
SourceItem item = SourceProtoMapper.ToItem(request);
|
||||||
|
|
||||||
|
Assert.Null(item.Source.ExternalId);
|
||||||
|
Assert.Null(item.Source.DisplayName);
|
||||||
|
Assert.Null(item.Source.OriginRef);
|
||||||
|
Assert.Null(item.Source.Author);
|
||||||
|
Assert.Null(item.Source.Extra);
|
||||||
|
Assert.Null(item.Content.Html);
|
||||||
|
Assert.Null(item.Content.Author);
|
||||||
|
Assert.Null(item.Content.Subject);
|
||||||
|
Assert.Null(item.Content.Links);
|
||||||
|
Assert.Null(item.Content.Contacts);
|
||||||
|
Assert.Null(item.Content.Extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Незаполненные необязательные поля вложения/контакта дают null
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ToItem_DataAndContactsWithoutOptionals_MapToNull()
|
||||||
|
{
|
||||||
|
var request = new PushSourceRequest
|
||||||
|
{
|
||||||
|
Source = new SourceRefProto { Kind = "file" },
|
||||||
|
Content = new SourceContentProto
|
||||||
|
{
|
||||||
|
Data = { new DataRefProto { Id = "obj_1", Ref = "storage://obj_1" } },
|
||||||
|
Contacts = { new ContactRefProto() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
SourceItem item = SourceProtoMapper.ToItem(request);
|
||||||
|
|
||||||
|
DataRef data = Assert.Single(item.Content.Data);
|
||||||
|
Assert.Null(data.Kind);
|
||||||
|
Assert.Null(data.MimeType);
|
||||||
|
Assert.Null(data.FileName);
|
||||||
|
Assert.Null(data.Width);
|
||||||
|
Assert.Null(data.Height);
|
||||||
|
Assert.Null(data.DurationSec);
|
||||||
|
Assert.Null(data.PreviewRef);
|
||||||
|
Assert.Null(data.Caption);
|
||||||
|
Assert.Null(data.Order);
|
||||||
|
Assert.Null(data.Meta);
|
||||||
|
|
||||||
|
ContactRef contact = Assert.Single(item.Content.Contacts!);
|
||||||
|
Assert.Null(contact.Kind);
|
||||||
|
Assert.Null(contact.Name);
|
||||||
|
Assert.Null(contact.Phone);
|
||||||
|
Assert.Null(contact.Email);
|
||||||
|
Assert.Null(contact.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// received_at=0 подменяется текущим временем получения
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ToItem_ZeroReceivedAt_UsesCurrentTime()
|
||||||
|
{
|
||||||
|
DateTimeOffset before = DateTimeOffset.UtcNow;
|
||||||
|
var request = new PushSourceRequest
|
||||||
|
{
|
||||||
|
Source = new SourceRefProto { Kind = "local" },
|
||||||
|
Content = new SourceContentProto { Text = "x" },
|
||||||
|
};
|
||||||
|
|
||||||
|
SourceItem item = SourceProtoMapper.ToItem(request);
|
||||||
|
|
||||||
|
Assert.InRange(item.Source.ReceivedAt, before.AddSeconds(-1), DateTimeOffset.UtcNow.AddSeconds(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Deal.Api.Events;
|
using Deal.Api.Events;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Telegram;
|
||||||
using Deal.Modules.Cards.Application.Sources;
|
|
||||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
|
||||||
using Deal.Modules.Pipeline.Application.Models;
|
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Modules.Telegram.Application;
|
using Deal.Modules.Telegram.Application;
|
||||||
using Deal.Modules.Tenants.Application.Abstractions;
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Modules.Tenants;
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
@@ -20,7 +16,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
namespace Deal.Tests.Unit.Support;
|
namespace Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-proc gRPC-тесты входящего потока telegram-service → ядро.
|
/// In-proc gRPC-тесты служебного потока telegram-service → ядро (SyncDialogs/ReportStatus).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TelegramIngressServiceTests
|
public sealed class TelegramIngressServiceTests
|
||||||
{
|
{
|
||||||
@@ -30,164 +26,6 @@ public sealed class TelegramIngressServiceTests
|
|||||||
// Тенант A сценариев (в реестре).
|
// Тенант A сценариев (в реестре).
|
||||||
private static readonly Guid TenantA = Guid.NewGuid();
|
private static readonly Guid TenantA = Guid.NewGuid();
|
||||||
|
|
||||||
// Id диалога сценариев PushMessage.
|
|
||||||
private const string DialogId = "d_channel_100";
|
|
||||||
|
|
||||||
// ─── PushMessage: приём, дубль, несуществующий тенант ──────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PushMessage кладёт строку очереди тенанта
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task PushMessage_ValidTenant_EnqueuesQueueRowAndAccepts()
|
|
||||||
{
|
|
||||||
var store = new FakePipelineStore();
|
|
||||||
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
|
||||||
|
|
||||||
await RunAsync(
|
|
||||||
registry,
|
|
||||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
|
||||||
async channel =>
|
|
||||||
{
|
|
||||||
PushMessageReply reply = await PushAsync(channel, Request("Вакансия Python-разработчика", msgId: 7), TenantA, ValidToken);
|
|
||||||
|
|
||||||
Assert.True(reply.Accepted);
|
|
||||||
Assert.False(reply.Duplicate);
|
|
||||||
|
|
||||||
QueueItemDto row = Assert.Single(store.Queue);
|
|
||||||
Assert.Equal("telegram", row.Source.Kind);
|
|
||||||
Assert.Equal(DialogId, row.Source.OriginRef);
|
|
||||||
Assert.Equal("7", row.Source.ExternalId);
|
|
||||||
Assert.Equal("Вакансия Python-разработчика", row.Text);
|
|
||||||
Assert.Equal("Канал", row.Source.DisplayName);
|
|
||||||
Assert.Equal("#a33", row.Source.ResolveHue());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Повтор PushMessage того же dialogId+msgId — duplicate=true, очередь не растёт
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task PushMessage_SameDialogAndMsgIdTwice_SecondIsDuplicateAndQueueNotGrown()
|
|
||||||
{
|
|
||||||
var store = new FakePipelineStore();
|
|
||||||
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
|
||||||
|
|
||||||
await RunAsync(
|
|
||||||
registry,
|
|
||||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
|
||||||
async channel =>
|
|
||||||
{
|
|
||||||
PushMessageReply first = await PushAsync(channel, Request("Сообщение канала", msgId: 42), TenantA, ValidToken);
|
|
||||||
PushMessageReply second = await PushAsync(channel, Request("Сообщение канала", msgId: 42), TenantA, ValidToken);
|
|
||||||
|
|
||||||
Assert.True(first.Accepted);
|
|
||||||
Assert.True(second.Accepted);
|
|
||||||
Assert.True(second.Duplicate);
|
|
||||||
Assert.Single(store.Queue);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PushMessage для несуществующего тенанта не падает
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task PushMessage_UnknownTenant_NotAcceptedWithoutRpcError()
|
|
||||||
{
|
|
||||||
var store = new FakePipelineStore();
|
|
||||||
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
|
||||||
|
|
||||||
await RunAsync(
|
|
||||||
registry,
|
|
||||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
|
||||||
async channel =>
|
|
||||||
{
|
|
||||||
PushMessageReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта"), Guid.NewGuid(), ValidToken);
|
|
||||||
|
|
||||||
Assert.False(reply.Accepted);
|
|
||||||
Assert.False(reply.Duplicate);
|
|
||||||
Assert.Empty(store.Queue);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Интерцептор service-token и metadata tenant-id ─────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Запрос без metadata «service-token» → UNAUTHENTICATED.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task PushMessage_WithoutToken_IsUnauthenticated()
|
|
||||||
{
|
|
||||||
var store = new FakePipelineStore();
|
|
||||||
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
|
||||||
|
|
||||||
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
|
|
||||||
{
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
||||||
() => PushAsync(channel, Request("текст"), TenantA, null));
|
|
||||||
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Запрос с неверным токеном → UNAUTHENTICATED.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task PushMessage_WithWrongToken_IsUnauthenticated()
|
|
||||||
{
|
|
||||||
var store = new FakePipelineStore();
|
|
||||||
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
|
||||||
|
|
||||||
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
|
|
||||||
{
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
||||||
() => PushAsync(channel, Request("текст"), TenantA, "wrong-token"));
|
|
||||||
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fail-closed: DEAL_SERVICE_TOKEN не задан — RPC ингресса отклоняется даже с «каким-то» токеном.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task PushMessage_UnsetEnvToken_FailsClosed()
|
|
||||||
{
|
|
||||||
var store = new FakePipelineStore();
|
|
||||||
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
|
||||||
|
|
||||||
await TelegramIngressTestHost.RunAsync(
|
|
||||||
serviceToken: null,
|
|
||||||
configureServices: services =>
|
|
||||||
{
|
|
||||||
services.AddSingleton<ITenantRepository>(registry);
|
|
||||||
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
|
|
||||||
services.AddScoped<IPipelineStore>(_ => store);
|
|
||||||
},
|
|
||||||
scenario: async channel =>
|
|
||||||
{
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
||||||
() => PushAsync(channel, Request("текст"), TenantA, ValidToken));
|
|
||||||
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Metadata tenant-id отсутствует → UNAUTHENTICATED
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task PushMessage_WithoutTenantIdMetadata_IsUnauthenticated()
|
|
||||||
{
|
|
||||||
var store = new FakePipelineStore();
|
|
||||||
var registry = new FakeTenantRegistry(Tenant(TenantA));
|
|
||||||
|
|
||||||
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
|
|
||||||
{
|
|
||||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
||||||
() => PushAsync(channel, Request("текст"), null, ValidToken));
|
|
||||||
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── SyncDialogs и ReportStatus ─────────────────────────────────────────
|
// ─── SyncDialogs и ReportStatus ─────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -337,7 +175,7 @@ public sealed class TelegramIngressServiceTests
|
|||||||
// Поднимает хост ингресса: реестр тенантов + tenant-адаптеры сценария (общий харнесс).
|
// Поднимает хост ингресса: реестр тенантов + tenant-адаптеры сценария (общий харнесс).
|
||||||
// registry: Реестр тенантов сценария.
|
// registry: Реестр тенантов сценария.
|
||||||
// registerTenantServices: Дополнительные tenant-scoped адаптеры сценария
|
// registerTenantServices: Дополнительные tenant-scoped адаптеры сценария
|
||||||
// (IPipelineStore/ISettingsStore/брокер).
|
// (ISettingsStore/ITelegramStore/брокер).
|
||||||
// scenario: Сценарий с gRPC-каналом.
|
// scenario: Сценарий с gRPC-каналом.
|
||||||
private static Task RunAsync(
|
private static Task RunAsync(
|
||||||
FakeTenantRegistry registry,
|
FakeTenantRegistry registry,
|
||||||
@@ -352,24 +190,6 @@ public sealed class TelegramIngressServiceTests
|
|||||||
},
|
},
|
||||||
scenario);
|
scenario);
|
||||||
|
|
||||||
// Вызывает PushMessage с metadata сценария (deadline 10 с — контракт README).
|
|
||||||
// channel: Канал к хосту.
|
|
||||||
// request: Запрос PushMessage.
|
|
||||||
// tenantId: Id тенанта в metadata (null — без заголовка tenant-id).
|
|
||||||
// tokenHeader: Значение metadata «service-token» (null — без заголовка).
|
|
||||||
private static Task<PushMessageReply> PushAsync(
|
|
||||||
GrpcChannel channel,
|
|
||||||
PushMessageRequest request,
|
|
||||||
Guid? tenantId,
|
|
||||||
string? tokenHeader)
|
|
||||||
{
|
|
||||||
var client = new IngressService.IngressServiceClient(channel);
|
|
||||||
AsyncUnaryCall<PushMessageReply> call = client.PushMessageAsync(
|
|
||||||
request,
|
|
||||||
new CallOptions(TelegramIngressTestHost.CallMetadata(tokenHeader, tenantId), deadline: Deadline()));
|
|
||||||
return call.ResponseAsync;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вызывает SyncDialogs с metadata сценария.
|
// Вызывает SyncDialogs с metadata сценария.
|
||||||
// channel: Канал к хосту.
|
// channel: Канал к хосту.
|
||||||
// request: Запрос SyncDialogs.
|
// request: Запрос SyncDialogs.
|
||||||
@@ -412,27 +232,6 @@ public sealed class TelegramIngressServiceTests
|
|||||||
return call.ResponseAsync;
|
return call.ResponseAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запрос PushMessage сценария (канальные поля фиксированы).
|
|
||||||
// text: Текст сообщения.
|
|
||||||
// msgId: Id сообщения в Telegram (null — без msg_id).
|
|
||||||
private static PushMessageRequest Request(string text, long? msgId = null)
|
|
||||||
{
|
|
||||||
var request = new PushMessageRequest
|
|
||||||
{
|
|
||||||
DialogId = DialogId,
|
|
||||||
ChannelName = "Канал",
|
|
||||||
ChannelHandle = "kanal_handle",
|
|
||||||
ChannelHue = "#a33",
|
|
||||||
Text = text,
|
|
||||||
};
|
|
||||||
if (msgId is not null)
|
|
||||||
{
|
|
||||||
request.MsgId = msgId.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deadline вызовов теста (контракт ингресса — 10 с).
|
// Deadline вызовов теста (контракт ингресса — 10 с).
|
||||||
private static DateTime Deadline()
|
private static DateTime Deadline()
|
||||||
=> DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds);
|
=> DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using Deal.Api.Configuration;
|
using Deal.Api.Configuration;
|
||||||
using Deal.Api.Events;
|
using Deal.Api.Events;
|
||||||
|
using Deal.Api.Sources;
|
||||||
using Deal.Api.Telegram;
|
using Deal.Api.Telegram;
|
||||||
using Deal.Contracts.Integrations.Abstractions;
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
using Deal.Infrastructure.Data;
|
using Deal.Infrastructure.Data;
|
||||||
@@ -75,6 +76,8 @@ internal static class TelegramIngressTestHost
|
|||||||
builder.Services.AddSingleton<SseBroker>();
|
builder.Services.AddSingleton<SseBroker>();
|
||||||
builder.Services.AddScoped<PipelineIngestService>();
|
builder.Services.AddScoped<PipelineIngestService>();
|
||||||
builder.Services.AddScoped<TelegramIngressService>();
|
builder.Services.AddScoped<TelegramIngressService>();
|
||||||
|
builder.Services.AddScoped<IngressTenantResolver>();
|
||||||
|
builder.Services.AddScoped<SourceIngressGrpcService>();
|
||||||
builder.Services.AddGrpc(grpc =>
|
builder.Services.AddGrpc(grpc =>
|
||||||
{
|
{
|
||||||
grpc.Interceptors.Add<IngressServiceTokenInterceptor>();
|
grpc.Interceptors.Add<IngressServiceTokenInterceptor>();
|
||||||
@@ -105,6 +108,7 @@ internal static class TelegramIngressTestHost
|
|||||||
|
|
||||||
app = builder.Build();
|
app = builder.Build();
|
||||||
app.MapGrpcService<TelegramIngressService>();
|
app.MapGrpcService<TelegramIngressService>();
|
||||||
|
app.MapGrpcService<SourceIngressGrpcService>();
|
||||||
if (rateLimitOptions is { Enabled: true })
|
if (rateLimitOptions is { Enabled: true })
|
||||||
{
|
{
|
||||||
app.MapGrpcHealthChecksService();
|
app.MapGrpcHealthChecksService();
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
|
using Deal.Modules.Telegram.Application;
|
||||||
|
using Deal.Tests.Unit.Contracts;
|
||||||
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тесты наблюдателя, сохраняющего превью принятых сообщений.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TelegramSourceIngestObserverTests
|
||||||
|
{
|
||||||
|
// Id диалога-оригинала сценариев.
|
||||||
|
private const string DialogId = "d_1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Принятая запись известного вида сохраняет превью сообщения
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task OnIngestedAsync_KnownKind_SavesPreview()
|
||||||
|
{
|
||||||
|
(TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create();
|
||||||
|
store.Seed(Dialog());
|
||||||
|
DateTimeOffset receivedAt = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||||
|
|
||||||
|
await observer.OnIngestedAsync(Item("telegram", DialogId, "7", "Нужен Python", receivedAt), CancellationToken.None);
|
||||||
|
|
||||||
|
FakeTelegramMessageRow preview = Assert.Single(store.Messages);
|
||||||
|
Assert.Equal("m_d_1_7", preview.Id);
|
||||||
|
Assert.Equal("Нужен Python", preview.Text);
|
||||||
|
Assert.Equal(receivedAt, preview.MsgAt);
|
||||||
|
Assert.Equal("Нужен Python", store.Dialogs[0].LastText);
|
||||||
|
Assert.Equal(receivedAt, store.Dialogs[0].LastAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чужой вид источника не трогает хранилище
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task OnIngestedAsync_ForeignKind_DoesNotTouchStore()
|
||||||
|
{
|
||||||
|
(TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create();
|
||||||
|
store.Seed(Dialog());
|
||||||
|
|
||||||
|
await observer.OnIngestedAsync(Item("avito", DialogId, "7", "Текст", DateTimeOffset.UtcNow), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(store.Messages);
|
||||||
|
Assert.Equal(string.Empty, store.Dialogs[0].LastText);
|
||||||
|
Assert.Null(store.Dialogs[0].LastAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запись без оригинала не сохраняет превью
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task OnIngestedAsync_NoOriginRef_DoesNotTouchStore()
|
||||||
|
{
|
||||||
|
(TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create();
|
||||||
|
store.Seed(Dialog());
|
||||||
|
|
||||||
|
await observer.OnIngestedAsync(Item("telegram", null, "7", "Текст", DateTimeOffset.UtcNow), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(store.Messages);
|
||||||
|
Assert.Equal(string.Empty, store.Dialogs[0].LastText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собирает наблюдателя на фейковом хранилище диалогов.
|
||||||
|
private static (TelegramSourceIngestObserver Observer, FakeTelegramStore Store) Create()
|
||||||
|
{
|
||||||
|
var store = new FakeTelegramStore();
|
||||||
|
var dialogs = new DialogsService(
|
||||||
|
store,
|
||||||
|
new FakeSettingsStore(),
|
||||||
|
new FakeTelegramGateway(),
|
||||||
|
NullLogger<DialogsService>.Instance);
|
||||||
|
var observer = new TelegramSourceIngestObserver(dialogs, NullLogger<TelegramSourceIngestObserver>.Instance);
|
||||||
|
return (observer, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка каталога для сценариев наблюдателя.
|
||||||
|
private static FakeTelegramDialogRow Dialog() => new(
|
||||||
|
DialogId,
|
||||||
|
Name: "Канал",
|
||||||
|
Handle: "kanal_handle",
|
||||||
|
Kind: "channel",
|
||||||
|
Hue: "#a33",
|
||||||
|
Monitor: true,
|
||||||
|
LastText: string.Empty,
|
||||||
|
LastAt: null,
|
||||||
|
Backfilled: false,
|
||||||
|
UpdatedAt: DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
// Запись источника сценариев.
|
||||||
|
// kind: Вид источника.
|
||||||
|
// originRef: Оригинал (диалог); null — без оригинала.
|
||||||
|
// externalId: Id сообщения.
|
||||||
|
private static SourceItem Item(string kind, string? originRef, string externalId, string text, DateTimeOffset receivedAt) => new()
|
||||||
|
{
|
||||||
|
Source = new SourceRef
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
OriginRef = originRef,
|
||||||
|
ExternalId = externalId,
|
||||||
|
ReceivedAt = receivedAt,
|
||||||
|
},
|
||||||
|
Content = new SourceContent { Text = text },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Telegram;
|
||||||
using Deal.Telegram.Core;
|
using Deal.Telegram.Core;
|
||||||
using Deal.Telegram.Sessions;
|
using Deal.Telegram.Sessions;
|
||||||
@@ -16,31 +17,35 @@ public sealed class CoreIngressClientTests
|
|||||||
private const string ServiceToken = "deal-test-token";
|
private const string ServiceToken = "deal-test-token";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PushMessage: тело уходит, metadata tenant-id/service-token на месте; ответ accepted.
|
/// PushSource: тело уходит, metadata tenant-id/service-token на месте; ответ accepted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushMessage_SendsMessageWithMetadata_AndReturnsReply()
|
public async Task PushSource_SendsSourceWithMetadata_AndReturnsReply()
|
||||||
{
|
{
|
||||||
(string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync();
|
(string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
CoreIngressClient client = CreateClient(endpoint);
|
CoreIngressClient client = CreateClient(endpoint);
|
||||||
|
|
||||||
PushMessageReply reply = await client.PushMessageAsync(
|
PushSourceReply reply = await client.PushSourceAsync(
|
||||||
TenantId,
|
TenantId,
|
||||||
new PushMessageRequest
|
new PushSourceRequest
|
||||||
{
|
{
|
||||||
DialogId = "-1001234567890",
|
Source = new SourceRefProto
|
||||||
ChannelName = "IT Канал",
|
{
|
||||||
Text = "сообщение",
|
Kind = "telegram",
|
||||||
MsgId = 42,
|
ExternalId = "42",
|
||||||
|
OriginRef = "-1001234567890",
|
||||||
|
DisplayName = "IT Канал",
|
||||||
|
},
|
||||||
|
Content = new SourceContentProto { Text = "сообщение" },
|
||||||
},
|
},
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(reply.Accepted);
|
Assert.True(reply.Accepted);
|
||||||
PushMessageRequest sent = Assert.Single(server.Pushes);
|
PushSourceRequest sent = Assert.Single(server.Pushes);
|
||||||
Assert.Equal("-1001234567890", sent.DialogId);
|
Assert.Equal("-1001234567890", sent.Source.OriginRef);
|
||||||
Assert.Equal("сообщение", sent.Text);
|
Assert.Equal("сообщение", sent.Content.Text);
|
||||||
Assert.Equal(TenantId, Assert.Single(server.Tenants));
|
Assert.Equal(TenantId, Assert.Single(server.Tenants));
|
||||||
Assert.Equal(ServiceToken, Assert.Single(server.Tokens));
|
Assert.Equal(ServiceToken, Assert.Single(server.Tokens));
|
||||||
}
|
}
|
||||||
@@ -83,13 +88,20 @@ public sealed class CoreIngressClientTests
|
|||||||
/// Ядро недоступно → SessionException UNAVAILABLE «Ядро недоступно…»
|
/// Ядро недоступно → SessionException UNAVAILABLE «Ядро недоступно…»
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushMessage_UnreachableCore_ThrowsSessionException()
|
public async Task PushSource_UnreachableCore_ThrowsSessionException()
|
||||||
{
|
{
|
||||||
// Порт без сервера: соединение отклоняется — вызов падает до deadline (RpcTimeout 15 с).
|
// Порт без сервера: соединение отклоняется — вызов падает до deadline (RpcTimeout 15 с).
|
||||||
CoreIngressClient client = CreateClient("http://127.0.0.1:1");
|
CoreIngressClient client = CreateClient("http://127.0.0.1:1");
|
||||||
|
|
||||||
SessionException exception = await Assert.ThrowsAsync<SessionException>(
|
SessionException exception = await Assert.ThrowsAsync<SessionException>(
|
||||||
() => client.PushMessageAsync(TenantId, new PushMessageRequest { DialogId = "-1001", Text = "x" }, CancellationToken.None));
|
() => client.PushSourceAsync(
|
||||||
|
TenantId,
|
||||||
|
new PushSourceRequest
|
||||||
|
{
|
||||||
|
Source = new SourceRefProto { Kind = "telegram", OriginRef = "-1001" },
|
||||||
|
Content = new SourceContentProto { Text = "x" },
|
||||||
|
},
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
Assert.Equal(StatusCode.Unavailable, exception.Code);
|
Assert.Equal(StatusCode.Unavailable, exception.Code);
|
||||||
Assert.Equal(SessionErrorMessages.IngressUnavailable, exception.Message);
|
Assert.Equal(SessionErrorMessages.IngressUnavailable, exception.Message);
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ public sealed class DialogRpcTests
|
|||||||
|
|
||||||
Assert.Equal(2, reply.Processed);
|
Assert.Equal(2, reply.Processed);
|
||||||
Assert.Equal(2, ingressServer.Pushes.Count);
|
Assert.Equal(2, ingressServer.Pushes.Count);
|
||||||
Assert.Equal([9, 10], ingressServer.Pushes.Select(push => push.MsgId));
|
Assert.Equal(["9", "10"], ingressServer.Pushes.Select(push => push.Source.ExternalId));
|
||||||
Assert.All(ingressServer.Tenants, tenant => Assert.Equal(TenantId, tenant));
|
Assert.All(ingressServer.Tenants, tenant => Assert.Equal(TenantId, tenant));
|
||||||
Assert.Equal([DialogA], factory.CreatedClients.Single().MarkedReadDialogs);
|
Assert.Equal([DialogA], factory.CreatedClients.Single().MarkedReadDialogs);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Telegram;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
@@ -9,12 +10,14 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Deal.Telegram.Tests.Grpc;
|
namespace Deal.Telegram.Tests.Grpc;
|
||||||
|
|
||||||
internal sealed class RecordingIngressService : IngressService.IngressServiceBase
|
// Общее состояние записанных вызовов: PushSource (SourceIngressService) и SyncDialogs (IngressService)
|
||||||
|
// хостятся на одном фейк-сервере, но лежат в разных protobuf-сервисах.
|
||||||
|
internal sealed class IngressRecorder
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Полученные PushMessage, в порядке вызовов.
|
/// Полученные PushSource, в порядке вызовов.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<PushMessageRequest> Pushes { get; } = [];
|
public List<PushSourceRequest> Pushes { get; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Полученные SyncDialogs, в порядке вызовов.
|
/// Полученные SyncDialogs, в порядке вызовов.
|
||||||
@@ -36,33 +39,87 @@ internal sealed class RecordingIngressService : IngressService.IngressServiceBas
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> MonitoredIds { get; set; } = [];
|
public List<string> MonitoredIds { get; set; } = [];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public override Task<PushMessageReply> PushMessage(PushMessageRequest request, ServerCallContext context)
|
/// Записывает metadata вызова
|
||||||
{
|
/// </summary>
|
||||||
Record(context);
|
/// <param name="context">Контекст серверного вызова.</param>
|
||||||
Pushes.Add(request);
|
public void Record(ServerCallContext context)
|
||||||
return Task.FromResult(new PushMessageReply { Accepted = true, Duplicate = false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override Task<SyncDialogsReply> SyncDialogs(SyncDialogsRequest request, ServerCallContext context)
|
|
||||||
{
|
|
||||||
Record(context);
|
|
||||||
Syncs.Add(request);
|
|
||||||
var reply = new SyncDialogsReply();
|
|
||||||
reply.MonitoredIds.AddRange(MonitoredIds);
|
|
||||||
return Task.FromResult(reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Record(ServerCallContext context)
|
|
||||||
{
|
{
|
||||||
Tenants.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty);
|
Tenants.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty);
|
||||||
Tokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty);
|
Tokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Поднимает in-proc gRPC-сервер IngressService на эфемерном порту (без service-token-интерцептора:
|
internal sealed class RecordingIngressService : SourceIngressService.SourceIngressServiceBase
|
||||||
// тест проверяет, что клиент шлёт токен, а не что сервер его принимает).
|
{
|
||||||
|
private readonly IngressRecorder _recorder;
|
||||||
|
|
||||||
|
public RecordingIngressService(IngressRecorder recorder)
|
||||||
|
{
|
||||||
|
_recorder = recorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Полученные PushSource, в порядке вызовов.
|
||||||
|
/// </summary>
|
||||||
|
public List<PushSourceRequest> Pushes => _recorder.Pushes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Полученные SyncDialogs, в порядке вызовов.
|
||||||
|
/// </summary>
|
||||||
|
public List<SyncDialogsRequest> Syncs => _recorder.Syncs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Значения tenant-id полученных вызовов
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Tenants => _recorder.Tenants;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Значения service-token полученных вызовов
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Tokens => _recorder.Tokens;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Monitored-набор ответа SyncDialogs
|
||||||
|
/// </summary>
|
||||||
|
public List<string> MonitoredIds
|
||||||
|
{
|
||||||
|
get => _recorder.MonitoredIds;
|
||||||
|
set => _recorder.MonitoredIds = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override Task<PushSourceReply> PushSource(PushSourceRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
_recorder.Record(context);
|
||||||
|
_recorder.Pushes.Add(request);
|
||||||
|
return Task.FromResult(new PushSourceReply { Accepted = true, Duplicate = false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RecordingSyncService : IngressService.IngressServiceBase
|
||||||
|
{
|
||||||
|
private readonly IngressRecorder _recorder;
|
||||||
|
|
||||||
|
public RecordingSyncService(IngressRecorder recorder)
|
||||||
|
{
|
||||||
|
_recorder = recorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override Task<SyncDialogsReply> SyncDialogs(SyncDialogsRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
_recorder.Record(context);
|
||||||
|
_recorder.Syncs.Add(request);
|
||||||
|
var reply = new SyncDialogsReply();
|
||||||
|
reply.MonitoredIds.AddRange(_recorder.MonitoredIds);
|
||||||
|
return Task.FromResult(reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поднимает in-proc gRPC-сервер исходящих контрактов (SourceIngressService/IngressService) на
|
||||||
|
// эфемерном порту (без service-token-интерцептора: тест проверяет, что клиент шлёт токен,
|
||||||
|
// а не что сервер его принимает).
|
||||||
internal static class FakeIngressServer
|
internal static class FakeIngressServer
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -70,17 +127,21 @@ internal static class FakeIngressServer
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<(string Endpoint, RecordingIngressService Server, WebApplication App)> StartAsync()
|
public static async Task<(string Endpoint, RecordingIngressService Server, WebApplication App)> StartAsync()
|
||||||
{
|
{
|
||||||
var server = new RecordingIngressService();
|
var recorder = new IngressRecorder();
|
||||||
|
var server = new RecordingIngressService(recorder);
|
||||||
|
var syncServer = new RecordingSyncService(recorder);
|
||||||
int port = FreeTcpPort();
|
int port = FreeTcpPort();
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
builder.WebHost.ConfigureKestrel(kestrel =>
|
builder.WebHost.ConfigureKestrel(kestrel =>
|
||||||
kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2));
|
kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2));
|
||||||
builder.Services.AddSingleton(server);
|
builder.Services.AddSingleton(server);
|
||||||
|
builder.Services.AddSingleton(syncServer);
|
||||||
builder.Services.AddGrpc();
|
builder.Services.AddGrpc();
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
app.MapGrpcService<RecordingIngressService>();
|
app.MapGrpcService<RecordingIngressService>();
|
||||||
|
app.MapGrpcService<RecordingSyncService>();
|
||||||
await app.StartAsync();
|
await app.StartAsync();
|
||||||
|
|
||||||
return ($"http://127.0.0.1:{port}", server, app);
|
return ($"http://127.0.0.1:{port}", server, app);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Telegram.Dialogs;
|
using Deal.Telegram.Dialogs;
|
||||||
using Deal.Telegram.Sessions;
|
using Deal.Telegram.Sessions;
|
||||||
using Deal.Telegram.Telegram;
|
using Deal.Telegram.Telegram;
|
||||||
@@ -35,9 +35,9 @@ public sealed class RealtimeListenerTests
|
|||||||
await harness.Client.RaiseMessageAsync(
|
await harness.Client.RaiseMessageAsync(
|
||||||
new TelegramMessage(DialogId, 101, "свежее сообщение", 1_700_000_000_101, "IT Канал", "it_channel"));
|
new TelegramMessage(DialogId, 101, "свежее сообщение", 1_700_000_000_101, "IT Канал", "it_channel"));
|
||||||
|
|
||||||
PushMessageRequest push = Assert.Single(ingress.Pushes).Message;
|
PushSourceRequest push = Assert.Single(ingress.Pushes).Request;
|
||||||
Assert.Equal(DialogId, push.DialogId);
|
Assert.Equal(DialogId, push.Source.OriginRef);
|
||||||
Assert.Equal("свежее сообщение", push.Text);
|
Assert.Equal("свежее сообщение", push.Content.Text);
|
||||||
Assert.Equal(DialogId, Assert.Single(harness.Client.MarkedReadDialogs));
|
Assert.Equal(DialogId, Assert.Single(harness.Client.MarkedReadDialogs));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -73,7 +73,7 @@ public sealed class RealtimeListenerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сбой PushMessage не роняет realtime
|
/// Сбой PushSource не роняет realtime
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Listener_PushFails_NoMarkRead()
|
public async Task Listener_PushFails_NoMarkRead()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Telegram;
|
||||||
using Deal.Telegram.Core;
|
using Deal.Telegram.Core;
|
||||||
using Deal.Telegram.Dialogs;
|
using Deal.Telegram.Dialogs;
|
||||||
@@ -7,9 +8,9 @@ namespace Deal.Telegram.Tests.Grpc;
|
|||||||
internal sealed class FakeIngress : ICoreIngressClient
|
internal sealed class FakeIngress : ICoreIngressClient
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Отправленные PushMessage
|
/// Отправленные PushSource
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<(string TenantId, PushMessageRequest Message)> Pushes { get; } = [];
|
public List<(string TenantId, PushSourceRequest Request)> Pushes { get; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Синхронизации каталога
|
/// Синхронизации каталога
|
||||||
@@ -22,7 +23,7 @@ internal sealed class FakeIngress : ICoreIngressClient
|
|||||||
public List<string> MonitoredIdsToReturn { get; set; } = [];
|
public List<string> MonitoredIdsToReturn { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ошибка PushMessageAsync
|
/// Ошибка PushSourceAsync
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Exception? PushError { get; set; }
|
public Exception? PushError { get; set; }
|
||||||
|
|
||||||
@@ -32,19 +33,19 @@ internal sealed class FakeIngress : ICoreIngressClient
|
|||||||
public Exception? SyncError { get; set; }
|
public Exception? SyncError { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Текст последнего PushMessage
|
/// Текст последнего PushSource
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? LastPushText => Pushes.Count == 0 ? null : Pushes[^1].Message.Text;
|
public string? LastPushText => Pushes.Count == 0 ? null : Pushes[^1].Request.Content.Text;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Id последнего отправленного диалога.
|
/// Id последнего отправленного диалога.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? LastPushDialogId => Pushes.Count == 0 ? null : Pushes[^1].Message.DialogId;
|
public string? LastPushDialogId => Pushes.Count == 0 ? null : Pushes[^1].Request.Source.OriginRef;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<PushMessageReply> PushMessageAsync(
|
public Task<PushSourceReply> PushSourceAsync(
|
||||||
string tenantId,
|
string tenantId,
|
||||||
PushMessageRequest message,
|
PushSourceRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (PushError is not null)
|
if (PushError is not null)
|
||||||
@@ -52,8 +53,8 @@ internal sealed class FakeIngress : ICoreIngressClient
|
|||||||
throw PushError;
|
throw PushError;
|
||||||
}
|
}
|
||||||
|
|
||||||
Pushes.Add((tenantId, message));
|
Pushes.Add((tenantId, request));
|
||||||
return Task.FromResult(new PushMessageReply { Accepted = true, Duplicate = false });
|
return Task.FromResult(new PushSourceReply { Accepted = true, Duplicate = false });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -35,13 +35,12 @@ public sealed class BackfillServiceTests
|
|||||||
|
|
||||||
Assert.Equal(3, processed);
|
Assert.Equal(3, processed);
|
||||||
// От старых к новым (в fake список хранится от новых к старым, как get_messages).
|
// От старых к новым (в fake список хранится от новых к старым, как get_messages).
|
||||||
Assert.Equal([28, 29, 30], ingress.Pushes.Select(push => push.Message.MsgId));
|
Assert.Equal(["28", "29", "30"], ingress.Pushes.Select(push => push.Request.Source.ExternalId));
|
||||||
Assert.Equal(TenantId, ingress.Pushes[0].TenantId);
|
Assert.Equal(TenantId, ingress.Pushes[0].TenantId);
|
||||||
Assert.Equal(DialogId, ingress.Pushes[0].Message.DialogId);
|
Assert.Equal(DialogId, ingress.Pushes[0].Request.Source.OriginRef);
|
||||||
Assert.Equal(ChannelName, ingress.Pushes[0].Message.ChannelName);
|
Assert.Equal(ChannelName, ingress.Pushes[0].Request.Source.DisplayName);
|
||||||
Assert.Equal(ChannelHandle, ingress.Pushes[0].Message.ChannelHandle);
|
Assert.Equal(DialogHue.Compute(DialogId, ChannelName), ingress.Pushes[0].Request.Source.Extra["hue"]);
|
||||||
Assert.Equal(DialogHue.Compute(DialogId, ChannelName), ingress.Pushes[0].Message.ChannelHue);
|
Assert.Equal("новое сообщение", ingress.Pushes[2].Request.Content.Text);
|
||||||
Assert.Equal("новое сообщение", ingress.Pushes[2].Message.Text);
|
|
||||||
Assert.Equal(3, pacer.Waits.Count);
|
Assert.Equal(3, pacer.Waits.Count);
|
||||||
Assert.All(pacer.Waits, wait =>
|
Assert.All(pacer.Waits, wait =>
|
||||||
{
|
{
|
||||||
@@ -114,7 +113,7 @@ public sealed class BackfillServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сбой PushMessage прерывает backfill без read-ack
|
/// Сбой PushSource прерывает backfill без read-ack
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Backfill_PushFails_Throws_WithoutMarkRead()
|
public async Task Backfill_PushFails_Throws_WithoutMarkRead()
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ public sealed class RealtimeSweepTests
|
|||||||
Assert.True(catalog.IsMonitored(TenantId, DialogId));
|
Assert.True(catalog.IsMonitored(TenantId, DialogId));
|
||||||
Assert.False(catalog.IsMonitored(TenantId, OtherDialogId));
|
Assert.False(catalog.IsMonitored(TenantId, OtherDialogId));
|
||||||
// Дочитаны только сообщения мониторящегося диалога, от старых к новым.
|
// Дочитаны только сообщения мониторящегося диалога, от старых к новым.
|
||||||
Assert.Equal([28, 29, 30], ingress.Pushes.Select(push => push.Message.MsgId));
|
Assert.Equal(["28", "29", "30"], ingress.Pushes.Select(push => push.Request.Source.ExternalId));
|
||||||
Assert.Equal(DialogId, ingress.Pushes[0].Message.DialogId);
|
Assert.Equal(DialogId, ingress.Pushes[0].Request.Source.OriginRef);
|
||||||
Assert.Equal([DialogId], harness.Client.MarkedReadDialogs);
|
Assert.Equal([DialogId], harness.Client.MarkedReadDialogs);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Deal.Grpc.Hosting.Interceptors;
|
|||||||
using Deal.Grpc.Hosting.Models;
|
using Deal.Grpc.Hosting.Models;
|
||||||
using Deal.Grpc.Hosting.Options;
|
using Deal.Grpc.Hosting.Options;
|
||||||
using Deal.Grpc.Hosting.Services;
|
using Deal.Grpc.Hosting.Services;
|
||||||
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Telegram;
|
||||||
using Deal.Telegram.Sessions;
|
using Deal.Telegram.Sessions;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
@@ -22,7 +23,8 @@ public sealed class CoreIngressClient : ICoreIngressClient
|
|||||||
private readonly ILogger<CoreIngressClient> _logger;
|
private readonly ILogger<CoreIngressClient> _logger;
|
||||||
private readonly MtlsCertificates? _mtlsCertificates;
|
private readonly MtlsCertificates? _mtlsCertificates;
|
||||||
private readonly object _channelGate = new();
|
private readonly object _channelGate = new();
|
||||||
private IngressService.IngressServiceClient? _client;
|
private IngressService.IngressServiceClient? _ingressClient;
|
||||||
|
private SourceIngressService.SourceIngressServiceClient? _sourceClient;
|
||||||
private GrpcChannel? _channel;
|
private GrpcChannel? _channel;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -42,19 +44,19 @@ public sealed class CoreIngressClient : ICoreIngressClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<PushMessageReply> PushMessageAsync(
|
public async Task<PushSourceReply> PushSourceAsync(
|
||||||
string tenantId,
|
string tenantId,
|
||||||
PushMessageRequest message,
|
PushSourceRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
IngressService.IngressServiceClient client = GetClient();
|
SourceIngressService.SourceIngressServiceClient client = GetSourceClient();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await client.PushMessageAsync(message, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
|
return await client.PushSourceAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
throw Fail("PushMessage", exception);
|
throw Fail("PushSource", exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ public sealed class CoreIngressClient : ICoreIngressClient
|
|||||||
IReadOnlyList<DialogEntry> entries,
|
IReadOnlyList<DialogEntry> entries,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
IngressService.IngressServiceClient client = GetClient();
|
IngressService.IngressServiceClient client = GetIngressClient();
|
||||||
var request = new SyncDialogsRequest();
|
var request = new SyncDialogsRequest();
|
||||||
request.Entries.AddRange(entries);
|
request.Entries.AddRange(entries);
|
||||||
try
|
try
|
||||||
@@ -78,31 +80,44 @@ public sealed class CoreIngressClient : ICoreIngressClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса).
|
// Клиент SourceIngress (PushSource); канал к ядру создаётся лениво.
|
||||||
private IngressService.IngressServiceClient GetClient()
|
private SourceIngressService.SourceIngressServiceClient GetSourceClient()
|
||||||
{
|
{
|
||||||
lock (_channelGate)
|
lock (_channelGate)
|
||||||
{
|
{
|
||||||
if (_client is null)
|
EnsureChannelLocked();
|
||||||
{
|
return _sourceClient!;
|
||||||
if (_mtlsCertificates is not null)
|
|
||||||
{
|
|
||||||
_channel = GrpcChannel.ForAddress(
|
|
||||||
_options.IngressEndpoint,
|
|
||||||
new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() });
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_channel = GrpcChannel.ForAddress(_options.IngressEndpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
_client = new IngressService.IngressServiceClient(_channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _client;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Клиент Ingress (SyncDialogs); канал к ядру создаётся лениво.
|
||||||
|
private IngressService.IngressServiceClient GetIngressClient()
|
||||||
|
{
|
||||||
|
lock (_channelGate)
|
||||||
|
{
|
||||||
|
EnsureChannelLocked();
|
||||||
|
return _ingressClient!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса).
|
||||||
|
private void EnsureChannelLocked()
|
||||||
|
{
|
||||||
|
if (_channel is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_channel = _mtlsCertificates is not null
|
||||||
|
? GrpcChannel.ForAddress(
|
||||||
|
_options.IngressEndpoint,
|
||||||
|
new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() })
|
||||||
|
: GrpcChannel.ForAddress(_options.IngressEndpoint);
|
||||||
|
|
||||||
|
_ingressClient = new IngressService.IngressServiceClient(_channel);
|
||||||
|
_sourceClient = new SourceIngressService.SourceIngressServiceClient(_channel);
|
||||||
|
}
|
||||||
|
|
||||||
private CallOptions CallOptions(string tenantId)
|
private CallOptions CallOptions(string tenantId)
|
||||||
{
|
{
|
||||||
var metadata = new Metadata
|
var metadata = new Metadata
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Telegram;
|
||||||
|
|
||||||
namespace Deal.Telegram.Core;
|
namespace Deal.Telegram.Core;
|
||||||
@@ -8,15 +9,15 @@ namespace Deal.Telegram.Core;
|
|||||||
public interface ICoreIngressClient
|
public interface ICoreIngressClient
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Отправляет сообщение диалога в ядро.
|
/// Отправляет запись источника в ядро.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tenantId">Id тенанта.</param>
|
/// <param name="tenantId">Id тенанта.</param>
|
||||||
/// <param name="message">Сообщение в контракте PushMessageRequest.</param>
|
/// <param name="request">Запись в контракте PushSourceRequest.</param>
|
||||||
/// <param name="cancellationToken">Отмена вызова.</param>
|
/// <param name="cancellationToken">Отмена вызова.</param>
|
||||||
/// <returns>Ответ ядра (accepted/duplicate — дубль dialog+msgId в очереди не растёт).</returns>
|
/// <returns>Ответ ядра (accepted/duplicate — дубль источника в очереди не растёт).</returns>
|
||||||
public Task<PushMessageReply> PushMessageAsync(
|
public Task<PushSourceReply> PushSourceAsync(
|
||||||
string tenantId,
|
string tenantId,
|
||||||
PushMessageRequest message,
|
PushSourceRequest request,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Telegram.Core;
|
using Deal.Telegram.Core;
|
||||||
using Deal.Telegram.Sessions;
|
using Deal.Telegram.Sessions;
|
||||||
using Deal.Telegram.Telegram;
|
using Deal.Telegram.Telegram;
|
||||||
@@ -54,7 +54,7 @@ public sealed class BackfillService
|
|||||||
/// Создаёт службу backfill'а.
|
/// Создаёт службу backfill'а.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sessionFarm">Пул сессий тенантов (read-операции диалогов).</param>
|
/// <param name="sessionFarm">Пул сессий тенантов (read-операции диалогов).</param>
|
||||||
/// <param name="ingress">Канал в ядро (PushMessage).</param>
|
/// <param name="ingress">Канал в ядро (PushSource).</param>
|
||||||
/// <param name="pacer">Анти-бан-паузы (реальный — случайные, тесты — фейк).</param>
|
/// <param name="pacer">Анти-бан-паузы (реальный — случайные, тесты — фейк).</param>
|
||||||
/// <param name="logger">Логгер.</param>
|
/// <param name="logger">Логгер.</param>
|
||||||
public BackfillService(
|
public BackfillService(
|
||||||
@@ -168,8 +168,8 @@ public sealed class BackfillService
|
|||||||
int processed = 0;
|
int processed = 0;
|
||||||
foreach (TelegramMessage message in messages.Reverse())
|
foreach (TelegramMessage message in messages.Reverse())
|
||||||
{
|
{
|
||||||
PushMessageReply reply = await _ingress
|
PushSourceReply reply = await _ingress
|
||||||
.PushMessageAsync(tenantId, DialogProtoMapper.ToPushRequest(message), cancellationToken)
|
.PushSourceAsync(tenantId, DialogProtoMapper.ToSourceRequest(message), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
if (reply.Accepted && !reply.Duplicate)
|
if (reply.Accepted && !reply.Duplicate)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Telegram;
|
||||||
using Deal.Telegram.Telegram;
|
using Deal.Telegram.Telegram;
|
||||||
|
|
||||||
@@ -9,6 +10,10 @@ namespace Deal.Telegram.Dialogs;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DialogProtoMapper
|
public static class DialogProtoMapper
|
||||||
{
|
{
|
||||||
|
private const string TelegramKind = "telegram";
|
||||||
|
|
||||||
|
private const string HueKey = "hue";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Нейтральный диалог → DialogEntry контракта
|
/// Нейтральный диалог → DialogEntry контракта
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -38,19 +43,27 @@ public static class DialogProtoMapper
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Нейтральное сообщение → PushMessageRequest ингресса.
|
/// Нейтральное сообщение → PushSourceRequest ингресса.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Сообщение диалога (непустой текст).</param>
|
/// <param name="message">Сообщение диалога (непустой текст).</param>
|
||||||
/// <returns>Запрос Ingress.PushMessage с канальными полями и дубль-гвардом msg_id.</returns>
|
/// <returns>Запрос SourceIngress.PushSource с источником telegram и содержимым.</returns>
|
||||||
public static PushMessageRequest ToPushRequest(TelegramMessage message)
|
public static PushSourceRequest ToSourceRequest(TelegramMessage message)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
DialogId = message.DialogId,
|
Source = new SourceRefProto
|
||||||
ChannelName = message.DialogName,
|
{
|
||||||
ChannelHandle = message.DialogHandle,
|
Kind = TelegramKind,
|
||||||
ChannelHue = DialogHue.Compute(message.DialogId, message.DialogName),
|
ExternalId = message.Id.ToString(CultureInfo.InvariantCulture),
|
||||||
MsgId = message.Id,
|
OriginRef = message.DialogId,
|
||||||
Text = message.Text,
|
DisplayName = message.DialogName,
|
||||||
MsgAt = message.DateMs,
|
Author = message.DialogName,
|
||||||
|
ReceivedAt = message.DateMs,
|
||||||
|
Extra = { [HueKey] = DialogHue.Compute(message.DialogId, message.DialogName) },
|
||||||
|
},
|
||||||
|
Content = new SourceContentProto
|
||||||
|
{
|
||||||
|
Text = message.Text,
|
||||||
|
Author = message.DialogName,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Telegram.Core;
|
using Deal.Telegram.Core;
|
||||||
using Deal.Telegram.Sessions;
|
using Deal.Telegram.Sessions;
|
||||||
using Deal.Telegram.Telegram;
|
using Deal.Telegram.Telegram;
|
||||||
@@ -20,7 +20,7 @@ public sealed class RealtimeListener
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="session">Ready-сессия тенанта (события сообщений её клиента).</param>
|
/// <param name="session">Ready-сессия тенанта (события сообщений её клиента).</param>
|
||||||
/// <param name="catalog">Зеркало мониторинга.</param>
|
/// <param name="catalog">Зеркало мониторинга.</param>
|
||||||
/// <param name="ingress">Канал в ядро (PushMessage).</param>
|
/// <param name="ingress">Канал в ядро (PushSource).</param>
|
||||||
/// <param name="logger">Логгер.</param>
|
/// <param name="logger">Логгер.</param>
|
||||||
public RealtimeListener(
|
public RealtimeListener(
|
||||||
TenantSession session,
|
TenantSession session,
|
||||||
@@ -52,7 +52,7 @@ public sealed class RealtimeListener
|
|||||||
_session.SetListenerActive(false);
|
_session.SetListenerActive(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработчик входящего сообщения: фильтр по зеркалу → PushMessage в ядро → mark-as-read.
|
// Обработчик входящего сообщения: фильтр по зеркалу → PushSource в ядро → mark-as-read.
|
||||||
// Сбой отправки не роняет realtime: сообщение остаётся непрочитанным и догоняется realtime_sweep.
|
// Сбой отправки не роняет realtime: сообщение остаётся непрочитанным и догоняется realtime_sweep.
|
||||||
// message: Входящее сообщение аккаунта.
|
// message: Входящее сообщение аккаунта.
|
||||||
private async Task OnMessageReceivedAsync(TelegramMessage message)
|
private async Task OnMessageReceivedAsync(TelegramMessage message)
|
||||||
@@ -64,8 +64,8 @@ public sealed class RealtimeListener
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PushMessageReply reply = await _ingress
|
PushSourceReply reply = await _ingress
|
||||||
.PushMessageAsync(_session.TenantId, DialogProtoMapper.ToPushRequest(message), CancellationToken.None)
|
.PushSourceAsync(_session.TenantId, DialogProtoMapper.ToSourceRequest(message), CancellationToken.None)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
if (reply.Accepted)
|
if (reply.Accepted)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deal.Grpc.Sources;
|
||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Telegram;
|
||||||
using Deal.Telegram.Core;
|
using Deal.Telegram.Core;
|
||||||
using Deal.Telegram.Sessions;
|
using Deal.Telegram.Sessions;
|
||||||
@@ -40,7 +41,7 @@ public sealed class RealtimeSweep
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sessionFarm">Пул сессий тенантов.</param>
|
/// <param name="sessionFarm">Пул сессий тенантов.</param>
|
||||||
/// <param name="catalog">Зеркало каталога/мониторинга (актуализируется ответом SyncDialogs).</param>
|
/// <param name="catalog">Зеркало каталога/мониторинга (актуализируется ответом SyncDialogs).</param>
|
||||||
/// <param name="ingress">Канал в ядро (PushMessage/SyncDialogs).</param>
|
/// <param name="ingress">Канал в ядро (PushSource/SyncDialogs).</param>
|
||||||
/// <param name="logger">Логгер.</param>
|
/// <param name="logger">Логгер.</param>
|
||||||
public RealtimeSweep(
|
public RealtimeSweep(
|
||||||
SessionFarm sessionFarm,
|
SessionFarm sessionFarm,
|
||||||
@@ -145,8 +146,8 @@ public sealed class RealtimeSweep
|
|||||||
int added = 0;
|
int added = 0;
|
||||||
foreach (TelegramMessage message in messages.Reverse())
|
foreach (TelegramMessage message in messages.Reverse())
|
||||||
{
|
{
|
||||||
PushMessageReply reply = await _ingress
|
PushSourceReply reply = await _ingress
|
||||||
.PushMessageAsync(tenantId, DialogProtoMapper.ToPushRequest(message), cancellationToken)
|
.PushSourceAsync(tenantId, DialogProtoMapper.ToSourceRequest(message), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
if (reply.Accepted && !reply.Duplicate)
|
if (reply.Accepted && !reply.Duplicate)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public sealed class RealtimeMonitorService : BackgroundService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sessionFarm">Пул сессий тенантов.</param>
|
/// <param name="sessionFarm">Пул сессий тенантов.</param>
|
||||||
/// <param name="catalog">Зеркало мониторинга (фильтр сообщений).</param>
|
/// <param name="catalog">Зеркало мониторинга (фильтр сообщений).</param>
|
||||||
/// <param name="ingress">Канал в ядро (PushMessage).</param>
|
/// <param name="ingress">Канал в ядро (PushSource).</param>
|
||||||
/// <param name="loggerFactory">Фабрика логгеров (логгеры listener'ов).</param>
|
/// <param name="loggerFactory">Фабрика логгеров (логгеры listener'ов).</param>
|
||||||
public RealtimeMonitorService(
|
public RealtimeMonitorService(
|
||||||
SessionFarm sessionFarm,
|
SessionFarm sessionFarm,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public static class SessionErrorMessages
|
|||||||
public const string TelegramUnavailable = "Telegram недоступен — повторите попытку позже";
|
public const string TelegramUnavailable = "Telegram недоступен — повторите попытку позже";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ядро (gRPC-ингресс) недоступно — PushMessage/SyncDialogs не доставлены (UNAVAILABLE).
|
/// Ядро (gRPC-ингресс) недоступно — PushSource/SyncDialogs не доставлены (UNAVAILABLE).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string IngressUnavailable = "Ядро недоступно — повторите попытку позже";
|
public const string IngressUnavailable = "Ядро недоступно — повторите попытку позже";
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public interface ISessionClient : IAsyncDisposable
|
|||||||
/// <param name="dialogId">Подписанный id диалога (каналы "-100…", группы "-…", личные "+…").</param>
|
/// <param name="dialogId">Подписанный id диалога (каналы "-100…", группы "-…", личные "+…").</param>
|
||||||
/// <param name="limit">Сколько последних сообщений запросить.</param>
|
/// <param name="limit">Сколько последних сообщений запросить.</param>
|
||||||
/// <param name="cancellationToken">Отмена операции.</param>
|
/// <param name="cancellationToken">Отмена операции.</param>
|
||||||
/// <returns>Сообщения диалога (с канальными полями для PushMessage).</returns>
|
/// <returns>Сообщения диалога (с канальными полями для PushSource).</returns>
|
||||||
public Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(
|
public Task<IReadOnlyList<TelegramMessage>> GetMessagesAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
int limit,
|
int limit,
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ public sealed record TelegramMessage
|
|||||||
/// <param name="id">Id сообщения в Telegram (дубль-гвард dialog+msgId ядра).</param>
|
/// <param name="id">Id сообщения в Telegram (дубль-гвард dialog+msgId ядра).</param>
|
||||||
/// <param name="text">Текст сообщения (непустой).</param>
|
/// <param name="text">Текст сообщения (непустой).</param>
|
||||||
/// <param name="dateMs">Время сообщения, epoch-ms.</param>
|
/// <param name="dateMs">Время сообщения, epoch-ms.</param>
|
||||||
/// <param name="dialogName">Имя диалога (title/first_name) для PushMessage.channel_name.</param>
|
/// <param name="dialogName">Имя диалога (title/first_name) для source.display_name.</param>
|
||||||
/// <param name="dialogHandle">Username диалога для PushMessage.channel_handle.</param>
|
/// <param name="dialogHandle">Username диалога.</param>
|
||||||
public TelegramMessage(
|
public TelegramMessage(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
int id,
|
int id,
|
||||||
@@ -51,7 +51,7 @@ public sealed record TelegramMessage
|
|||||||
public long DateMs { get; }
|
public long DateMs { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Имя диалога (title/first_name) — для PushMessage.channel_name.
|
/// Имя диалога (title/first_name) — для source.display_name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string DialogName { get; }
|
public string DialogName { get; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user