diff --git a/backlog.md b/backlog.md index 5d3ff8d..3d8f0fc 100644 --- a/backlog.md +++ b/backlog.md @@ -57,7 +57,7 @@ | TD-DBL-CLICK | Двойная перезагрузка доски у инициатора batch-reclassify (ответ + SSE) | этап 12, E | P3 | TECHDEBT | | TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | 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 | Провайдеры содержимого источников. **Сделано (2026-09-11):** `TelegramSourceContentProvider` + `ReadSource` RPC + `GET /api/cards/{id}/source` + UI «Обновить из источника». Осталось: провайдеры прочих источников по мере появления | generic source 2026-09-11 | P2 | TECHDEBT | | TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером. **Решение (2026-09-11): медиа-посты Telegram пропускаем** — извлечение/выгрузка не делаются. Остались на будущее: вложения прочих источников (файл/диск/таблица) и `ISourceContentProvider` для remote-просмотра | generic source 2026-09-11 | P3 | BACKLOG | | 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 | diff --git a/docs/superpowers/STATUS.md b/docs/superpowers/STATUS.md index 618e23b..4b846eb 100644 --- a/docs/superpowers/STATUS.md +++ b/docs/superpowers/STATUS.md @@ -10,9 +10,10 @@ > пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и > `GET /api/cards/{id}/source`. Входящий поток источников — generic (`sources.proto`/`PushSource`, > `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру -> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Ядро: build 5 sln 0/0, -> `Deal.Tests.Unit` **1298/1298 PASS**, telegram **125/125**, фронт `build` + `lint:i18n` зелёные. -> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`. +> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр +> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника». +> Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1307/1307 PASS**, telegram **130/130**, фронт `build` + +> `lint:i18n` зелёные. Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`. > Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений > telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики > (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис. diff --git a/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md b/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md index cef6d56..163fc2a 100644 --- a/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md +++ b/docs/superpowers/specs/2026-09-11-source-attachments-вопросы.md @@ -9,7 +9,9 @@ нести `DataRef` — этим смогут пользоваться другие источники (файл/диск/таблица) и ручные вложения карточки. - **Б) Проверка без живого Telegram** — реализуем с юнит-тестами на фейковой сессии/фейковом Storage, без реального API. -- **В)** Объяснение термина — в §1.5. +- **В)** Объяснение термина — в §1.5. **Решение: делаем.** Реализован remote-просмотр: `TelegramService.ReadSource`, + `ITelegramGateway.ReadSourceAsync`, `TelegramSourceContentProvider` (Kind=telegram) в ядре, + `GET /api/cards/{id}/source` и кнопка «Обновить из источника» в подробной карточке. ## 1.5. Что такое «remote-просмотр исходника» diff --git a/docs/superpowers/specs/2026-09-11-source-contract-design.md b/docs/superpowers/specs/2026-09-11-source-contract-design.md index d3a4989..7683790 100644 --- a/docs/superpowers/specs/2026-09-11-source-contract-design.md +++ b/docs/superpowers/specs/2026-09-11-source-contract-design.md @@ -181,3 +181,13 @@ API ядра: `GET /api/cards/{id}/source` → generic контент. telegram-сервис шлёт записи через `PushSource` (`kind="telegram"`). Превью каталога/TgMessages сохраняет `TelegramSourceIngestObserver` (ядро, telegram-модуль — единственное место с telegram-спецификой приёма). - Любой другой источник (whatsapp/avito/файл/excel) шлёт тот же `PushSource` со своим `source.kind`. + +### Remote-просмотр исходника (2026-09-11) + +- `TelegramService.ReadSource(ReadSourceRequest{dialog_id, msg_id})` → `ReadSourceReply{found, text?, time?}` + (`src/contracts/telegram.proto`); telegram-сервис достаёт конкретное сообщение + (`ISessionClient.GetMessageAsync` → TL `Messages_GetMessages`). Медиа без текста → `found=false`. +- Ядро: `ITelegramGateway.ReadSourceAsync` + `TelegramSourceContentProvider` (`ISourceContentProvider`, + `Kind="telegram"`, `Deal.Infrastructure/Integrations/Sources`) — резолвится `SourceContentResolver`. +- `GET /api/cards/{id}/source` отдаёт результат провайдера либо сохранённое содержимое карточки. + Фронт: кнопка «Обновить из источника» в подробной карточке (`CardDrawer.vue` → `loadCardSource`). diff --git a/src/contracts/telegram.proto b/src/contracts/telegram.proto index ca1de0f..caf4b3c 100644 --- a/src/contracts/telegram.proto +++ b/src/contracts/telegram.proto @@ -76,6 +76,10 @@ service TelegramService { // свежие из Telegram; признак lead и фолбэк на БД добавляет ядро rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply); + // Исходное сообщение источника по id (remote-просмотр исходника карточки). + // found=false — сообщение не найдено/удалено (не ошибка RPC). + rpc ReadSource(ReadSourceRequest) returns (ReadSourceReply); + // Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро rpc Search(SearchRequest) returns (SearchReply); @@ -235,6 +239,22 @@ message ReadRecentReply { repeated PreviewMessage messages = 1; } +message ReadSourceRequest { + // Id диалога-источника. + string dialog_id = 1; + // Id исходного сообщения в Telegram. + int64 msg_id = 2; +} + +message ReadSourceReply { + // True — сообщение найдено и передано; false — нет (поля пусты). + bool found = 1; + // Текст исходного сообщения. + optional string text = 2; + // Время сообщения, epoch-ms. + optional int64 time = 3; +} + message PreviewMessage { // Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки // "m__", поэтому значение передаётся строкой. diff --git a/src/core/Deal.Contracts/Integrations/Abstractions/ITelegramGateway.cs b/src/core/Deal.Contracts/Integrations/Abstractions/ITelegramGateway.cs index 23d661b..d544bfd 100644 --- a/src/core/Deal.Contracts/Integrations/Abstractions/ITelegramGateway.cs +++ b/src/core/Deal.Contracts/Integrations/Abstractions/ITelegramGateway.cs @@ -103,6 +103,17 @@ public interface ITelegramGateway int limit, CancellationToken ct); + /// + /// Исходное сообщение источника по id (remote-просмотр исходника). + /// + /// Id диалога-источника. + /// Id исходного сообщения в Telegram. + /// Найденный текст либо found=false. + public Task ReadSourceAsync( + string dialogId, + long msgId, + CancellationToken ct); + /// /// Глобальный поиск каналов/групп по ключу. /// diff --git a/src/core/Deal.Contracts/Integrations/Models/TelegramSourceContentDto.cs b/src/core/Deal.Contracts/Integrations/Models/TelegramSourceContentDto.cs new file mode 100644 index 0000000..6520bcd --- /dev/null +++ b/src/core/Deal.Contracts/Integrations/Models/TelegramSourceContentDto.cs @@ -0,0 +1,9 @@ +namespace Deal.Contracts.Integrations.Models; + +/// +/// Исходное сообщение источника — ответ ReadSourceAsync гейта. +/// +/// True — сообщение найдено у источника; false — недоступно (поля пусты). +/// Текст исходного сообщения, если найдено. +/// Время сообщения, epoch-ms; null — источник не отдал время. +public sealed record TelegramSourceContentDto(bool Found, string? Text, long? TimeMs); diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs index a5a7de3..c3590ba 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs @@ -278,6 +278,30 @@ public sealed class GrpcTelegramClient : ITelegramGateway } } + /// + public async Task ReadSourceAsync( + string dialogId, + long msgId, + CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + ReadSourceReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.ReadSourceAsync( + new ReadSourceRequest { DialogId = dialogId ?? string.Empty, MsgId = msgId }, options)); + return new TelegramSourceContentDto( + reply.Found, + reply.HasText ? reply.Text : null, + reply.HasTime ? reply.Time : null); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "read_source"); + } + } + /// public async Task> SearchAsync( string query, diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs index 5c95cc4..e024c3d 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs @@ -67,6 +67,13 @@ public sealed class LocalTelegramGateway : ITelegramGateway CancellationToken ct) => Task.FromResult>([]); + /// + public Task ReadSourceAsync( + string dialogId, + long msgId, + CancellationToken ct) + => Task.FromResult(new TelegramSourceContentDto(false, null, null)); + /// public Task> SearchAsync( string query, diff --git a/src/core/Deal.Infrastructure/Integrations/Sources/TelegramSourceContentProvider.cs b/src/core/Deal.Infrastructure/Integrations/Sources/TelegramSourceContentProvider.cs new file mode 100644 index 0000000..777e35e --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Sources/TelegramSourceContentProvider.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Sources; + +namespace Deal.Infrastructure.Integrations.Sources; + +/// +/// Провайдер содержимого исходника источника telegram: запрос к telegram-service по id сообщения. +/// +/// Порт-гейт к telegram-service. +public sealed class TelegramSourceContentProvider(ITelegramGateway gateway) : ISourceContentProvider +{ + private const string TelegramKind = "telegram"; + + /// + public string Kind => TelegramKind; + + /// + public async Task LoadAsync(SourceRef source, CancellationToken ct) + { + if (!string.Equals(source.Kind, TelegramKind, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string dialogId = source.OriginRef ?? string.Empty; + if (dialogId.Length == 0 + || !long.TryParse(source.ExternalId, NumberStyles.Integer, CultureInfo.InvariantCulture, out long msgId)) + { + return null; + } + + TelegramSourceContentDto content = await gateway.ReadSourceAsync(dialogId, msgId, ct).ConfigureAwait(false); + return content.Found + ? new SourceContent { Text = content.Text } + : null; + } +} diff --git a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs index 831daeb..84c5734 100644 --- a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs +++ b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs @@ -3,12 +3,14 @@ using Deal.Infrastructure.Integrations.Abstractions; using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; +using Deal.Infrastructure.Integrations.Sources; using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence.Repositories; using Deal.Infrastructure.Security; using Deal.Infrastructure.Services; using Deal.Infrastructure.Tenancy; using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Discovery.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions; @@ -56,6 +58,8 @@ public static class ServiceCollectionExtensions services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs index af97334..06d7db7 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs @@ -130,6 +130,13 @@ public sealed class FakeDiscoveryGateway : ITelegramGateway CancellationToken ct) => throw new NotSupportedException(); + /// + public Task ReadSourceAsync( + string dialogId, + long msgId, + CancellationToken ct) + => throw new NotSupportedException(); + /// public Task> SearchAsync( string query, diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs index f23086c..baf05dd 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs @@ -29,6 +29,16 @@ public sealed class FakeTelegramGateway : ITelegramGateway /// public List<(string DialogId, bool Force)> BackfillCalls { get; } = []; + /// + /// Вызовы ReadSource + /// + public List<(string DialogId, long MsgId)> ReadSourceCalls { get; } = []; + + /// + /// Результат ReadSourceAsync + /// + public TelegramSourceContentDto SourceContent { get; set; } = new(false, null, null); + /// /// Сколько сообщений «разобрал» Backfill /// @@ -143,6 +153,16 @@ public sealed class FakeTelegramGateway : ITelegramGateway CancellationToken ct) => Task.FromResult>([]); + /// + public Task ReadSourceAsync( + string dialogId, + long msgId, + CancellationToken ct) + { + ReadSourceCalls.Add((dialogId, msgId)); + return Task.FromResult(SourceContent); + } + /// public Task> SearchAsync( string query, diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs index a97bbae..3061638 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs @@ -174,6 +174,52 @@ public sealed class GrpcTelegramClientTests }); } + /// + /// ReadSource уходит с dialog_id/msg_id и маппит текст/время + /// + [Fact] + public async Task ReadSourceAsync_SendsDialogAndMsgIdAndMapsText() + { + var service = new RecordingTelegramService + { + ReadSourceFound = true, + ReadSourceText = "Исходник", + ReadSourceTime = 1700000000000, + }; + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => + { + ITelegramGateway gateway = CreateClient(port); + TelegramSourceContentDto content = await gateway.ReadSourceAsync("-1001", 777, CancellationToken.None); + + ReadSourceRequest request = Assert.Single(recorded.ReadSourceRequests); + Assert.Equal("-1001", request.DialogId); + Assert.Equal(777, request.MsgId); + Assert.True(content.Found); + Assert.Equal("Исходник", content.Text); + Assert.Equal(1700000000000, content.TimeMs); + }); + } + + /// + /// ReadSource с found=false не отдаёт текст и время + /// + [Fact] + public async Task ReadSourceAsync_NotFound_MapsEmptyContent() + { + var service = new RecordingTelegramService { ReadSourceFound = false }; + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) => + { + ITelegramGateway gateway = CreateClient(port); + TelegramSourceContentDto content = await gateway.ReadSourceAsync("-1001", 777, CancellationToken.None); + + Assert.False(content.Found); + Assert.Null(content.Text); + Assert.Null(content.TimeMs); + }); + } + /// /// Вызов вне tenant-контекста — ошибка конфигурации. /// diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs index 1984084..5674774 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs @@ -20,6 +20,7 @@ public sealed class TelegramGatewayPortTests "SetMonitorAllAsync", "BackfillAsync", "ReadRecentAsync", + "ReadSourceAsync", "SearchAsync", "InfoAsync", "ReadForEvalAsync", diff --git a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs index 778d40a..09d562a 100644 --- a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs +++ b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs @@ -28,6 +28,26 @@ public sealed class RecordingTelegramService : TelegramService.TelegramServiceBa /// public List ReadRecentRequests { get; } = []; + /// + /// Запросы ReadSource + /// + public List ReadSourceRequests { get; } = []; + + /// + /// found ответа ReadSource + /// + public bool ReadSourceFound { get; set; } + + /// + /// text ответа ReadSource (null — поле не задано) + /// + public string? ReadSourceText { get; set; } + + /// + /// time ответа ReadSource (null — поле не задано) + /// + public long? ReadSourceTime { get; set; } + /// /// Каталог ответа RefreshDialogs /// @@ -146,6 +166,25 @@ public sealed class RecordingTelegramService : TelegramService.TelegramServiceBa return Task.FromResult(reply); } + /// + public override Task ReadSource(ReadSourceRequest request, ServerCallContext context) + { + ThrowIfDomainFailure(context); + ReadSourceRequests.Add(request); + var reply = new ReadSourceReply { Found = ReadSourceFound }; + if (ReadSourceText is not null) + { + reply.Text = ReadSourceText; + } + + if (ReadSourceTime is not null) + { + reply.Time = ReadSourceTime.Value; + } + + return Task.FromResult(reply); + } + // Бросает доменный сбой сценария (если задан) — как RPC-ошибка telegram-service. // context: Контекст вызова (не используется, но держит сигнатуру единообразной). private void ThrowIfDomainFailure(ServerCallContext context) diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceContentProviderTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceContentProviderTests.cs new file mode 100644 index 0000000..cb4ed00 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceContentProviderTests.cs @@ -0,0 +1,110 @@ +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Integrations.Sources; +using Deal.Modules.Cards.Application.Sources; +using Deal.Tests.Unit.Contracts; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты провайдера содержимого исходника telegram — TelegramSourceContentProvider. +/// +public sealed class TelegramSourceContentProviderTests +{ + [Fact] + public async Task LoadAsync_ForeignKind_ReturnsNullWithoutGatewayCall() + { + var gateway = new FakeTelegramGateway(); + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "local"), CancellationToken.None); + + Assert.Null(content); + Assert.Empty(gateway.ReadSourceCalls); + } + + [Fact] + public async Task LoadAsync_TelegramKindCaseInsensitive_LoadsContent() + { + var gateway = new FakeTelegramGateway + { + SourceContent = new TelegramSourceContentDto(true, "текст", null), + }; + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "Telegram", origin: "d_1", externalId: "100"), CancellationToken.None); + + Assert.NotNull(content); + Assert.Equal("текст", content.Text); + } + + [Fact] + public async Task LoadAsync_EmptyOriginRef_ReturnsNullWithoutGatewayCall() + { + var gateway = new FakeTelegramGateway(); + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "telegram", origin: null, externalId: "100"), CancellationToken.None); + + Assert.Null(content); + Assert.Empty(gateway.ReadSourceCalls); + } + + [Fact] + public async Task LoadAsync_NonNumericExternalId_ReturnsNullWithoutGatewayCall() + { + var gateway = new FakeTelegramGateway(); + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "not-a-number"), CancellationToken.None); + + Assert.Null(content); + Assert.Empty(gateway.ReadSourceCalls); + } + + [Fact] + public async Task LoadAsync_GatewayNotFound_ReturnsNull() + { + var gateway = new FakeTelegramGateway + { + SourceContent = new TelegramSourceContentDto(false, null, null), + }; + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "100"), CancellationToken.None); + + Assert.Null(content); + } + + [Fact] + public async Task LoadAsync_Found_ReturnsText() + { + var gateway = new FakeTelegramGateway + { + SourceContent = new TelegramSourceContentDto(true, "исходный текст", 1_700_000_000_000), + }; + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None); + + Assert.NotNull(content); + Assert.Equal("исходный текст", content.Text); + } + + [Fact] + public async Task LoadAsync_NumericExternalIdAndOriginRef_CallsGatewayWithParsedArguments() + { + var gateway = new FakeTelegramGateway(); + var provider = new TelegramSourceContentProvider(gateway); + + await provider.LoadAsync( + SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None); + + Assert.Equal([("d_42", 777L)], gateway.ReadSourceCalls); + } +} diff --git a/src/frontend/src/components/card/CardDrawer.vue b/src/frontend/src/components/card/CardDrawer.vue index 64b475c..38aa567 100644 --- a/src/frontend/src/components/card/CardDrawer.vue +++ b/src/frontend/src/components/card/CardDrawer.vue @@ -24,6 +24,7 @@ import { takeCard, addCardFiles, removeCardFile, + loadCardSource, toast, fmtDateTime, } from '../../store.js' @@ -91,16 +92,38 @@ function contactHref(c) { return contactInfo(c?.value)?.href || null } const sourceUrl = computed(() => sourceOriginUrl(card.value)) -// есть что показать в блоке исходника (текст/ссылки/контакты/вложения) -const hasSource = computed(() => { - const c = card.value?.content +// remote-исходник, догруженный кнопкой «Обновить из источника»; пока null — +// показываем содержимое, сохранённое в карточке (card.content). +const sourceOverride = ref(null) +const sourceLoading = ref(false) +function sourceHasContent(c) { if (!c) return false return !!(c.text || c.links?.length || c.contacts?.length || c.data?.length) -}) +} +const sourceContent = computed(() => sourceOverride.value || card.value?.content || null) +// есть что показать в блоке исходника (текст/ссылки/контакты/вложения) +const hasSource = computed(() => sourceHasContent(sourceContent.value)) const sourceSummary = computed(() => { const name = card.value?.source?.displayName || t('drawer.kanal') return `${name} · ${card.value?.time || ''} ${t('drawer.pokazat-tekst')}`.trim() }) +// Клик не дублирует запросы: пока идёт загрузка, повторный вызов игнорируется, +// кнопка disabled. Пустой ответ — тост, сохранённое содержимое остаётся. +async function refreshSource() { + const id = card.value?.id + if (!id || sourceLoading.value) return + sourceLoading.value = true + try { + const remote = await loadCardSource(id) + if (sourceHasContent(remote)) { + sourceOverride.value = remote + } else { + toast(t('drawer.istochnik-nedostupen'), { icon: 'x' }) + } + } finally { + sourceLoading.value = false + } +} // ─── «Выбранные»: редактирование ───────────────────────────────────────── const historyReversed = computed(() => [...(card.value?.history || [])].reverse()) @@ -159,6 +182,14 @@ watch( { immediate: true }, ) +// смена карточки — сбрасываем догруженный исходник обратно на card.content +watch( + () => state.drawerId, + () => { + sourceOverride.value = null + }, +) + function onComment(text) { if (card.value) addComment(card.value.id, text) } @@ -530,8 +561,19 @@ async function copyProjectContact() { {{ $t('drawer.otkryt-ishodnik') }} + - + diff --git a/src/frontend/src/i18n/locales/ru.js b/src/frontend/src/i18n/locales/ru.js index 9ec6c33..a7347dd 100644 --- a/src/frontend/src/i18n/locales/ru.js +++ b/src/frontend/src/i18n/locales/ru.js @@ -1048,6 +1048,8 @@ export const ru = { "ishodnoe-soobshchenie": "Исходное сообщение", "otkryt-ishodnoe-soobshchenie": "Открыть исходное сообщение", "otkryt-ishodnik": "Открыть исходник", + "obnovit-iz-istochnika": "Обновить из источника", + "istochnik-nedostupen": "Исходник недоступен", "pokazat-tekst": "— показать текст", "udalit-ssylku": "Удалить ссылку", "nazvanie-napr-maket": "Название (напр. макет)", diff --git a/src/frontend/src/store/cards.js b/src/frontend/src/store/cards.js index 1da737a..3fd0d27 100644 --- a/src/frontend/src/store/cards.js +++ b/src/frontend/src/store/cards.js @@ -402,6 +402,19 @@ export async function patchCard(cardId, patch) { } } +// Догрузка исходника у сервиса-владельца (remote-просмотр). Сервер сам решает, +// тянуть исходное сообщение у провайдера (telegram и т.п.) или отдать содержимое, +// сохранённое в карточке. Ошибку не пробрасываем: UI показывает «источник +// недоступен» и оставляет сохранённое содержимое. +export async function loadCardSource(cardId) { + if (!cardId) return null + try { + return await api.get(`/api/cards/${cardId}/source`) + } catch { + return null + } +} + export async function addCardLink(cardId, name, url) { // разрешаем только http(s): javascript:/data: и прочие схемы не открываем const u = String(url || '').trim() diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/ReadSourceRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/ReadSourceRpcTests.cs new file mode 100644 index 0000000..2c55a38 --- /dev/null +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/ReadSourceRpcTests.cs @@ -0,0 +1,155 @@ +using Deal.Grpc.Telegram; +using Deal.Telegram.Telegram; +using Deal.Telegram.Tests.Telegram; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Telegram.Tests.Grpc; + +/// +/// RPC-тесты remote-просмотра исходного сообщения (ReadSource). +/// +public sealed class ReadSourceRpcTests +{ + private const string TenantId = TelegramTestHost.DefaultTenantId; + private const string Phone = "+79990001122"; + private const string Code = "11111"; + private const string DialogA = "-100111"; + private const int PollTimeoutMilliseconds = 5000; + + /// + /// ReadSource найденного сообщения: текст и время + /// + [Fact] + public async Task ReadSource_Found_ReturnsTextAndTime() + { + await RunScenarioAsync( + async (channel, factory) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().Messages[DialogA] = + [ + new TelegramMessage(DialogA, 10, "исходный текст", 1_700_000_000_010, "Канал А", "ch_a"), + ]; + + ReadSourceReply reply = await client.ReadSourceAsync( + new ReadSourceRequest { DialogId = DialogA, MsgId = 10 }, Options()); + + Assert.True(reply.Found); + Assert.True(reply.HasText); + Assert.Equal("исходный текст", reply.Text); + Assert.True(reply.HasTime); + Assert.Equal(1_700_000_000_010, reply.Time); + Assert.Equal([(DialogA, 10L)], factory.CreatedClients.Single().MessageRequests); + }); + } + + /// + /// ReadSource ненайденного сообщения: found=false, поля пусты + /// + [Fact] + public async Task ReadSource_NotFound_FoundFalse() + { + await RunScenarioAsync( + async (channel, factory) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().Messages[DialogA] = + [ + new TelegramMessage(DialogA, 10, "исходный текст", 1_700_000_000_010, "Канал А", "ch_a"), + ]; + + ReadSourceReply reply = await client.ReadSourceAsync( + new ReadSourceRequest { DialogId = DialogA, MsgId = 99 }, Options()); + + Assert.False(reply.Found); + Assert.False(reply.HasText); + Assert.False(reply.HasTime); + }); + } + + /// + /// ReadSource неположительного msg_id → INVALID_ARGUMENT + /// + [Fact] + public async Task ReadSource_InvalidMsgId_InvalidArgument() + { + await RunScenarioAsync( + async (channel, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.ReadSourceAsync( + new ReadSourceRequest { DialogId = DialogA, MsgId = 0 }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.InvalidMessageId, exception.Status.Detail); + }); + } + + /// + /// ReadSource без готовой сессии → FAILED_PRECONDITION «Telegram не подключён» + /// + [Fact] + public async Task ReadSource_NoSession_FailedPrecondition() + { + await RunScenarioAsync( + async (channel, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.ReadSourceAsync( + new ReadSourceRequest { DialogId = DialogA, MsgId = 10 }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); + }); + } + + // Вход по телефону на фейк-клиенте до фазы ready (StartPhone → SendCode). + // client: Клиент TelegramService. + private static async Task LoginAsync(TelegramService.TelegramServiceClient client) + { + await client.StartPhoneAsync( + new StartPhoneRequest { Phone = Phone, ApiId = SessionHarness.ApiId, ApiHash = SessionHarness.ApiHash }, + Options()); + await client.SendCodeAsync(new SendCodeRequest { Code = Code }, Options()); + await WaitForPhaseAsync(client, "ready"); + } + + // Поллинг GetStatus до ожидаемой фазы. + // client: Клиент TelegramService. + // phase: Ожидаемая фаза ("ready"). + private static async Task WaitForPhaseAsync(TelegramService.TelegramServiceClient client, string phase) + { + var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); + while (DateTime.UtcNow < deadline) + { + GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options()); + if (status.Phase == phase) + { + return; + } + + await Task.Delay(25); + } + } + + // Прогоняет сценарий на хосте с фейковой фабрикой клиентов. + // scenario: Сценарий (канал, фабрика). + private static async Task RunScenarioAsync(Func scenario) + { + var factory = new FakeClientFactory(); + await TelegramTestHost.RunAsync( + TelegramTestHost.DefaultToken, + channel => scenario(channel, factory), + configureServices: services => services.AddSingleton(factory)); + } + + // Опции вызова с токеном и tenant-id. + private static CallOptions Options() + => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, TenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds)); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs index e48a21d..503c15b 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs @@ -259,6 +259,16 @@ internal sealed class FakeSessionClient : ISessionClient /// public Exception? MessagesError { get; set; } + /// + /// Id сообщений, запрошенных через GetMessageAsync. + /// + public List<(string DialogId, long MsgId)> MessageRequests { get; } = []; + + /// + /// Исключение GetMessageAsync + /// + public Exception? MessageError { get; set; } + /// public Task> GetDialogsAsync(int limit, CancellationToken cancellationToken) { @@ -289,6 +299,24 @@ internal sealed class FakeSessionClient : ISessionClient : []); } + /// + public Task GetMessageAsync( + string dialogId, + long msgId, + CancellationToken cancellationToken) + { + MessageRequests.Add((dialogId, msgId)); + if (MessageError is not null) + { + throw MessageError; + } + + TelegramMessage? message = Messages.TryGetValue(dialogId, out List? messages) + ? messages.FirstOrDefault(item => item.Id == msgId) + : null; + return Task.FromResult(message); + } + /// public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) { diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs index 6438d2f..c626836 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs @@ -144,6 +144,24 @@ public sealed class TlMessageMapperTests Assert.Null(TlMessageMapper.ToMessage(new MessageService { id = 45, peer_id = new PeerChannel { channel_id = ChannelId } }, new Dictionary(), new Dictionary())); } + /// + /// Медиа-сообщение без текста (подпись пуста) не отдаётся. + /// + [Fact] + public void ToMessage_MediaMessageWithoutText_ReturnsNull() + { + var message = new Message + { + id = 46, + message = string.Empty, + date = UtcDate(), + peer_id = new PeerChannel { channel_id = ChannelId }, + media = new MessageMediaPhoto { photo = new PhotoEmpty { id = 1 } }, + }; + + Assert.Null(TlMessageMapper.ToMessage(message, new Dictionary(), new Dictionary())); + } + /// /// Сообщение личного чата /// diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs index 7e1686a..45a1fc7 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs @@ -92,6 +92,11 @@ public static class SessionErrorMessages /// public const string InvalidDialogId = "Некорректный id источника"; + /// + /// Неположительный id сообщения в запросе + /// + public const string InvalidMessageId = "Некорректный id сообщения"; + /// /// Телефон не зарегистрирован в Telegram /// diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs index e062413..45fa0da 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs @@ -73,6 +73,20 @@ public sealed class SessionFarm CancellationToken cancellationToken) => RequireSession(tenantId).GetMessagesAsync(dialogId, limit, cancellationToken); + /// + /// Исходное сообщение диалога тенанта по id. + /// + /// Id тенанта. + /// Подписанный id диалога. + /// Id сообщения в Telegram. + /// Отмена операции. + public Task GetMessageAsync( + string tenantId, + string dialogId, + long msgId, + CancellationToken cancellationToken) + => RequireSession(tenantId).GetMessageAsync(dialogId, msgId, cancellationToken); + /// /// Помечает диалог тенанта прочитанным. /// diff --git a/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs b/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs index 4988ffc..8790fd3 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs @@ -416,6 +416,30 @@ public sealed class TenantSession : IAsyncDisposable } } + /// + /// Исходное сообщение диалога по id (remote-просмотр). + /// + /// Подписанный id диалога. + /// Id сообщения в Telegram. + /// Отмена операции. + /// Сообщение с текстом либо null (медиа без текста/не найдено). + public async Task GetMessageAsync( + string dialogId, + long msgId, + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); + return await client.GetMessageAsync(dialogId, msgId, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + /// /// Помечает диалог прочитанным /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs b/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs index 09d2082..abf9f44 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs @@ -98,6 +98,18 @@ public interface ISessionClient : IAsyncDisposable int limit, CancellationToken cancellationToken); + /// + /// Возвращает исходное сообщение диалога по id (remote-просмотр карточки). + /// + /// Подписанный id диалога (каналы "-100…", группы "-…", личные "+…"). + /// Id сообщения в Telegram. + /// Отмена операции. + /// Сообщение с текстом либо null (медиа без текста/не найдено). + public Task GetMessageAsync( + string dialogId, + long msgId, + CancellationToken cancellationToken); + /// /// Помечает весь диалог прочитанным. /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs b/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs index 9e48792..a653a26 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs @@ -53,6 +53,7 @@ public static class TlMessageMapper { if (message is not Message textMessage || string.IsNullOrWhiteSpace(textMessage.message) + || textMessage.media is not null || (textMessage.flags & Message.Flags.out_) != 0 || textMessage.Peer is null) { diff --git a/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs b/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs index 15a4cfa..ad23af1 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs @@ -264,6 +264,36 @@ public sealed class WTelegramSessionClient : ISessionClient return items; } + /// + public async Task GetMessageAsync( + string dialogId, + long msgId, + CancellationToken cancellationToken) + { + // Диалог обязан быть доступен аккаунту (иначе INVALID_ARGUMENT, как в истории). + await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); + + // messages.getMessages по голому id (InputMessageID) — без привязки к peer. + InputMessage[] ids = [new InputMessageID { id = (int)msgId }]; + Messages_MessagesBase result = await RunTlCallAsync( + () => _client.Messages_GetMessages(ids), + cancellationToken).ConfigureAwait(false); + (MessageBase[] messages, Dictionary chats, Dictionary users) = UnpackMessages(result); + CacheEntities(chats.Values, users.Values); + + foreach (MessageBase message in messages) + { + TelegramMessage? mapped = TlMessageMapper.ToMessage(message, chats, users); + if (mapped is not null && mapped.Id == msgId && mapped.DialogId == dialogId) + { + return mapped; + } + } + + // Медиа без текста или удалённое сообщение — не найдено (found=false). + return null; + } + /// public async Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) { diff --git a/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs b/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs index 1504bee..7dc2eab 100644 --- a/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs +++ b/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs @@ -291,6 +291,44 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } + /// + /// ReadSource — исходное сообщение источника по id (remote-просмотр). + /// + public override async Task ReadSource(ReadSourceRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + RequireDialogId(request.DialogId); + if (request.MsgId <= 0) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.InvalidMessageId)); + } + + ReadSourceReply reply = await ExecuteAsync( + tenantId, + async ct => + { + Telegram.TelegramMessage? message = await _sessionFarm + .GetMessageAsync(tenantId, request.DialogId, request.MsgId, ct) + .ConfigureAwait(false); + + if (message is null) + { + return new ReadSourceReply { Found = false }; + } + + return new ReadSourceReply + { + Found = true, + Text = message.Text, + Time = message.DateMs, + }; + }, + "read_source", + context).ConfigureAwait(false); + return reply; + } + + /// /// Search — глобальный поиск каналов/групп по ключу. ///