diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs index b55fa87..a75d12f 100644 --- a/src/food-market.api/Background/HangfireJobsConfigurator.cs +++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs @@ -59,6 +59,15 @@ public Task StartAsync(CancellationToken ct) cronExpression: cronLowStock, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + // Telegram-сводка владельцу — 09:00 МСК = 06:00 UTC. По вкусу + // переопределяется через Hangfire:Cron:TelegramOwnerDailySummary. + var cronTgDaily = _cfg["Hangfire:Cron:TelegramOwnerDailySummary"] ?? "0 6 * * *"; + _jobs.AddOrUpdate( + recurringJobId: "telegram-owner-daily-summary", + methodCall: j => j.RunAsync(CancellationToken.None), + cronExpression: cronTgDaily, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + return Task.CompletedTask; } diff --git a/src/food-market.api/Background/OwnerDailySummaryJob.cs b/src/food-market.api/Background/OwnerDailySummaryJob.cs new file mode 100644 index 0000000..b41b2be --- /dev/null +++ b/src/food-market.api/Background/OwnerDailySummaryJob.cs @@ -0,0 +1,138 @@ +using System.Net; +using foodmarket.Api.Integrations.Telegram; +using foodmarket.Domain.Inventory; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Background; + +/// Раз в день в 09:00 по МСК шлёт владельцу каждой org Telegram-сводку: +/// выручка вчера, количество чеков, топ-3 товаров (qty*price), low-stock-список. +/// +/// Регистрация — в HangfireJobsConfigurator (cron 0 6 * * *; в UTC это +/// 09:00 МСК, потому что MSK = UTC+3). +/// +/// На org без привязанного OwnerTelegramChatId или с null AccountOwnerUserId +/// пропускаем без логов (нет адресата). +public sealed class OwnerDailySummaryJob +{ + private readonly AppDbContext _db; + private readonly ITelegramBotClient _tg; + private readonly ILogger _log; + + public OwnerDailySummaryJob(AppDbContext db, ITelegramBotClient tg, ILogger log) + { + _db = db; + _tg = tg; + _log = log; + } + + /// Hangfire-entry. Пробегает по всем org с OwnerTelegramChatId != + /// null, рендерит сводку и шлёт. Best-effort на каждой org — одна неудача + /// не валит остальных. + public async Task RunAsync(CancellationToken ct) + { + if (!_tg.IsEnabled) + { + _log.LogInformation("Telegram bot disabled (no token) — skipping daily summary"); + return; + } + var orgs = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.OwnerTelegramChatId != null && !o.IsArchived && o.IsActive) + .Select(o => new { o.Id, o.Name, ChatId = o.OwnerTelegramChatId!.Value }) + .ToListAsync(ct); + foreach (var o in orgs) + { + try + { + var text = await RenderSummaryAsync(o.Id, o.Name, ct); + await _tg.SendMessageAsync(o.ChatId, text, ct); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Daily summary failed for org={OrgId}", o.Id); + } + } + } + + /// Чистый рендерер — exposед публично для тестов. Берёт «вчерашний» + /// день в UTC (UTC простоты ради; для МСК-таймзоны 09:00 рассылка → вчера + /// UTC примерно совпадает со вчера МСК с поправкой 3ч, для бизнес-сводки + /// этого достаточно). + public async Task RenderSummaryAsync(Guid orgId, string orgName, CancellationToken ct) + { + var todayStart = DateTime.UtcNow.Date; + var yStart = todayStart.AddDays(-1); + var yEnd = todayStart; + + // Розничные продажи вчера, проведённые. + var ySales = _db.RetailSales.IgnoreQueryFilters() + .Where(s => s.OrganizationId == orgId + && s.Status == RetailSaleStatus.Posted + && !s.IsReturn + && s.Date >= yStart && s.Date < yEnd); + var revenue = await ySales.SumAsync(s => (decimal?)s.Total, ct) ?? 0m; + var count = await ySales.CountAsync(ct); + + // Топ-3 товара по выручке вчера. + var top3 = await (from sl in _db.Set().IgnoreQueryFilters() + join s in _db.RetailSales.IgnoreQueryFilters() on sl.RetailSaleId equals s.Id + join p in _db.Products.IgnoreQueryFilters() on sl.ProductId equals p.Id + where s.OrganizationId == orgId + && s.Status == RetailSaleStatus.Posted + && !s.IsReturn + && s.Date >= yStart && s.Date < yEnd + group new { sl, p } by new { sl.ProductId, p.Name } into g + orderby g.Sum(x => x.sl.LineTotal) descending + select new + { + g.Key.Name, + Total = g.Sum(x => x.sl.LineTotal), + Qty = g.Sum(x => x.sl.Quantity), + }) + .Take(3).ToListAsync(ct); + + // Low-stock: продукты у которых остаток на ЛЮБОМ складе ниже MinStock. + var low = await (from p in _db.Products.IgnoreQueryFilters() + join s in _db.Stocks.IgnoreQueryFilters() on p.Id equals s.ProductId + join st in _db.Stores.IgnoreQueryFilters() on s.StoreId equals st.Id + where p.OrganizationId == orgId + && p.MinStock != null + && s.Quantity < p.MinStock + orderby s.Quantity ascending + select new { p.Name, StoreName = st.Name, s.Quantity, p.MinStock }) + .Take(5).ToListAsync(ct); + + // HTML-кодирование чтобы спецсимволы в названиях не сломали parse_mode=HTML. + string Esc(string s) => WebUtility.HtmlEncode(s); + string Money(decimal d) => $"{d:N0} ₸"; + + var sb = new System.Text.StringBuilder(); + sb.Append($"Food Market — сводка за {yStart:dd.MM}\n"); + sb.Append($"{Esc(orgName)}\n\n"); + sb.Append($"💰 Выручка: {Money(revenue)}\n"); + sb.Append($"🧾 Чеков: {count}\n"); + if (count > 0) + { + sb.Append($"📊 Средний чек: {Money(revenue / count)}\n"); + } + if (top3.Count > 0) + { + sb.Append("\nТоп-3 товара\n"); + for (int i = 0; i < top3.Count; i++) + { + sb.Append($"{i + 1}. {Esc(top3[i].Name)} — {Money(top3[i].Total)} ({top3[i].Qty:N0} шт.)\n"); + } + } + if (low.Count > 0) + { + sb.Append("\n⚠️ Низкие остатки\n"); + foreach (var l in low) + { + sb.Append($"• {Esc(l.Name)} ({Esc(l.StoreName)}) — {l.Quantity:N0} / мин {l.MinStock:N0}\n"); + } + } + return sb.ToString(); + } +} diff --git a/src/food-market.api/Controllers/Telegram/TelegramBindingController.cs b/src/food-market.api/Controllers/Telegram/TelegramBindingController.cs new file mode 100644 index 0000000..d38f9ba --- /dev/null +++ b/src/food-market.api/Controllers/Telegram/TelegramBindingController.cs @@ -0,0 +1,100 @@ +using System.Security.Cryptography; +using foodmarket.Api.Integrations.Telegram; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Telegram; + +/// Привязка Telegram-чата владельца к организации. +/// +/// Flow: +/// 1. UI запрашивает POST /api/organization/telegram/start → сервер +/// генерирует одноразовый код (`tg_token`), кладёт в Telegram-payload +/// с deep-link https://t.me/{bot}?start={code} и orgId-маркером. +/// 2. Юзер открывает ссылку, бот через webhook /api/integrations/telegram/webhook +/// видит /start {code}, мапит code → orgId, отвечает «Связь установлена. +/// Введите код {Z}{Z}{Z}{Z} в админке». +/// Простоты ради реализуем lite-вариант: пользователь сам копирует chat_id +/// из @userinfobot или из ссылки бота и вставляет в UI. Полноценный webhook +/// — в следующей итерации. +/// 3. UI PUT /api/organization/telegram/bind с chatId → бот +/// отправляет подтверждающее сообщение, если 200 — сохраняем chatId в +/// Organization.OwnerTelegramChatId. +/// 4. DELETE /api/organization/telegram — отвязка (NULL чтобы джоб +/// больше не слал). +/// +[ApiController] +[Authorize(Policy = "AdminAccess")] +[Route("api/organization/telegram")] +public class TelegramBindingController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ITelegramBotClient _bot; + private readonly ITenantContext _tenant; + + public TelegramBindingController(AppDbContext db, ITelegramBotClient bot, ITenantContext tenant) + { + _db = db; + _bot = bot; + _tenant = tenant; + } + + public sealed record StatusResp(bool BotEnabled, string? BotUsername, long? ChatId, string? DeepLink); + + [HttpGet("status")] + public async Task> Status(CancellationToken ct) + { + var orgId = _tenant.OrganizationId + ?? throw new InvalidOperationException("No tenant in context"); + var chatId = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId).Select(o => o.OwnerTelegramChatId).FirstOrDefaultAsync(ct); + // Deep-link с маркером org_ — пользователь жмёт «Старт», + // бот видит маркер и пишет ответом chat_id. UI просит юзера ввести + // chat_id руками (см. описание класса). + var deepLink = _bot.IsEnabled && !string.IsNullOrWhiteSpace(_bot.BotUsername) + ? $"https://t.me/{_bot.BotUsername}?start=org_{orgId:N}" + : null; + return Ok(new StatusResp(_bot.IsEnabled, _bot.BotUsername, chatId, deepLink)); + } + + public sealed record BindInput(long ChatId); + + [HttpPut("bind")] + public async Task Bind([FromBody] BindInput input, CancellationToken ct) + { + var orgId = _tenant.OrganizationId + ?? throw new InvalidOperationException("No tenant in context"); + if (input.ChatId == 0) return BadRequest(new { error = "ChatId обязателен и не может быть 0." }); + if (!_bot.IsEnabled) return BadRequest(new { error = "Telegram-бот не настроен на сервере (нет токена)." }); + + // Sanity-check: отправим тестовое сообщение. Если 200 — chat_id валиден. + var orgName = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId).Select(o => o.Name).FirstAsync(ct); + var ok = await _bot.SendMessageAsync(input.ChatId, + $"✅ Привязка к {System.Net.WebUtility.HtmlEncode(orgName)} подтверждена.\n" + + "Ежедневная сводка будет приходить в 09:00 по МСК.", ct); + if (!ok) + { + return BadRequest(new { error = "Не удалось отправить сообщение в Telegram — проверьте ChatId и что бот добавлен в чат." }); + } + + var org = await _db.Organizations.IgnoreQueryFilters().FirstAsync(o => o.Id == orgId, ct); + org.OwnerTelegramChatId = input.ChatId; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete] + public async Task Unbind(CancellationToken ct) + { + var orgId = _tenant.OrganizationId + ?? throw new InvalidOperationException("No tenant in context"); + var org = await _db.Organizations.IgnoreQueryFilters().FirstAsync(o => o.Id == orgId, ct); + org.OwnerTelegramChatId = null; + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/food-market.api/Integrations/Telegram/TelegramBotClient.cs b/src/food-market.api/Integrations/Telegram/TelegramBotClient.cs new file mode 100644 index 0000000..ddfaf78 --- /dev/null +++ b/src/food-market.api/Integrations/Telegram/TelegramBotClient.cs @@ -0,0 +1,78 @@ +using System.Net.Http.Json; +using Microsoft.Extensions.Options; + +namespace foodmarket.Api.Integrations.Telegram; + +/// Минимальный клиент Telegram Bot API — нужен только sendMessage. +/// Token читается из конфига Telegram:BotToken (можно через env +/// Telegram__BotToken). Если токен не задан — клиент тихо отключён, +/// = false; возвращает +/// false. Это позволяет не блокировать запуск сервиса в Dev/CI, где бота нет. +public sealed class TelegramOptions +{ + public string? BotToken { get; set; } + /// Username бота без @ — для построения deep-link + /// `https://t.me/{BotUsername}?start={token}`. + public string? BotUsername { get; set; } +} + +public interface ITelegramBotClient +{ + bool IsEnabled { get; } + string? BotUsername { get; } + /// Возвращает true если успешно отправили. На сетевую ошибку + /// либо неверный ChatId логируем warning и возвращаем false, чтобы + /// caller'у не приходилось ловить исключение. + Task SendMessageAsync(long chatId, string text, CancellationToken ct = default); +} + +public sealed class TelegramBotClient : ITelegramBotClient +{ + private readonly HttpClient _http; + private readonly TelegramOptions _opts; + private readonly ILogger _log; + + public TelegramBotClient(HttpClient http, IOptions opts, ILogger log) + { + _http = http; + _opts = opts.Value; + _log = log; + } + + public bool IsEnabled => !string.IsNullOrWhiteSpace(_opts.BotToken); + public string? BotUsername => _opts.BotUsername; + + public async Task SendMessageAsync(long chatId, string text, CancellationToken ct = default) + { + if (!IsEnabled) + { + _log.LogDebug("Telegram disabled (no token) — skipping sendMessage to {ChatId}", chatId); + return false; + } + try + { + var url = $"https://api.telegram.org/bot{_opts.BotToken}/sendMessage"; + // parse_mode=HTML позволяет жирные/курсив для сводки; text сюда + // должен идти HtmlEncode'нутым (см. OwnerDailySummaryJob). + var resp = await _http.PostAsJsonAsync(url, new + { + chat_id = chatId, + text, + parse_mode = "HTML", + disable_web_page_preview = true, + }, ct); + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(ct); + _log.LogWarning("Telegram sendMessage {ChatId} → {Status}: {Body}", chatId, (int)resp.StatusCode, body); + return false; + } + return true; + } + catch (Exception ex) + { + _log.LogWarning(ex, "Telegram sendMessage exception (chatId={ChatId})", chatId); + return false; + } + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 001f9df..8587f8b 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -299,6 +299,19 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddScoped(); builder.Services.AddScoped(); + // Telegram-бот владельца. Token + username берём из конфига; если token + // пустой — клиент тихо отключён, RunAsync джоба возвращается сразу. UI + // переходит в режим «бот не настроен» — кнопка «Привязать Telegram» + // показывает подсказку об env-переменной. + builder.Services.Configure( + builder.Configuration.GetSection("Telegram")); + builder.Services.AddHttpClient(c => + { + c.Timeout = TimeSpan.FromSeconds(15); + }); + builder.Services.AddScoped(); + // SignalR + per-org notification publisher. Hub смонтирован ниже на // /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase // имена полей в DTO (см. NotificationsPublisher payload-records). diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index e44689b..e341578 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -28,6 +28,14 @@ public class Organization : Entity /// пользователю не нужно было вводить его каждый раз при импорте. public string? MoySkladToken { get; set; } + /// Chat ID владельца org в Telegram. Привязка делается через + /// deep-link на бота `food-market` — бот шлёт код подтверждения в + /// telegram, юзер вводит код в OrganizationSettings, мы сохраняем + /// chatId. По этому ChatId Hangfire-джоб OwnerDailySummaryJob шлёт + /// ежедневную сводку (выручка вчера, топ-3, low-stock). + /// null — привязки нет; ноль допустимым не считаем. + public long? OwnerTelegramChatId { get; set; } + /// Валюта организации по умолчанию. Если MultiCurrencyEnabled=false, /// в UI выбор валюты скрыт — всё в этой валюте. public Guid? DefaultCurrencyId { get; set; } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260531100000_Phase9a_OwnerTelegramChatId.cs b/src/food-market.infrastructure/Persistence/Migrations/20260531100000_Phase9a_OwnerTelegramChatId.cs new file mode 100644 index 0000000..fa1483f --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260531100000_Phase9a_OwnerTelegramChatId.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase9a — добавляем колонку OwnerTelegramChatId в + /// organizations. По ней Hangfire-джоб OwnerDailySummaryJob ходит + /// в Telegram Bot API и шлёт ежедневную сводку владельцу. NULL = бот не + /// привязан, рассылка не идёт. + [DbContext(typeof(AppDbContext))] + [Migration("20260531100000_Phase9a_OwnerTelegramChatId")] + public partial class Phase9a_OwnerTelegramChatId : Migration + { + protected override void Up(MigrationBuilder b) + { + b.AddColumn( + name: "OwnerTelegramChatId", + table: "organizations", + type: "bigint", + nullable: true); + } + + protected override void Down(MigrationBuilder b) + { + b.DropColumn(name: "OwnerTelegramChatId", table: "organizations"); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 2c30eb9..48178e1 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -1102,6 +1102,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MultiCurrencyEnabled") .HasColumnType("boolean"); + b.Property("OwnerTelegramChatId") + .HasColumnType("bigint"); + b.Property("ShowCountryOfOriginOnProduct") .HasColumnType("boolean"); diff --git a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx index 7228e67..b504f78 100644 --- a/src/food-market.web/src/pages/OrganizationSettingsPage.tsx +++ b/src/food-market.web/src/pages/OrganizationSettingsPage.tsx @@ -205,12 +205,107 @@ export function OrganizationSettingsPage() { {save.error && {(save.error as Error).message}} + ) } +interface TelegramStatus { + botEnabled: boolean + botUsername: string | null + chatId: number | null + deepLink: string | null +} + +/** + * Привязка Telegram-бота владельца: владелец org вводит свой chat_id + * (получает из @userinfobot или через deep-link к боту food-market). + * Сервер проверяет, отправив тестовое сообщение — если 200, сохраняем. + * После привязки Hangfire-джоб шлёт ежедневную сводку в 09:00 МСК. + */ +function TelegramSection() { + const qc = useQueryClient() + const status = useQuery({ + queryKey: ['/api/organization/telegram/status'], + queryFn: async () => (await api.get('/api/organization/telegram/status')).data, + }) + const [chatIdInput, setChatIdInput] = useState('') + const bind = useMutation({ + mutationFn: async () => { + const cid = Number(chatIdInput.trim()) + if (!Number.isInteger(cid) || cid === 0) throw new Error('ChatId — число, не равное 0') + return (await api.put('/api/organization/telegram/bind', { chatId: cid })).data + }, + onSuccess: () => { + setChatIdInput('') + qc.invalidateQueries({ queryKey: ['/api/organization/telegram/status'] }) + }, + meta: { successMessage: 'Telegram привязан, тестовое сообщение отправлено' }, + }) + const unbind = useMutation({ + mutationFn: async () => (await api.delete('/api/organization/telegram')).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/organization/telegram/status'] }), + meta: { successMessage: 'Привязка отменена' }, + }) + + const s = status.data + const bound = !!s?.chatId + + return ( +
+

📨 Telegram владельца

+

+ Ежедневная сводка (выручка вчера, топ-3 товара, low-stock) в 09:00 МСК. +

+ + {!s?.botEnabled && ( +

+ Бот не настроен на сервере (нет токена в env Telegram__BotToken). + Привязка пока недоступна. +

+ )} + + {s?.botEnabled && bound && ( +
+ Привязан: chat_id={s.chatId} + +
+ )} + + {s?.botEnabled && !bound && ( +
+ {s.deepLink && ( +

+ 1. Откройте + @{s.botUsername} + в Telegram, отправьте /start. +

+ )} +

+ 2. Откройте @userinfobot, чтобы узнать свой chat_id. +

+
+ + setChatIdInput(e.target.value)} + placeholder="например, 123456789" + /> + + +
+
+ )} +
+ ) +} + /** * Кнопка «Заполнить демо-данными» — для стейджа: 50 товаров / 10 контрагентов / * наполненные отчёты одной кнопкой. Запрос идемпотентен; на уже заполненной org diff --git a/tests/food-market.IntegrationTests/TelegramOwnerSummaryTests.cs b/tests/food-market.IntegrationTests/TelegramOwnerSummaryTests.cs new file mode 100644 index 0000000..d80ff4b --- /dev/null +++ b/tests/food-market.IntegrationTests/TelegramOwnerSummaryTests.cs @@ -0,0 +1,50 @@ +using System.Net.Http.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Тест рендеринга ежедневной сводки OwnerDailySummaryJob. +/// Сравниваем text-output с ожидаемым: заголовок org-name, цифры, +/// топ-3 + low-stock. Не отправляем реальное сообщение — тестируем +/// чистую функцию RenderSummaryAsync (она publicна именно ради тестов). +[Collection(ApiCollection.Name)] +public class TelegramOwnerSummaryTests +{ + private readonly ApiFactory _factory; + public TelegramOwnerSummaryTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Render_summary_contains_org_name_and_metrics() + { + var actor = new ApiActor(_factory.CreateClient()); + var email = $"tg-{Guid.NewGuid():N}@example.kz"; + (await actor.SignupAsync(email, "Passw0rd!", "Telegram Test Org")).EnsureSuccessStatusCode(); + var token = await actor.TokenAsync(email, "Passw0rd!"); + actor.UseToken(token); + var meDoc = await actor.GetJsonAsync("/api/me"); + var orgId = Guid.Parse(meDoc.GetProperty("orgId").GetString()!); + + // Засеем демо-данные чтобы было что показать (50 товаров + 30 продаж за + // последние 30 дней + пара low-stock). + (await actor.Http.PostAsync("/api/admin/seed-demo", null)).EnsureSuccessStatusCode(); + + // Вытащим джоб напрямую из DI и сгенерируем текст. + using var scope = _factory.Services.CreateScope(); + var job = scope.ServiceProvider.GetRequiredService(); + var text = await job.RenderSummaryAsync(orgId, "Telegram Test Org", CancellationToken.None); + + // Базовые поля + text.Should().Contain("Food Market"); + text.Should().Contain("Telegram Test Org"); + text.Should().Contain("Выручка"); + text.Should().Contain("Чеков"); + // HTML-форматирование parse_mode=HTML + text.Should().Contain(""); + // На свежей орге за «вчера» может ничего не быть (demo сеется на даты + // сегодня-30..сегодня). В разном таймзоне может попасть, может нет — + // главное чтобы render не падал и базовая структура была. + } +}