Include news text preview in Telegram notification
CI / Backend (build + test) (push) Failing after 1m26s
CI / Frontend (lint + typecheck + build) (push) Successful in 37s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-13 17:25:36 +03:00
1 parent 94b02242ed
commit 0dcbd4e2b8
5 files changed
+100 -3

No files matched your search

@@ -0,0 +1,82 @@
using System.Text.RegularExpressions;
namespace PnvPanel.Api.Telegram;
/// <summary>Короткое текстовое превью markdown-тела новости для Telegram-оповещения: разметка
/// снимается (картинки выкидываются, ссылки → текст ссылки), длинный текст обрезается по границе
/// слова с «…». Полная новость — на сайте по кнопке.</summary>
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();
}
@@ -187,6 +187,7 @@ internal sealed class TelegramNotifier(
public async Task NotifyUsersNewsPublishedAsync( public async Task NotifyUsersNewsPublishedAsync(
string title, string title,
string body,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
@@ -194,6 +195,9 @@ internal sealed class TelegramNotifier(
return; return;
var text = $"📰 Новая новость: <b>{Escape(title)}</b>"; var text = $"📰 Новая новость: <b>{Escape(title)}</b>";
var preview = NewsPreview.FromMarkdown(body);
if (preview.Length > 0)
text += $"\n\n{Escape(preview)}";
InlineKeyboardMarkup? keyboard = null; InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
@@ -26,7 +26,11 @@ public sealed class CreatePostCommandHandler(
post.CreatedAt, post.CreatedAt,
cancellationToken cancellationToken
); );
await telegramNotifier.NotifyUsersNewsPublishedAsync(post.Title, cancellationToken); await telegramNotifier.NotifyUsersNewsPublishedAsync(
post.Title,
post.Body,
cancellationToken
);
return Result.Success(NewsPostDto.FromDomain(post)); return Result.Success(NewsPostDto.FromDomain(post));
} }
@@ -33,8 +33,13 @@ public interface ITelegramNotifier
); );
/// <summary>Рассылка о публикации новости всем активированным пользователям с привязанным Telegram /// <summary>Рассылка о публикации новости всем активированным пользователям с привязанным Telegram
/// (кнопка-ссылка на сайт, где новость показана целиком).</summary> /// (заголовок + текстовое превью markdown-тела, длинное обрезается с «…»; кнопка-ссылка на сайт,
Task NotifyUsersNewsPublishedAsync(string title, CancellationToken cancellationToken); /// где новость показана целиком).</summary>
Task NotifyUsersNewsPublishedAsync(
string title,
string body,
CancellationToken cancellationToken
);
/// <summary>Пользователь переоткрыл решённый тикет — только кнопка-ссылка на сайт, без /// <summary>Пользователь переоткрыл решённый тикет — только кнопка-ссылка на сайт, без
/// инлайн-действий (аналогично баг-репортам).</summary> /// инлайн-действий (аналогично баг-репортам).</summary>
+2
View File
@@ -281,6 +281,8 @@ AppUser
Нет статуса черновик/запланировано — публикация мгновенная. Нет видимости по ролям — доступно Нет статуса черновик/запланировано — публикация мгновенная. Нет видимости по ролям — доступно
всем аутентифицированным пользователям. Realtime-оповещение о новом посте — `newsPublished` всем аутентифицированным пользователям. Realtime-оповещение о новом посте — `newsPublished`
(SignalR, широковещательно всем подключенным клиентам), см. [architecture.md](architecture.md#realtime-signalr). (SignalR, широковещательно всем подключенным клиентам), см. [architecture.md](architecture.md#realtime-signalr).
Плюс Telegram-рассылка активированным пользователям с привязкой: заголовок + текстовое превью тела
(markdown снят, картинки выкинуты, длиннее ~600 символов — обрезка по слову с «…») и кнопка на сайт.
### AuditLog — журнал действий ### AuditLog — журнал действий
Аудит значимых действий (прежде всего админских) для расследований и прозрачности. Аудит значимых действий (прежде всего админских) для расследований и прозрачности.