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:`: