Добавить 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-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
|
||||
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | P3 | TECHDEBT |
|
||||
| TD-SOURCE-PROVIDER | `ISourceContentProvider`/`SourceContentResolver` и `GET /api/cards/{id}/source` добавлены; осталось — реализовать провайдеры источников (telegram/local/file) с ленивой догрузкой | generic source 2026-09-11 | P1 | BACKLOG |
|
||||
| TD-SOURCE-PROVIDER | Провайдеры содержимого источников. **Сделано (2026-09-11):** `TelegramSourceContentProvider` + `ReadSource` RPC + `GET /api/cards/{id}/source` + UI «Обновить из источника». Осталось: провайдеры прочих источников по мере появления | generic source 2026-09-11 | P2 | TECHDEBT |
|
||||
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером. **Решение (2026-09-11): медиа-посты Telegram пропускаем** — извлечение/выгрузка не делаются. Остались на будущее: вложения прочих источников (файл/диск/таблица) и `ISourceContentProvider` для remote-просмотра | generic source 2026-09-11 | P3 | BACKLOG |
|
||||
| TD-TG-CORE-SPLIT | Перенести оставшуюся Telegram-специфику ядра (`TelegramStore`, таблицы `Dialogs`/`TgMessages`, Discovery) в telegram-сервис. **Сделано (2026-09-11):** входящий поток переведён на generic `sources.proto`/`PushSource`, `PushMessage` удалён, приём в ядре generic (`SourceIngressGrpcService`). Осталось: каталог (`SyncDialogs`/`ReportStatus`), `TelegramStore`, Discovery | generic source 2026-09-11 | P2 | TECHDEBT |
|
||||
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG |
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
> пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и
|
||||
> `GET /api/cards/{id}/source`. Входящий поток источников — generic (`sources.proto`/`PushSource`,
|
||||
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
|
||||
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Ядро: build 5 sln 0/0,
|
||||
> `Deal.Tests.Unit` **1298/1298 PASS**, telegram **125/125**, фронт `build` + `lint:i18n` зелёные.
|
||||
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
||||
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
|
||||
> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
|
||||
> Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1307/1307 PASS**, telegram **130/130**, фронт `build` +
|
||||
> `lint:i18n` зелёные. Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
||||
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
||||
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
||||
> (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис.
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
нести `DataRef` — этим смогут пользоваться другие источники (файл/диск/таблица) и ручные вложения карточки.
|
||||
- **Б) Проверка без живого Telegram** — реализуем с юнит-тестами на фейковой сессии/фейковом Storage,
|
||||
без реального API.
|
||||
- **В)** Объяснение термина — в §1.5.
|
||||
- **В)** Объяснение термина — в §1.5. **Решение: делаем.** Реализован remote-просмотр: `TelegramService.ReadSource`,
|
||||
`ITelegramGateway.ReadSourceAsync`, `TelegramSourceContentProvider` (Kind=telegram) в ядре,
|
||||
`GET /api/cards/{id}/source` и кнопка «Обновить из источника» в подробной карточке.
|
||||
|
||||
## 1.5. Что такое «remote-просмотр исходника»
|
||||
|
||||
|
||||
@@ -181,3 +181,13 @@ API ядра: `GET /api/cards/{id}/source` → generic контент.
|
||||
telegram-сервис шлёт записи через `PushSource` (`kind="telegram"`). Превью каталога/TgMessages сохраняет
|
||||
`TelegramSourceIngestObserver` (ядро, telegram-модуль — единственное место с telegram-спецификой приёма).
|
||||
- Любой другой источник (whatsapp/avito/файл/excel) шлёт тот же `PushSource` со своим `source.kind`.
|
||||
|
||||
### Remote-просмотр исходника (2026-09-11)
|
||||
|
||||
- `TelegramService.ReadSource(ReadSourceRequest{dialog_id, msg_id})` → `ReadSourceReply{found, text?, time?}`
|
||||
(`src/contracts/telegram.proto`); telegram-сервис достаёт конкретное сообщение
|
||||
(`ISessionClient.GetMessageAsync` → TL `Messages_GetMessages`). Медиа без текста → `found=false`.
|
||||
- Ядро: `ITelegramGateway.ReadSourceAsync` + `TelegramSourceContentProvider` (`ISourceContentProvider`,
|
||||
`Kind="telegram"`, `Deal.Infrastructure/Integrations/Sources`) — резолвится `SourceContentResolver`.
|
||||
- `GET /api/cards/{id}/source` отдаёт результат провайдера либо сохранённое содержимое карточки.
|
||||
Фронт: кнопка «Обновить из источника» в подробной карточке (`CardDrawer.vue` → `loadCardSource`).
|
||||
|
||||
@@ -76,6 +76,10 @@ service TelegramService {
|
||||
// свежие из Telegram; признак lead и фолбэк на БД добавляет ядро
|
||||
rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply);
|
||||
|
||||
// Исходное сообщение источника по id (remote-просмотр исходника карточки).
|
||||
// found=false — сообщение не найдено/удалено (не ошибка RPC).
|
||||
rpc ReadSource(ReadSourceRequest) returns (ReadSourceReply);
|
||||
|
||||
// Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро
|
||||
rpc Search(SearchRequest) returns (SearchReply);
|
||||
|
||||
@@ -235,6 +239,22 @@ message ReadRecentReply {
|
||||
repeated PreviewMessage messages = 1;
|
||||
}
|
||||
|
||||
message ReadSourceRequest {
|
||||
// Id диалога-источника.
|
||||
string dialog_id = 1;
|
||||
// Id исходного сообщения в Telegram.
|
||||
int64 msg_id = 2;
|
||||
}
|
||||
|
||||
message ReadSourceReply {
|
||||
// True — сообщение найдено и передано; false — нет (поля пусты).
|
||||
bool found = 1;
|
||||
// Текст исходного сообщения.
|
||||
optional string text = 2;
|
||||
// Время сообщения, epoch-ms.
|
||||
optional int64 time = 3;
|
||||
}
|
||||
|
||||
message PreviewMessage {
|
||||
// Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки
|
||||
// "m_<dialog>_<msg>", поэтому значение передаётся строкой.
|
||||
|
||||
@@ -103,6 +103,17 @@ public interface ITelegramGateway
|
||||
int limit,
|
||||
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>
|
||||
|
||||
@@ -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 />
|
||||
public async Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
|
||||
string query,
|
||||
|
||||
@@ -67,6 +67,13 @@ public sealed class LocalTelegramGateway : ITelegramGateway
|
||||
CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TelegramSourceContentDto> ReadSourceAsync(
|
||||
string dialogId,
|
||||
long msgId,
|
||||
CancellationToken ct)
|
||||
=> Task.FromResult(new TelegramSourceContentDto(false, null, null));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
|
||||
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.Options;
|
||||
using Deal.Infrastructure.Integrations.Services;
|
||||
using Deal.Infrastructure.Integrations.Sources;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Deal.Infrastructure.Persistence.Repositories;
|
||||
using Deal.Infrastructure.Security;
|
||||
using Deal.Infrastructure.Services;
|
||||
using Deal.Infrastructure.Tenancy;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
@@ -56,6 +58,8 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddScoped<ICardStore, KanbanStore>();
|
||||
|
||||
services.AddScoped<ISourceContentProvider, TelegramSourceContentProvider>();
|
||||
|
||||
services.AddScoped<IPipelineStore, PipelineStore>();
|
||||
|
||||
services.AddScoped<IMlLearningStore, MlLearningStore>();
|
||||
|
||||
@@ -130,6 +130,13 @@ public sealed class FakeDiscoveryGateway : ITelegramGateway
|
||||
CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TelegramSourceContentDto> ReadSourceAsync(
|
||||
string dialogId,
|
||||
long msgId,
|
||||
CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
|
||||
string query,
|
||||
|
||||
@@ -29,6 +29,16 @@ public sealed class FakeTelegramGateway : ITelegramGateway
|
||||
/// </summary>
|
||||
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>
|
||||
/// Сколько сообщений «разобрал» Backfill
|
||||
/// </summary>
|
||||
@@ -143,6 +153,16 @@ public sealed class FakeTelegramGateway : ITelegramGateway
|
||||
CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TelegramSourceContentDto> ReadSourceAsync(
|
||||
string dialogId,
|
||||
long msgId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ReadSourceCalls.Add((dialogId, msgId));
|
||||
return Task.FromResult(SourceContent);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
|
||||
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>
|
||||
/// Вызов вне tenant-контекста — ошибка конфигурации.
|
||||
/// </summary>
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class TelegramGatewayPortTests
|
||||
"SetMonitorAllAsync",
|
||||
"BackfillAsync",
|
||||
"ReadRecentAsync",
|
||||
"ReadSourceAsync",
|
||||
"SearchAsync",
|
||||
"InfoAsync",
|
||||
"ReadForEvalAsync",
|
||||
|
||||
@@ -28,6 +28,26 @@ public sealed class RecordingTelegramService : TelegramService.TelegramServiceBa
|
||||
/// </summary>
|
||||
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>
|
||||
/// Каталог ответа RefreshDialogs
|
||||
/// </summary>
|
||||
@@ -146,6 +166,25 @@ public sealed class RecordingTelegramService : TelegramService.TelegramServiceBa
|
||||
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.
|
||||
// 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,
|
||||
addCardFiles,
|
||||
removeCardFile,
|
||||
loadCardSource,
|
||||
toast,
|
||||
fmtDateTime,
|
||||
} from '../../store.js'
|
||||
@@ -91,16 +92,38 @@ function contactHref(c) {
|
||||
return contactInfo(c?.value)?.href || null
|
||||
}
|
||||
const sourceUrl = computed(() => sourceOriginUrl(card.value))
|
||||
// есть что показать в блоке исходника (текст/ссылки/контакты/вложения)
|
||||
const hasSource = computed(() => {
|
||||
const c = card.value?.content
|
||||
// remote-исходник, догруженный кнопкой «Обновить из источника»; пока null —
|
||||
// показываем содержимое, сохранённое в карточке (card.content).
|
||||
const sourceOverride = ref(null)
|
||||
const sourceLoading = ref(false)
|
||||
function sourceHasContent(c) {
|
||||
if (!c) return false
|
||||
return !!(c.text || c.links?.length || c.contacts?.length || c.data?.length)
|
||||
})
|
||||
}
|
||||
const sourceContent = computed(() => sourceOverride.value || card.value?.content || null)
|
||||
// есть что показать в блоке исходника (текст/ссылки/контакты/вложения)
|
||||
const hasSource = computed(() => sourceHasContent(sourceContent.value))
|
||||
const sourceSummary = computed(() => {
|
||||
const name = card.value?.source?.displayName || t('drawer.kanal')
|
||||
return `${name} · ${card.value?.time || ''} ${t('drawer.pokazat-tekst')}`.trim()
|
||||
})
|
||||
// Клик не дублирует запросы: пока идёт загрузка, повторный вызов игнорируется,
|
||||
// кнопка disabled. Пустой ответ — тост, сохранённое содержимое остаётся.
|
||||
async function refreshSource() {
|
||||
const id = card.value?.id
|
||||
if (!id || sourceLoading.value) return
|
||||
sourceLoading.value = true
|
||||
try {
|
||||
const remote = await loadCardSource(id)
|
||||
if (sourceHasContent(remote)) {
|
||||
sourceOverride.value = remote
|
||||
} else {
|
||||
toast(t('drawer.istochnik-nedostupen'), { icon: 'x' })
|
||||
}
|
||||
} finally {
|
||||
sourceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── «Выбранные»: редактирование ─────────────────────────────────────────
|
||||
const historyReversed = computed(() => [...(card.value?.history || [])].reverse())
|
||||
@@ -159,6 +182,14 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// смена карточки — сбрасываем догруженный исходник обратно на card.content
|
||||
watch(
|
||||
() => state.drawerId,
|
||||
() => {
|
||||
sourceOverride.value = null
|
||||
},
|
||||
)
|
||||
|
||||
function onComment(text) {
|
||||
if (card.value) addComment(card.value.id, text)
|
||||
}
|
||||
@@ -530,8 +561,19 @@ async function copyProjectContact() {
|
||||
<Icon name="external" :size="13" />
|
||||
{{ $t('drawer.otkryt-ishodnik') }}
|
||||
</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>
|
||||
<SourceContentView :content="card.content" :summary="sourceSummary" />
|
||||
<SourceContentView :content="sourceContent" :summary="sourceSummary" />
|
||||
</div>
|
||||
|
||||
<!-- ссылки (только «Выбранные») -->
|
||||
|
||||
@@ -1048,6 +1048,8 @@ export const ru = {
|
||||
"ishodnoe-soobshchenie": "Исходное сообщение",
|
||||
"otkryt-ishodnoe-soobshchenie": "Открыть исходное сообщение",
|
||||
"otkryt-ishodnik": "Открыть исходник",
|
||||
"obnovit-iz-istochnika": "Обновить из источника",
|
||||
"istochnik-nedostupen": "Исходник недоступен",
|
||||
"pokazat-tekst": "— показать текст",
|
||||
"udalit-ssylku": "Удалить ссылку",
|
||||
"nazvanie-napr-maket": "Название (напр. макет)",
|
||||
|
||||
@@ -402,6 +402,19 @@ export async function patchCard(cardId, patch) {
|
||||
}
|
||||
}
|
||||
|
||||
// Догрузка исходника у сервиса-владельца (remote-просмотр). Сервер сам решает,
|
||||
// тянуть исходное сообщение у провайдера (telegram и т.п.) или отдать содержимое,
|
||||
// сохранённое в карточке. Ошибку не пробрасываем: UI показывает «источник
|
||||
// недоступен» и оставляет сохранённое содержимое.
|
||||
export async function loadCardSource(cardId) {
|
||||
if (!cardId) return null
|
||||
try {
|
||||
return await api.get(`/api/cards/${cardId}/source`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCardLink(cardId, name, url) {
|
||||
// разрешаем только http(s): javascript:/data: и прочие схемы не открываем
|
||||
const u = String(url || '').trim()
|
||||
|
||||
@@ -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>
|
||||
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 />
|
||||
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 />
|
||||
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>()));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -92,6 +92,11 @@ public static class SessionErrorMessages
|
||||
/// </summary>
|
||||
public const string InvalidDialogId = "Некорректный id источника";
|
||||
|
||||
/// <summary>
|
||||
/// Неположительный id сообщения в запросе
|
||||
/// </summary>
|
||||
public const string InvalidMessageId = "Некорректный id сообщения";
|
||||
|
||||
/// <summary>
|
||||
/// Телефон не зарегистрирован в Telegram
|
||||
/// </summary>
|
||||
|
||||
@@ -73,6 +73,20 @@ public sealed class SessionFarm
|
||||
CancellationToken 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -98,6 +98,18 @@ public interface ISessionClient : IAsyncDisposable
|
||||
int limit,
|
||||
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>
|
||||
|
||||
@@ -53,6 +53,7 @@ public static class TlMessageMapper
|
||||
{
|
||||
if (message is not Message textMessage
|
||||
|| string.IsNullOrWhiteSpace(textMessage.message)
|
||||
|| textMessage.media is not null
|
||||
|| (textMessage.flags & Message.Flags.out_) != 0
|
||||
|| textMessage.Peer is null)
|
||||
{
|
||||
|
||||
@@ -264,6 +264,36 @@ public sealed class WTelegramSessionClient : ISessionClient
|
||||
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 />
|
||||
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>
|
||||
/// Search — глобальный поиск каналов/групп по ключу.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user