Добавить remote-просмотр исходника карточки

RPC TelegramService.ReadSource + ISessionClient.GetMessageAsync, порт ITelegramGateway.ReadSourceAsync и провайдер TelegramSourceContentProvider в ядре; GET /api/cards/{id}/source догружает исходник у сервиса-владельца, в UI — кнопка «Обновить из источника». Медиа-посты пропускаются.
This commit is contained in:
Rustam Khalimov
2026-09-11 17:22:06 +03:00
parent c06ee1cf79
commit db4455a554
30 changed files with 742 additions and 10 deletions
+1 -1
View File
@@ -57,7 +57,7 @@
| TD-DBL-CLICK | Двойная перезагрузка доски у инициатора batch-reclassify (ответ + SSE) | этап 12, E | P3 | TECHDEBT | | TD-DBL-CLICK | Двойная перезагрузка доски у инициатора batch-reclassify (ответ + SSE) | этап 12, E | P3 | TECHDEBT |
| TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT | | TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
| 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 | Провайдеры содержимого источников. **Сделано (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-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-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 |
+4 -3
View File
@@ -10,9 +10,10 @@
> пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и > пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и
> `GET /api/cards/{id}/source`. Входящий поток источников — generic (`sources.proto`/`PushSource`, > `GET /api/cards/{id}/source`. Входящий поток источников — generic (`sources.proto`/`PushSource`,
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру > `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Ядро: build 5 sln 0/0, > (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
> `Deal.Tests.Unit` **1298/1298 PASS**, telegram **125/125**, фронт `build` + `lint:i18n` зелёные. > исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`. > Ядро: 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`, выгрузка вложений > Осталось (в 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-сервис.
@@ -9,7 +9,9 @@
нести `DataRef` — этим смогут пользоваться другие источники (файл/диск/таблица) и ручные вложения карточки. нести `DataRef` — этим смогут пользоваться другие источники (файл/диск/таблица) и ручные вложения карточки.
- **Б) Проверка без живого Telegram** — реализуем с юнит-тестами на фейковой сессии/фейковом Storage, - **Б) Проверка без живого Telegram** — реализуем с юнит-тестами на фейковой сессии/фейковом Storage,
без реального API. без реального API.
- **В)** Объяснение термина — в §1.5. - **В)** Объяснение термина — в §1.5. **Решение: делаем.** Реализован remote-просмотр: `TelegramService.ReadSource`,
`ITelegramGateway.ReadSourceAsync`, `TelegramSourceContentProvider` (Kind=telegram) в ядре,
`GET /api/cards/{id}/source` и кнопка «Обновить из источника» в подробной карточке.
## 1.5. Что такое «remote-просмотр исходника» ## 1.5. Что такое «remote-просмотр исходника»
@@ -181,3 +181,13 @@ API ядра: `GET /api/cards/{id}/source` → generic контент.
telegram-сервис шлёт записи через `PushSource` (`kind="telegram"`). Превью каталога/TgMessages сохраняет telegram-сервис шлёт записи через `PushSource` (`kind="telegram"`). Превью каталога/TgMessages сохраняет
`TelegramSourceIngestObserver` (ядро, telegram-модуль — единственное место с telegram-спецификой приёма). `TelegramSourceIngestObserver` (ядро, telegram-модуль — единственное место с telegram-спецификой приёма).
- Любой другой источник (whatsapp/avito/файл/excel) шлёт тот же `PushSource` со своим `source.kind`. - Любой другой источник (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`).
+20
View File
@@ -76,6 +76,10 @@ service TelegramService {
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро // свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply); rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
// Исходное сообщение источника по id (remote-просмотр исходника карточки).
// found=false — сообщение не найдено/удалено (не ошибка RPC).
rpc ReadSource(ReadSourceRequest) returns (ReadSourceReply);
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро // Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
rpc Search(SearchRequest) returns (SearchReply); rpc Search(SearchRequest) returns (SearchReply);
@@ -235,6 +239,22 @@ message ReadRecentReply {
repeated PreviewMessage messages = 1; 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 { message PreviewMessage {
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки // Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
// "m_<dialog>_<msg>", поэтому значение передаётся строкой. // "m_<dialog>_<msg>", поэтому значение передаётся строкой.
@@ -103,6 +103,17 @@ public interface ITelegramGateway
int limit, int limit,
CancellationToken ct); CancellationToken ct);
/// <summary>
/// Исходное сообщение источника по id (remote-просмотр исходника).
/// </summary>
/// <param name="dialogId">Id диалога-источника.</param>
/// <param name="msgId">Id исходного сообщения в Telegram.</param>
/// <returns>Найденный текст либо found=false.</returns>
public Task<TelegramSourceContentDto> ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct);
/// <summary> /// <summary>
/// Глобальный поиск каналов/групп по ключу. /// Глобальный поиск каналов/групп по ключу.
/// </summary> /// </summary>
@@ -0,0 +1,9 @@
namespace Deal.Contracts.Integrations.Models;
/// <summary>
/// Исходное сообщение источника — ответ ReadSourceAsync гейта.
/// </summary>
/// <param name="Found">True — сообщение найдено у источника; false — недоступно (поля пусты).</param>
/// <param name="Text">Текст исходного сообщения, если найдено.</param>
/// <param name="TimeMs">Время сообщения, epoch-ms; null — источник не отдал время.</param>
public sealed record TelegramSourceContentDto(bool Found, string? Text, long? TimeMs);
@@ -278,6 +278,30 @@ public sealed class GrpcTelegramClient : ITelegramGateway
} }
} }
/// <inheritdoc />
public async Task<TelegramSourceContentDto> 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");
}
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync( public async Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query, string query,
@@ -67,6 +67,13 @@ public sealed class LocalTelegramGateway : ITelegramGateway
CancellationToken ct) CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]); => Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc />
public Task<TelegramSourceContentDto> ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
=> Task.FromResult(new TelegramSourceContentDto(false, null, null));
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync( public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query, string query,
@@ -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;
/// <summary>
/// Провайдер содержимого исходника источника telegram: запрос к telegram-service по id сообщения.
/// </summary>
/// <param name="gateway">Порт-гейт к telegram-service.</param>
public sealed class TelegramSourceContentProvider(ITelegramGateway gateway) : ISourceContentProvider
{
private const string TelegramKind = "telegram";
/// <inheritdoc />
public string Kind => TelegramKind;
/// <inheritdoc />
public async Task<SourceContent?> 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;
}
}
@@ -3,12 +3,14 @@ using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Infrastructure.Integrations.Sources;
using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories; using Deal.Infrastructure.Persistence.Repositories;
using Deal.Infrastructure.Security; using Deal.Infrastructure.Security;
using Deal.Infrastructure.Services; using Deal.Infrastructure.Services;
using Deal.Infrastructure.Tenancy; using Deal.Infrastructure.Tenancy;
using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Discovery.Application.Abstractions; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions;
@@ -56,6 +58,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<ICardStore, KanbanStore>(); services.AddScoped<ICardStore, KanbanStore>();
services.AddScoped<ISourceContentProvider, TelegramSourceContentProvider>();
services.AddScoped<IPipelineStore, PipelineStore>(); services.AddScoped<IPipelineStore, PipelineStore>();
services.AddScoped<IMlLearningStore, MlLearningStore>(); services.AddScoped<IMlLearningStore, MlLearningStore>();
@@ -130,6 +130,13 @@ public sealed class FakeDiscoveryGateway : ITelegramGateway
CancellationToken ct) CancellationToken ct)
=> throw new NotSupportedException(); => throw new NotSupportedException();
/// <inheritdoc />
public Task<TelegramSourceContentDto> ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync( public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query, string query,
@@ -29,6 +29,16 @@ public sealed class FakeTelegramGateway : ITelegramGateway
/// </summary> /// </summary>
public List<(string DialogId, bool Force)> BackfillCalls { get; } = []; public List<(string DialogId, bool Force)> BackfillCalls { get; } = [];
/// <summary>
/// Вызовы ReadSource
/// </summary>
public List<(string DialogId, long MsgId)> ReadSourceCalls { get; } = [];
/// <summary>
/// Результат ReadSourceAsync
/// </summary>
public TelegramSourceContentDto SourceContent { get; set; } = new(false, null, null);
/// <summary> /// <summary>
/// Сколько сообщений «разобрал» Backfill /// Сколько сообщений «разобрал» Backfill
/// </summary> /// </summary>
@@ -143,6 +153,16 @@ public sealed class FakeTelegramGateway : ITelegramGateway
CancellationToken ct) CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]); => Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc />
public Task<TelegramSourceContentDto> ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
{
ReadSourceCalls.Add((dialogId, msgId));
return Task.FromResult(SourceContent);
}
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync( public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query, string query,
@@ -174,6 +174,52 @@ public sealed class GrpcTelegramClientTests
}); });
} }
/// <summary>
/// ReadSource уходит с dialog_id/msg_id и маппит текст/время
/// </summary>
[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);
});
}
/// <summary>
/// ReadSource с found=false не отдаёт текст и время
/// </summary>
[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);
});
}
/// <summary> /// <summary>
/// Вызов вне tenant-контекста — ошибка конфигурации. /// Вызов вне tenant-контекста — ошибка конфигурации.
/// </summary> /// </summary>
@@ -20,6 +20,7 @@ public sealed class TelegramGatewayPortTests
"SetMonitorAllAsync", "SetMonitorAllAsync",
"BackfillAsync", "BackfillAsync",
"ReadRecentAsync", "ReadRecentAsync",
"ReadSourceAsync",
"SearchAsync", "SearchAsync",
"InfoAsync", "InfoAsync",
"ReadForEvalAsync", "ReadForEvalAsync",
@@ -28,6 +28,26 @@ public sealed class RecordingTelegramService : TelegramService.TelegramServiceBa
/// </summary> /// </summary>
public List<ReadRecentRequest> ReadRecentRequests { get; } = []; public List<ReadRecentRequest> ReadRecentRequests { get; } = [];
/// <summary>
/// Запросы ReadSource
/// </summary>
public List<ReadSourceRequest> ReadSourceRequests { get; } = [];
/// <summary>
/// found ответа ReadSource
/// </summary>
public bool ReadSourceFound { get; set; }
/// <summary>
/// text ответа ReadSource (null — поле не задано)
/// </summary>
public string? ReadSourceText { get; set; }
/// <summary>
/// time ответа ReadSource (null — поле не задано)
/// </summary>
public long? ReadSourceTime { get; set; }
/// <summary> /// <summary>
/// Каталог ответа RefreshDialogs /// Каталог ответа RefreshDialogs
/// </summary> /// </summary>
@@ -146,6 +166,25 @@ public sealed class RecordingTelegramService : TelegramService.TelegramServiceBa
return Task.FromResult(reply); return Task.FromResult(reply);
} }
/// <inheritdoc />
public override Task<ReadSourceReply> 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. // Бросает доменный сбой сценария (если задан) — как RPC-ошибка telegram-service.
// context: Контекст вызова (не используется, но держит сигнатуру единообразной). // context: Контекст вызова (не используется, но держит сигнатуру единообразной).
private void ThrowIfDomainFailure(ServerCallContext context) private void ThrowIfDomainFailure(ServerCallContext context)
@@ -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;
/// <summary>
/// Тесты провайдера содержимого исходника telegram — TelegramSourceContentProvider.
/// </summary>
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);
}
}
@@ -24,6 +24,7 @@ import {
takeCard, takeCard,
addCardFiles, addCardFiles,
removeCardFile, removeCardFile,
loadCardSource,
toast, toast,
fmtDateTime, fmtDateTime,
} from '../../store.js' } from '../../store.js'
@@ -91,16 +92,38 @@ function contactHref(c) {
return contactInfo(c?.value)?.href || null return contactInfo(c?.value)?.href || null
} }
const sourceUrl = computed(() => sourceOriginUrl(card.value)) const sourceUrl = computed(() => sourceOriginUrl(card.value))
// есть что показать в блоке исходника (текст/ссылки/контакты/вложения) // remote-исходник, догруженный кнопкой «Обновить из источника»; пока null —
const hasSource = computed(() => { // показываем содержимое, сохранённое в карточке (card.content).
const c = card.value?.content const sourceOverride = ref(null)
const sourceLoading = ref(false)
function sourceHasContent(c) {
if (!c) return false if (!c) return false
return !!(c.text || c.links?.length || c.contacts?.length || c.data?.length) 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 sourceSummary = computed(() => {
const name = card.value?.source?.displayName || t('drawer.kanal') const name = card.value?.source?.displayName || t('drawer.kanal')
return `${name} · ${card.value?.time || ''} ${t('drawer.pokazat-tekst')}`.trim() 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()) const historyReversed = computed(() => [...(card.value?.history || [])].reverse())
@@ -159,6 +182,14 @@ watch(
{ immediate: true }, { immediate: true },
) )
// смена карточки — сбрасываем догруженный исходник обратно на card.content
watch(
() => state.drawerId,
() => {
sourceOverride.value = null
},
)
function onComment(text) { function onComment(text) {
if (card.value) addComment(card.value.id, text) if (card.value) addComment(card.value.id, text)
} }
@@ -530,8 +561,19 @@ async function copyProjectContact() {
<Icon name="external" :size="13" /> <Icon name="external" :size="13" />
{{ $t('drawer.otkryt-ishodnik') }} {{ $t('drawer.otkryt-ishodnik') }}
</a> </a>
<button
type="button"
class="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-white/5 text-mid border border-white/10 text-[12px] font-medium hover:bg-white/10 hover:text-hi transition-colors disabled:opacity-55 disabled:cursor-not-allowed"
:class="sourceUrl ? '' : 'ml-auto'"
:disabled="sourceLoading"
:title="$t('drawer.obnovit-iz-istochnika')"
@click="refreshSource"
>
<Icon name="refresh" :size="13" :class="sourceLoading ? 'animate-spin' : ''" />
{{ $t('drawer.obnovit-iz-istochnika') }}
</button>
</div> </div>
<SourceContentView :content="card.content" :summary="sourceSummary" /> <SourceContentView :content="sourceContent" :summary="sourceSummary" />
</div> </div>
<!-- ссылки (только «Выбранные») --> <!-- ссылки (только «Выбранные») -->
+2
View File
@@ -1048,6 +1048,8 @@ export const ru = {
"ishodnoe-soobshchenie": "Исходное сообщение", "ishodnoe-soobshchenie": "Исходное сообщение",
"otkryt-ishodnoe-soobshchenie": "Открыть исходное сообщение", "otkryt-ishodnoe-soobshchenie": "Открыть исходное сообщение",
"otkryt-ishodnik": "Открыть исходник", "otkryt-ishodnik": "Открыть исходник",
"obnovit-iz-istochnika": "Обновить из источника",
"istochnik-nedostupen": "Исходник недоступен",
"pokazat-tekst": "— показать текст", "pokazat-tekst": "— показать текст",
"udalit-ssylku": "Удалить ссылку", "udalit-ssylku": "Удалить ссылку",
"nazvanie-napr-maket": "Название (напр. макет)", "nazvanie-napr-maket": "Название (напр. макет)",
+13
View File
@@ -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) { export async function addCardLink(cardId, name, url) {
// разрешаем только http(s): javascript:/data: и прочие схемы не открываем // разрешаем только http(s): javascript:/data: и прочие схемы не открываем
const u = String(url || '').trim() const u = String(url || '').trim()
@@ -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;
/// <summary>
/// RPC-тесты remote-просмотра исходного сообщения (ReadSource).
/// </summary>
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;
/// <summary>
/// ReadSource найденного сообщения: текст и время
/// </summary>
[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);
});
}
/// <summary>
/// ReadSource ненайденного сообщения: found=false, поля пусты
/// </summary>
[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);
});
}
/// <summary>
/// ReadSource неположительного msg_id → INVALID_ARGUMENT
/// </summary>
[Fact]
public async Task ReadSource_InvalidMsgId_InvalidArgument()
{
await RunScenarioAsync(
async (channel, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => client.ReadSourceAsync(
new ReadSourceRequest { DialogId = DialogA, MsgId = 0 }, Options()).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(Sessions.SessionErrorMessages.InvalidMessageId, exception.Status.Detail);
});
}
/// <summary>
/// ReadSource без готовой сессии → FAILED_PRECONDITION «Telegram не подключён»
/// </summary>
[Fact]
public async Task ReadSource_NoSession_FailedPrecondition()
{
await RunScenarioAsync(
async (channel, _) =>
{
var client = new TelegramService.TelegramServiceClient(channel);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => 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<GrpcChannel, FakeClientFactory, Task> scenario)
{
var factory = new FakeClientFactory();
await TelegramTestHost.RunAsync(
TelegramTestHost.DefaultToken,
channel => scenario(channel, factory),
configureServices: services => services.AddSingleton<ITelegramClientFactory>(factory));
}
// Опции вызова с токеном и tenant-id.
private static CallOptions Options()
=> new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, TenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds));
}
@@ -259,6 +259,16 @@ internal sealed class FakeSessionClient : ISessionClient
/// </summary> /// </summary>
public Exception? MessagesError { get; set; } public Exception? MessagesError { get; set; }
/// <summary>
/// Id сообщений, запрошенных через GetMessageAsync.
/// </summary>
public List<(string DialogId, long MsgId)> MessageRequests { get; } = [];
/// <summary>
/// Исключение GetMessageAsync
/// </summary>
public Exception? MessageError { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<TelegramDialog>> GetDialogsAsync(int limit, CancellationToken cancellationToken) public Task<IReadOnlyList<TelegramDialog>> GetDialogsAsync(int limit, CancellationToken cancellationToken)
{ {
@@ -289,6 +299,24 @@ internal sealed class FakeSessionClient : ISessionClient
: []); : []);
} }
/// <inheritdoc />
public Task<TelegramMessage?> 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<TelegramMessage>? messages)
? messages.FirstOrDefault(item => item.Id == msgId)
: null;
return Task.FromResult(message);
}
/// <inheritdoc /> /// <inheritdoc />
public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken)
{ {
@@ -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<long, ChatBase>(), new Dictionary<long, User>())); Assert.Null(TlMessageMapper.ToMessage(new MessageService { id = 45, peer_id = new PeerChannel { channel_id = ChannelId } }, new Dictionary<long, ChatBase>(), new Dictionary<long, User>()));
} }
/// <summary>
/// Медиа-сообщение без текста (подпись пуста) не отдаётся.
/// </summary>
[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<long, ChatBase>(), new Dictionary<long, User>()));
}
/// <summary> /// <summary>
/// Сообщение личного чата /// Сообщение личного чата
/// </summary> /// </summary>
@@ -92,6 +92,11 @@ public static class SessionErrorMessages
/// </summary> /// </summary>
public const string InvalidDialogId = "Некорректный id источника"; public const string InvalidDialogId = "Некорректный id источника";
/// <summary>
/// Неположительный id сообщения в запросе
/// </summary>
public const string InvalidMessageId = "Некорректный id сообщения";
/// <summary> /// <summary>
/// Телефон не зарегистрирован в Telegram /// Телефон не зарегистрирован в Telegram
/// </summary> /// </summary>
@@ -73,6 +73,20 @@ public sealed class SessionFarm
CancellationToken cancellationToken) CancellationToken cancellationToken)
=> RequireSession(tenantId).GetMessagesAsync(dialogId, limit, cancellationToken); => RequireSession(tenantId).GetMessagesAsync(dialogId, limit, cancellationToken);
/// <summary>
/// Исходное сообщение диалога тенанта по id.
/// </summary>
/// <param name="tenantId">Id тенанта.</param>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="msgId">Id сообщения в Telegram.</param>
/// <param name="cancellationToken">Отмена операции.</param>
public Task<TelegramMessage?> GetMessageAsync(
string tenantId,
string dialogId,
long msgId,
CancellationToken cancellationToken)
=> RequireSession(tenantId).GetMessageAsync(dialogId, msgId, cancellationToken);
/// <summary> /// <summary>
/// Помечает диалог тенанта прочитанным. /// Помечает диалог тенанта прочитанным.
/// </summary> /// </summary>
@@ -416,6 +416,30 @@ public sealed class TenantSession : IAsyncDisposable
} }
} }
/// <summary>
/// Исходное сообщение диалога по id (remote-просмотр).
/// </summary>
/// <param name="dialogId">Подписанный id диалога.</param>
/// <param name="msgId">Id сообщения в Telegram.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Сообщение с текстом либо null (медиа без текста/не найдено).</returns>
public async Task<TelegramMessage?> 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();
}
}
/// <summary> /// <summary>
/// Помечает диалог прочитанным /// Помечает диалог прочитанным
/// </summary> /// </summary>
@@ -98,6 +98,18 @@ public interface ISessionClient : IAsyncDisposable
int limit, int limit,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Возвращает исходное сообщение диалога по id (remote-просмотр карточки).
/// </summary>
/// <param name="dialogId">Подписанный id диалога (каналы "-100…", группы "-…", личные "+…").</param>
/// <param name="msgId">Id сообщения в Telegram.</param>
/// <param name="cancellationToken">Отмена операции.</param>
/// <returns>Сообщение с текстом либо null (медиа без текста/не найдено).</returns>
public Task<TelegramMessage?> GetMessageAsync(
string dialogId,
long msgId,
CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Помечает весь диалог прочитанным. /// Помечает весь диалог прочитанным.
/// </summary> /// </summary>
@@ -53,6 +53,7 @@ public static class TlMessageMapper
{ {
if (message is not Message textMessage if (message is not Message textMessage
|| string.IsNullOrWhiteSpace(textMessage.message) || string.IsNullOrWhiteSpace(textMessage.message)
|| textMessage.media is not null
|| (textMessage.flags & Message.Flags.out_) != 0 || (textMessage.flags & Message.Flags.out_) != 0
|| textMessage.Peer is null) || textMessage.Peer is null)
{ {
@@ -264,6 +264,36 @@ public sealed class WTelegramSessionClient : ISessionClient
return items; return items;
} }
/// <inheritdoc />
public async Task<TelegramMessage?> 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<long, ChatBase> chats, Dictionary<long, User> 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;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) public async Task MarkReadAsync(string dialogId, CancellationToken cancellationToken)
{ {
@@ -291,6 +291,44 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase
} }
/// <summary>
/// ReadSource — исходное сообщение источника по id (remote-просмотр).
/// </summary>
public override async Task<ReadSourceReply> 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;
}
/// <summary> /// <summary>
/// Search — глобальный поиск каналов/групп по ключу. /// Search — глобальный поиск каналов/групп по ключу.
/// </summary> /// </summary>