Добавить remote-просмотр исходника карточки
RPC TelegramService.ReadSource + ISessionClient.GetMessageAsync, порт ITelegramGateway.ReadSourceAsync и провайдер TelegramSourceContentProvider в ядре; GET /api/cards/{id}/source догружает исходник у сервиса-владельца, в UI — кнопка «Обновить из источника». Медиа-посты пропускаются.
This commit is contained in:
+1
-1
@@ -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 |
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<!-- ссылки (только «Выбранные») -->
|
<!-- ссылки (только «Выбранные») -->
|
||||||
|
|||||||
@@ -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": "Название (напр. макет)",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user