diff --git a/backend/src/PnvPanel.Api/Telegram/NewsPreview.cs b/backend/src/PnvPanel.Api/Telegram/NewsPreview.cs new file mode 100644 index 0000000..97bf735 --- /dev/null +++ b/backend/src/PnvPanel.Api/Telegram/NewsPreview.cs @@ -0,0 +1,82 @@ +using System.Text.RegularExpressions; + +namespace PnvPanel.Api.Telegram; + +/// Короткое текстовое превью markdown-тела новости для Telegram-оповещения: разметка +/// снимается (картинки выкидываются, ссылки → текст ссылки), длинный текст обрезается по границе +/// слова с «…». Полная новость — на сайте по кнопке. +internal static partial class NewsPreview +{ + public const int MaxLength = 600; + + public static string FromMarkdown(string? markdown, int maxLength = MaxLength) + { + if (string.IsNullOrWhiteSpace(markdown)) + return string.Empty; + + var text = markdown.Replace("\r\n", "\n"); + text = CodeFenceRegex().Replace(text, ""); + text = ImageRegex().Replace(text, ""); + text = LinkRegex().Replace(text, "$1"); + text = HtmlTagRegex().Replace(text, ""); + text = HeadingRegex().Replace(text, ""); + text = QuoteRegex().Replace(text, ""); + text = HorizontalRuleRegex().Replace(text, ""); + text = ListBulletRegex().Replace(text, "• "); + text = EmphasisRegex().Replace(text, "$2"); + text = InlineCodeRegex().Replace(text, "$1"); + text = TrailingSpacesRegex().Replace(text, "\n"); + text = ExtraBlankLinesRegex().Replace(text, "\n\n").Trim(); + + return Truncate(text, maxLength); + } + + private static string Truncate(string text, int maxLength) + { + if (text.Length <= maxLength) + return text; + + var cut = text[..maxLength]; + var lastSpace = cut.LastIndexOfAny([' ', '\n']); + if (lastSpace > maxLength / 2) + cut = cut[..lastSpace]; + + return cut.TrimEnd(' ', '\n', ',', '.', ';', ':', '-', '—') + "…"; + } + + [GeneratedRegex(@"```.*?```", RegexOptions.Singleline)] + private static partial Regex CodeFenceRegex(); + + [GeneratedRegex(@"!\[[^\]]*\]\([^)]*\)")] + private static partial Regex ImageRegex(); + + [GeneratedRegex(@"\[([^\]]*)\]\([^)]*\)")] + private static partial Regex LinkRegex(); + + [GeneratedRegex(@"<[^>]+>")] + private static partial Regex HtmlTagRegex(); + + [GeneratedRegex(@"^\s{0,3}#{1,6}\s*", RegexOptions.Multiline)] + private static partial Regex HeadingRegex(); + + [GeneratedRegex(@"^\s{0,3}>\s?", RegexOptions.Multiline)] + private static partial Regex QuoteRegex(); + + [GeneratedRegex(@"^\s{0,3}([-*_])(\s*\1){2,}\s*$", RegexOptions.Multiline)] + private static partial Regex HorizontalRuleRegex(); + + [GeneratedRegex(@"^\s{0,3}[-*+]\s+", RegexOptions.Multiline)] + private static partial Regex ListBulletRegex(); + + [GeneratedRegex(@"(\*\*|__|\*|_|~~)(?=\S)(.+?)(?<=\S)\1")] + private static partial Regex EmphasisRegex(); + + [GeneratedRegex(@"`([^`]*)`")] + private static partial Regex InlineCodeRegex(); + + [GeneratedRegex(@"[ \t]+\n")] + private static partial Regex TrailingSpacesRegex(); + + [GeneratedRegex(@"\n{3,}")] + private static partial Regex ExtraBlankLinesRegex(); +} diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs index f776ad3..94b4790 100644 --- a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs +++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs @@ -187,6 +187,7 @@ internal sealed class TelegramNotifier( public async Task NotifyUsersNewsPublishedAsync( string title, + string body, CancellationToken cancellationToken ) { @@ -194,6 +195,9 @@ internal sealed class TelegramNotifier( return; var text = $"📰 Новая новость: {Escape(title)}"; + var preview = NewsPreview.FromMarkdown(body); + if (preview.Length > 0) + text += $"\n\n{Escape(preview)}"; InlineKeyboardMarkup? keyboard = null; if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) diff --git a/backend/src/PnvPanel.Application/Admin/News/CreatePostCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/News/CreatePostCommandHandler.cs index f428d0f..3fb0eb6 100644 --- a/backend/src/PnvPanel.Application/Admin/News/CreatePostCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/News/CreatePostCommandHandler.cs @@ -26,7 +26,11 @@ public sealed class CreatePostCommandHandler( post.CreatedAt, cancellationToken ); - await telegramNotifier.NotifyUsersNewsPublishedAsync(post.Title, cancellationToken); + await telegramNotifier.NotifyUsersNewsPublishedAsync( + post.Title, + post.Body, + cancellationToken + ); return Result.Success(NewsPostDto.FromDomain(post)); } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs index 9a06712..2e3b183 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs @@ -33,8 +33,13 @@ public interface ITelegramNotifier ); /// Рассылка о публикации новости всем активированным пользователям с привязанным Telegram - /// (кнопка-ссылка на сайт, где новость показана целиком). - Task NotifyUsersNewsPublishedAsync(string title, CancellationToken cancellationToken); + /// (заголовок + текстовое превью markdown-тела, длинное обрезается с «…»; кнопка-ссылка на сайт, + /// где новость показана целиком). + Task NotifyUsersNewsPublishedAsync( + string title, + string body, + CancellationToken cancellationToken + ); /// Пользователь переоткрыл решённый тикет — только кнопка-ссылка на сайт, без /// инлайн-действий (аналогично баг-репортам). diff --git a/docs/domain-model.md b/docs/domain-model.md index 4adc9b6..7a49fb8 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -281,6 +281,8 @@ AppUser Нет статуса черновик/запланировано — публикация мгновенная. Нет видимости по ролям — доступно всем аутентифицированным пользователям. Realtime-оповещение о новом посте — `newsPublished` (SignalR, широковещательно всем подключенным клиентам), см. [architecture.md](architecture.md#realtime-signalr). +Плюс Telegram-рассылка активированным пользователям с привязкой: заголовок + текстовое превью тела +(markdown снят, картинки выкинуты, длиннее ~600 символов — обрезка по слову с «…») и кнопка на сайт. ### AuditLog — журнал действий Аудит значимых действий (прежде всего админских) для расследований и прозрачности.