Add Telegram bot integration and related features
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:
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Notifications.Settings;
|
||||
|
||||
public sealed record GetTelegramSettingsQuery : IQuery<Result<TelegramSettingsDto>>;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Notifications.Settings;
|
||||
|
||||
public sealed class GetTelegramSettingsQueryHandler(TelegramSettingsService settings)
|
||||
: IQueryHandler<GetTelegramSettingsQuery, Result<TelegramSettingsDto>>
|
||||
{
|
||||
public async Task<Result<TelegramSettingsDto>> Handle(
|
||||
GetTelegramSettingsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Читаем без создания строки: GET не должен писать в базу, а умолчания те же самые.
|
||||
var stored = await settings.ReadAsync(cancellationToken);
|
||||
var current = stored ?? Domain.Notifications.TelegramSettings.CreateDefault();
|
||||
return Result.Success(await settings.ToDtoAsync(current, cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using TeleWave.Domain.Notifications;
|
||||
|
||||
namespace TeleWave.Application.Notifications.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки бота для админки. Секретов здесь нет: токен и пароль прокси наружу не отдаются,
|
||||
/// вместо них признак «задан» — иначе они утекали бы в каждый GET и в отладочные дампы.
|
||||
/// </summary>
|
||||
public sealed record TelegramSettingsDto(
|
||||
bool IsEnabled,
|
||||
bool HasToken,
|
||||
string? BotUsername,
|
||||
/// <summary>Ссылка на чат с ботом; null — бот не настроен, и кнопку зрителю показывать нечем.</summary>
|
||||
string? BotLink,
|
||||
TelegramTransport Transport,
|
||||
string? WebhookUrl,
|
||||
TelegramProxyKind ProxyKind,
|
||||
string? ProxyHost,
|
||||
int? ProxyPort,
|
||||
string? ProxyUsername,
|
||||
bool HasProxyPassword,
|
||||
DateTimeOffset? LastContactAt,
|
||||
string? LastError,
|
||||
int Subscribers
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Notifications;
|
||||
|
||||
namespace TeleWave.Application.Notifications.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Доступ к единственной строке настроек бота и сборка её представления для админки.
|
||||
///
|
||||
/// Отдельный тип, а не метод хендлера: настройки читают все — оба транспорта, рассылка, выдача
|
||||
/// ссылки зрителю, — и «создать, если строки ещё нет» должно жить в одном месте.
|
||||
/// </summary>
|
||||
public sealed class TelegramSettingsService(IAppDbContext dbContext)
|
||||
{
|
||||
/// <summary>Настройки для записи. Первое обращение заводит строку с умолчаниями.</summary>
|
||||
public async Task<TelegramSettings> GetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await dbContext.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
|
||||
if (settings is not null)
|
||||
return settings;
|
||||
|
||||
settings = TelegramSettings.CreateDefault();
|
||||
dbContext.TelegramSettings.Add(settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>Настройки только для чтения — для фоновых служб, которым писать нечего.</summary>
|
||||
public Task<TelegramSettings?> ReadAsync(CancellationToken cancellationToken) =>
|
||||
dbContext.TelegramSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public async Task<TelegramSettingsDto> ToDtoAsync(
|
||||
TelegramSettings settings,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var subscribers = await dbContext.TelegramSubscribers.CountAsync(
|
||||
s => !s.IsStopped,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return new TelegramSettingsDto(
|
||||
settings.IsEnabled,
|
||||
!string.IsNullOrWhiteSpace(settings.BotToken),
|
||||
settings.BotUsername,
|
||||
BotLink(settings),
|
||||
settings.Transport,
|
||||
settings.WebhookUrl,
|
||||
settings.ProxyKind,
|
||||
settings.ProxyHost,
|
||||
settings.ProxyPort,
|
||||
settings.ProxyUsername,
|
||||
!string.IsNullOrWhiteSpace(settings.ProxyPassword),
|
||||
settings.LastContactAt,
|
||||
settings.LastError,
|
||||
subscribers
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ссылка на чат. Без имени бота её не собрать, а имя приходит от Telegram при проверке
|
||||
/// токена — поэтому кнопка у зрителя появляется только после успешного соединения.
|
||||
/// </summary>
|
||||
public static string? BotLink(TelegramSettings settings) =>
|
||||
string.IsNullOrWhiteSpace(settings.BotUsername) || !settings.IsEnabled
|
||||
? null
|
||||
: $"https://t.me/{settings.BotUsername}";
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Notifications;
|
||||
|
||||
namespace TeleWave.Application.Notifications.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Правка настроек бота. <paramref name="BotToken"/> и <paramref name="ProxyPassword"/> пустые
|
||||
/// означают «оставить как есть»: наружу они не отдаются, и форма вернуть их не может.
|
||||
/// </summary>
|
||||
public sealed record UpdateTelegramSettingsCommand(
|
||||
bool IsEnabled,
|
||||
TelegramTransport Transport,
|
||||
string? BotToken,
|
||||
string? WebhookUrl,
|
||||
TelegramProxyKind ProxyKind,
|
||||
string? ProxyHost,
|
||||
int? ProxyPort,
|
||||
string? ProxyUsername,
|
||||
string? ProxyPassword
|
||||
) : ICommand<Result<TelegramSettingsDto>>;
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
using System.Security.Cryptography;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Notifications;
|
||||
|
||||
namespace TeleWave.Application.Notifications.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет настройки бота и сразу проверяет их делом: спрашивает у Telegram, кто мы,
|
||||
/// и переключает вебхук.
|
||||
///
|
||||
/// Проверка на сохранении, а не отдельной кнопкой, намеренно: неверный токен или недоступный
|
||||
/// прокси иначе всплыли бы через сутки молчания бота, а не в момент правки. Имя бота приходит
|
||||
/// из того же ответа — без него не собрать ссылку на чат для зрителя.
|
||||
/// </summary>
|
||||
public sealed class UpdateTelegramSettingsCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
TelegramSettingsService settingsService,
|
||||
ITelegramApi api
|
||||
) : ICommandHandler<UpdateTelegramSettingsCommand, Result<TelegramSettingsDto>>
|
||||
{
|
||||
public async Task<Result<TelegramSettingsDto>> Handle(
|
||||
UpdateTelegramSettingsCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (
|
||||
command.Transport == TelegramTransport.Webhook
|
||||
&& string.IsNullOrWhiteSpace(command.WebhookUrl)
|
||||
)
|
||||
return Result.Failure<TelegramSettingsDto>(TelegramErrors.WebhookUrlRequired);
|
||||
|
||||
if (
|
||||
command.ProxyKind != TelegramProxyKind.None
|
||||
&& string.IsNullOrWhiteSpace(command.ProxyHost)
|
||||
)
|
||||
return Result.Failure<TelegramSettingsDto>(TelegramErrors.ProxyHostRequired);
|
||||
|
||||
var settings = await settingsService.GetAsync(cancellationToken);
|
||||
settings.Update(
|
||||
new TelegramSettingsUpdate(
|
||||
command.IsEnabled,
|
||||
command.Transport,
|
||||
command.BotToken,
|
||||
command.WebhookUrl,
|
||||
command.ProxyKind,
|
||||
command.ProxyHost,
|
||||
command.ProxyPort,
|
||||
command.ProxyUsername,
|
||||
command.ProxyPassword
|
||||
)
|
||||
);
|
||||
|
||||
if (command.IsEnabled && string.IsNullOrWhiteSpace(settings.BotToken))
|
||||
return Result.Failure<TelegramSettingsDto>(TelegramErrors.NotConfigured);
|
||||
|
||||
if (settings.IsOperational)
|
||||
{
|
||||
var check = await ConnectAsync(settings, cancellationToken);
|
||||
if (check is { } error)
|
||||
return Result.Failure<TelegramSettingsDto>(error);
|
||||
}
|
||||
|
||||
return Result.Success(await settingsService.ToDtoAsync(settings, cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет связь и приводит режим доставки в соответствие настройке. Вебхук снимается явно:
|
||||
/// пока он стоит, Telegram не отдаёт обновления опросом, и переключение режима без этого
|
||||
/// оставило бы бота немым.
|
||||
/// </summary>
|
||||
private async Task<Error?> ConnectAsync(
|
||||
TelegramSettings settings,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
var me = await api.GetMeAsync(settings, cancellationToken);
|
||||
if (me is null)
|
||||
return TelegramErrors.NotConfigured;
|
||||
|
||||
settings.SetBotUsername(me.Username);
|
||||
|
||||
if (settings.Transport == TelegramTransport.Webhook)
|
||||
{
|
||||
// Секрет генерируем при первой установке: он подтверждает, что вызов пришёл
|
||||
// от Telegram, а не от того, кто узнал адрес.
|
||||
if (string.IsNullOrWhiteSpace(settings.WebhookSecret))
|
||||
settings.SetWebhookSecret(
|
||||
Convert.ToHexString(RandomNumberGenerator.GetBytes(24))
|
||||
);
|
||||
await api.SetWebhookAsync(settings, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await api.DeleteWebhookAsync(settings, cancellationToken);
|
||||
}
|
||||
|
||||
settings.MarkContact(DateTimeOffset.UtcNow);
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
settings.MarkError(exception.Message);
|
||||
// Настройки при этом сохраняются: админ должен видеть, что именно он ввёл, и поправить
|
||||
// одно поле, а не набирать форму заново.
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return TelegramErrors.Unreachable(exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user