Notify via Telegram about new support ticket comments
CI / Backend (build + test) (push) Failing after 6m7s
CI / Frontend (lint + typecheck + build) (push) Successful in 39s

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 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-16 17:57:08 +03:00
1 parent 0dcbd4e2b8
commit 1bc86dc426
5 files changed
+117 -5

No files matched your search

@@ -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 = $"💬 Новый комментарий в тикете от <b>{Escape(userName)}</b>\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,
@@ -50,6 +50,15 @@ public interface ITelegramNotifier
CancellationToken cancellationToken
);
/// <summary>Владелец тикета написал новый комментарий — превью текста + кнопка-ссылка на сайт,
/// без инлайн-действий (ответ — только на сайте).</summary>
Task NotifyAdminsTicketCommentAddedAsync(
Guid ticketId,
string userName,
string body,
CancellationToken cancellationToken
);
/// <summary>Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается
/// полностью в Telegram. Period задан только для Kind.Subscription — для Kind.PlanChangeTopUp он
/// null (доплата не привязана к тарифному периоду).</summary>
@@ -12,6 +12,7 @@ public sealed class AddTicketCommentCommandHandler(
IIdentityService identityService,
IFileStorage fileStorage,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<AddTicketCommentCommand, Result<TicketCommentDto>>
{
@@ -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,
@@ -14,6 +14,7 @@ public class AddTicketCommentCommandHandlerTests
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private readonly IFileStorage _fileStorage = Substitute.For<IFileStorage>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
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<Guid>(),
Arg.Any<CancellationToken>()
);
await _telegramNotifier
.DidNotReceive()
.NotifyUserAsync(
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<CancellationToken>()
);
await _telegramNotifier
.Received(1)
.NotifyAdminsTicketCommentAddedAsync(
ticket.Id,
"alice",
"апдейт",
Arg.Any<CancellationToken>()
);
}
[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<CancellationToken>());
await _telegramNotifier
.Received(1)
.NotifyUserAsync(
ownerId,
Arg.Any<string>(),
$"/support?ticket={ticket.Id}",
Arg.Any<CancellationToken>()
);
await _telegramNotifier
.DidNotReceive()
.NotifyAdminsTicketCommentAddedAsync(
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>()
);
}
[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
);
+6 -1
View File
@@ -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:`: