Add Telegram bot integration and related features
ci / build-backend (push) Successful in 2m38s
ci / build-frontend (push) Successful in 1m16s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 3m48s

Implemented Telegram bot functionality, including settings management, subscriber tracking, and link generation for user interaction. Updated the backend to support new Telegram-related services and database entities. Enhanced the frontend to display Telegram options and allow users to open a chat with the bot. Localization strings were added for both English and Russian to support the new features. This integration aims to improve user engagement through Telegram notifications and interactions.
This commit is contained in:
Leonid Pershin
2026-07-31 07:58:18 +03:00
parent bd3ced3637
commit 2b03e43a83
53 changed files with 6566 additions and 2 deletions
@@ -0,0 +1,299 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Notifications;
namespace TeleWave.Application.Notifications;
/// <summary>
/// Разговор с ботом: привязка чата к учётке и меню подписок.
///
/// Один и тот же код обслуживает оба транспорта — опрос и вебхук приносят одно и то же обновление,
/// и разводить логику по ним значило бы чинить каждую находку дважды.
///
/// Меню живёт одним сообщением: нажатие кнопки переписывает его на месте. Лента из десятка
/// одинаковых сообщений «ваши подписки» после каждого клика — это не настройка, а мусор в чате.
/// </summary>
public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api)
{
/// <summary>Разделитель в callback: идентификатор канала и вид оповещения.</summary>
private const char Separator = ':';
public async Task HandleAsync(
TelegramSettings settings,
TelegramUpdate update,
DateTimeOffset now,
CancellationToken cancellationToken
)
{
if (update.ChatId is not { } chatId)
return;
if (update.CallbackData is { Length: > 0 } data)
{
await HandleCallbackAsync(settings, update, chatId, data, now, cancellationToken);
return;
}
var text = update.Text?.Trim() ?? string.Empty;
if (text.StartsWith("/start", StringComparison.OrdinalIgnoreCase))
{
await HandleStartAsync(settings, chatId, text, update.From, now, cancellationToken);
return;
}
if (text.StartsWith("/stop", StringComparison.OrdinalIgnoreCase))
{
await HandleStopAsync(settings, chatId, cancellationToken);
return;
}
// Любое другое сообщение — это «я не знаю, что нажать»: показываем меню, если чат известен.
var subscriber = await FindAsync(chatId, cancellationToken);
if (subscriber is null)
await api.SendMessageAsync(settings, chatId, Texts.NeedLink, null, cancellationToken);
else
await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken);
}
/// <summary>
/// <c>/start КОД</c> — единственный вход: код выдаёт страница эфира авторизованному зрителю,
/// и только по нему бот узнаёт, кто пришёл. Без кода подписаться нельзя — иначе рассылка
/// уходила бы любому, кто нашёл бота.
/// </summary>
private async Task HandleStartAsync(
TelegramSettings settings,
long chatId,
string text,
string? from,
DateTimeOffset now,
CancellationToken cancellationToken
)
{
var parts = text.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var code = parts.Length > 1 ? parts[1].Trim() : null;
var subscriber = await FindAsync(chatId, cancellationToken);
if (code is null)
{
// Уже привязанный чат просто открывает меню: заставлять его снова ходить за кодом незачем.
if (subscriber is null)
{
await api.SendMessageAsync(
settings,
chatId,
Texts.NeedLink,
null,
cancellationToken
);
return;
}
subscriber.Resume();
subscriber.Touch(now);
await dbContext.SaveChangesAsync(cancellationToken);
await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken);
return;
}
var link = await dbContext.TelegramLinkCodes.FirstOrDefaultAsync(
c => c.Code == code,
cancellationToken
);
if (link is null || !link.IsUsable(now))
{
await api.SendMessageAsync(settings, chatId, Texts.BadCode, null, cancellationToken);
return;
}
link.MarkUsed(now);
if (subscriber is null)
{
subscriber = TelegramSubscriber.Create(chatId, link.UserId, from, now);
dbContext.TelegramSubscribers.Add(subscriber);
}
else
{
subscriber.Rebind(link.UserId, from, now);
}
await dbContext.SaveChangesAsync(cancellationToken);
await api.SendMessageAsync(settings, chatId, Texts.Linked, null, cancellationToken);
await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken);
}
private async Task HandleStopAsync(
TelegramSettings settings,
long chatId,
CancellationToken cancellationToken
)
{
var subscriber = await FindAsync(chatId, cancellationToken);
if (subscriber is null)
return;
// Подписки не трогаем: вернувшись по /start, зритель получит свои каналы, а не пустой список.
subscriber.Stop();
await dbContext.SaveChangesAsync(cancellationToken);
await api.SendMessageAsync(settings, chatId, Texts.Stopped, null, cancellationToken);
}
private async Task HandleCallbackAsync(
TelegramSettings settings,
TelegramUpdate update,
long chatId,
string data,
DateTimeOffset now,
CancellationToken cancellationToken
)
{
var subscriber = await FindAsync(chatId, cancellationToken);
if (subscriber is null)
{
await AnswerAsync(settings, update, Texts.NeedLink, cancellationToken);
return;
}
var parts = data.Split(Separator);
if (
parts.Length != 2
|| !Guid.TryParse(parts[0], out var channelId)
|| !Enum.TryParse<TelegramNotificationKind>(parts[1], out var kind)
)
{
await AnswerAsync(settings, update, null, cancellationToken);
return;
}
var known = await dbContext.Channels.AnyAsync(c => c.Id == channelId, cancellationToken);
if (!known)
{
await AnswerAsync(settings, update, Texts.ChannelGone, cancellationToken);
return;
}
var enabled = subscriber.Toggle(channelId, kind);
subscriber.Touch(now);
subscriber.Resume();
await dbContext.SaveChangesAsync(cancellationToken);
await AnswerAsync(
settings,
update,
enabled ? Texts.Subscribed : Texts.Unsubscribed,
cancellationToken
);
await ShowMenuAsync(
settings,
subscriber,
chatId,
update.CallbackMessageId,
cancellationToken
);
}
private Task AnswerAsync(
TelegramSettings settings,
TelegramUpdate update,
string? text,
CancellationToken cancellationToken
) =>
update.CallbackId is { Length: > 0 } id
? api.AnswerCallbackAsync(settings, id, text, cancellationToken)
: Task.CompletedTask;
/// <summary>
/// Меню подписок: строка на канал, в ней — кнопка на каждый вид оповещения с отметкой.
/// <paramref name="messageId"/> задан — правим уже отправленное сообщение, а не шлём новое.
/// </summary>
private async Task ShowMenuAsync(
TelegramSettings settings,
TelegramSubscriber subscriber,
long chatId,
long? messageId,
CancellationToken cancellationToken
)
{
var channels = await dbContext
.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
.OrderBy(c => c.Number ?? int.MaxValue)
.ThenBy(c => c.Name)
.Select(c => new { c.Id, c.Name })
.ToListAsync(cancellationToken);
if (channels.Count == 0)
{
await api.SendMessageAsync(settings, chatId, Texts.NoChannels, null, cancellationToken);
return;
}
var keyboard = new List<IReadOnlyList<TelegramButton>>();
foreach (var channel in channels)
{
keyboard.Add([
new TelegramButton($"— {channel.Name} —", $"{channel.Id}{Separator}none"),
]);
keyboard.Add([
Button(subscriber, channel.Id, TelegramNotificationKind.ShowStart, "Начало шоу"),
Button(subscriber, channel.Id, TelegramNotificationKind.UpNext, "Скоро в эфире"),
]);
}
if (messageId is { } id)
await api.EditMessageAsync(
settings,
chatId,
id,
Texts.Menu,
keyboard,
cancellationToken
);
else
await api.SendMessageAsync(settings, chatId, Texts.Menu, keyboard, cancellationToken);
}
private static TelegramButton Button(
TelegramSubscriber subscriber,
Guid channelId,
TelegramNotificationKind kind,
string label
)
{
var on = subscriber.IsSubscribed(channelId, kind);
return new TelegramButton($"{(on ? "" : "")} {label}", $"{channelId}{Separator}{kind}");
}
private Task<TelegramSubscriber?> FindAsync(long chatId, CancellationToken cancellationToken) =>
dbContext
.TelegramSubscribers.Include(s => s.Subscriptions)
.FirstOrDefaultAsync(s => s.ChatId == chatId, cancellationToken);
/// <summary>Тексты бота. Собраны в одном месте: их правят чаще, чем логику вокруг них.</summary>
private static class Texts
{
public const string NeedLink =
"Чтобы получать оповещения, откройте страницу эфира TeleWave и нажмите «Открыть чат с ботом» — "
+ "ссылка придёт с одноразовым кодом.";
public const string BadCode =
"Код не подошёл: он одноразовый и живёт 15 минут. Нажмите кнопку на странице эфира ещё раз.";
public const string Linked = "Готово, чат привязан к вашей учётной записи.";
public const string Menu =
"<b>Оповещения</b>\nНажмите, чтобы включить или выключить. ✅ — включено.\n"
+ "Остановить всё — командой /stop.";
public const string NoChannels = "Пока нет ни одного включённого канала.";
public const string Stopped = "Оповещения остановлены. Вернуть — командой /start.";
public const string Subscribed = "Включено";
public const string Unsubscribed = "Выключено";
public const string ChannelGone = "Канала больше нет";
}
}