From 1bc86dc426daecece0b48bfa85930dc600a9f462 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:57:08 +0300 Subject: [PATCH] Notify via Telegram about new support ticket comments Owner's reply goes to all admins (text preview + link to the ticket), admin's reply goes to the ticket owner as a DM. The comment author is never notified. Co-Authored-By: Claude Opus 5.5 --- .../PnvPanel.Api/Telegram/TelegramNotifier.cs | 41 +++++++++++++++++++ .../Common/Interfaces/ITelegramNotifier.cs | 9 ++++ .../AddTicketCommentCommandHandler.cs | 28 +++++++++++-- .../AddTicketCommentCommandHandlerTests.cs | 37 +++++++++++++++++ docs/telegram-bot.md | 7 +++- 5 files changed, 117 insertions(+), 5 deletions(-) diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs index 94b4790..edda0cb 100644 --- a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs +++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs @@ -185,6 +185,47 @@ internal sealed class TelegramNotifier( } } + public async Task NotifyAdminsTicketCommentAddedAsync( + Guid ticketId, + string userName, + string body, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(options.Value.BotToken)) + return; + + var preview = body.Length > 300 ? body[..300] + "…" : body; + var text = $"💬 Новый комментарий в тикете от {Escape(userName)}\n{Escape(preview)}"; + + InlineKeyboardMarkup? keyboard = null; + if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) + { + var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}"; + keyboard = new InlineKeyboardMarkup( + new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) } + ); + } + + foreach (var adminId in options.Value.ParseAdminTelegramUserIds()) + { + try + { + await botClient.SendMessage( + adminId, + text, + parseMode: ParseMode.Html, + replyMarkup: keyboard, + cancellationToken: cancellationToken + ); + } + catch + { + // Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду. + } + } + } + public async Task NotifyUsersNewsPublishedAsync( string title, string body, diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs index 2e3b183..78fd259 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs @@ -50,6 +50,15 @@ public interface ITelegramNotifier CancellationToken cancellationToken ); + /// Владелец тикета написал новый комментарий — превью текста + кнопка-ссылка на сайт, + /// без инлайн-действий (ответ — только на сайте). + Task NotifyAdminsTicketCommentAddedAsync( + Guid ticketId, + string userName, + string body, + CancellationToken cancellationToken + ); + /// Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается /// полностью в Telegram. Period задан только для Kind.Subscription — для Kind.PlanChangeTopUp он /// null (доплата не привязана к тарифному периоду). diff --git a/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandHandler.cs b/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandHandler.cs index e926527..aaf1970 100644 --- a/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandHandler.cs @@ -12,6 +12,7 @@ public sealed class AddTicketCommentCommandHandler( IIdentityService identityService, IFileStorage fileStorage, IRealtimeNotifier notifier, + ITelegramNotifier telegramNotifier, ICurrentUser currentUser ) : ICommandHandler> { @@ -55,11 +56,30 @@ public sealed class AddTicketCommentCommandHandler( ); dbContext.TicketAttachments.AddRange(attachments); - // Пушим автору тикета, только если комментирует не он сам (иначе он и так это видит у себя). - if (ticket.UserId != userId) - await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); - var userName = currentUser.UserName ?? userId.ToString(); + + // Ответ админа — пушим автору тикета (сайт + Telegram); реплика самого автора — админам в + // Telegram. Себя не уведомляем (админ в собственном тикете — тоже никого). + if (ticket.UserId != userId) + { + await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); + await telegramNotifier.NotifyUserAsync( + ticket.UserId, + "💬 Новый ответ в вашем обращении.", + $"/support?ticket={ticket.Id}", + cancellationToken + ); + } + else if (!TicketAuthorization.IsAdmin(profile)) + { + await telegramNotifier.NotifyAdminsTicketCommentAddedAsync( + ticket.Id, + userName, + comment.Body, + cancellationToken + ); + } + var dto = new TicketCommentDto( comment.Id, userId, diff --git a/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs index 25e01f9..69a9dbc 100644 --- a/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs @@ -14,6 +14,7 @@ public class AddTicketCommentCommandHandlerTests private readonly IIdentityService _identityService = Substitute.For(); private readonly IFileStorage _fileStorage = Substitute.For(); private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); private static CurrentUserProfile Profile(Guid userId, string role) => new( @@ -51,6 +52,7 @@ public class AddTicketCommentCommandHandlerTests _identityService, _fileStorage, _notifier, + _telegramNotifier, currentUser ); @@ -68,6 +70,22 @@ public class AddTicketCommentCommandHandlerTests Arg.Any(), Arg.Any() ); + await _telegramNotifier + .DidNotReceive() + .NotifyUserAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + await _telegramNotifier + .Received(1) + .NotifyAdminsTicketCommentAddedAsync( + ticket.Id, + "alice", + "апдейт", + Arg.Any() + ); } [Fact] @@ -90,6 +108,7 @@ public class AddTicketCommentCommandHandlerTests _identityService, _fileStorage, _notifier, + _telegramNotifier, currentUser ); @@ -102,6 +121,22 @@ public class AddTicketCommentCommandHandlerTests await _notifier .Received(1) .NotifyTicketUpdatedAsync(ticket.Id, ownerId, Arg.Any()); + await _telegramNotifier + .Received(1) + .NotifyUserAsync( + ownerId, + Arg.Any(), + $"/support?ticket={ticket.Id}", + Arg.Any() + ); + await _telegramNotifier + .DidNotReceive() + .NotifyAdminsTicketCommentAddedAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); } [Fact] @@ -124,6 +159,7 @@ public class AddTicketCommentCommandHandlerTests _identityService, _fileStorage, _notifier, + _telegramNotifier, currentUser ); @@ -156,6 +192,7 @@ public class AddTicketCommentCommandHandlerTests _identityService, _fileStorage, _notifier, + _telegramNotifier, currentUser ); diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 1c58d50..8d3e1da 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -39,6 +39,10 @@ Telegram-бот — **второй канал доставки** (presentation- захода на сайт** — одобрение продлевает `BillingPaidUntil` той же командой, что и на сайте. Баг-репорты/предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий (переписка и вложения удобнее там). +9. **Новые комментарии в тикетах** (`AddTicketCommentCommandHandler`): реплика владельца тикета → + всем админам `NotifyAdminsTicketCommentAddedAsync` (превью ~300 символов + ссылка на + `/admin/support?ticket={id}`); ответ админа → владельцу DM «💬 Новый ответ в вашем обращении.» + через `NotifyUserAsync` (ссылка на `/support?ticket={id}`). Автора комментария не уведомляем. **Не реализовано:** - QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке). @@ -181,7 +185,8 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl 2. Каждому админу уходит сообщение с превью текста (обрезано до ~300 символов) и, если задан `Telegram__PublicSiteUrl`, **кнопкой-ссылкой** `🌐 Открыть на сайте` на `/admin/support/{ticketId}` (`InlineKeyboardButton.WithUrl`, не callback) — тап открывает страницу тикета в браузере. -3. Дальше — только на сайте: переписка, вложения, resolve/close. +3. Дальше — только на сайте: переписка, вложения, resolve/close. О новых комментариях в + тикете обе стороны получают Telegram-уведомление со ссылкой (см. п. 9 выше). **Заявка на продление** (`ExtensionRequest`, только для billing-ролей) — полностью решается в Telegram, инлайн-кнопки с префиксом `erq:`: