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:
@@ -10,7 +10,7 @@
|
|||||||
Работает: аутентификация, роли, пользователи; библиотека шоу/серий, коллекции, жанры, метаданные
|
Работает: аутентификация, роли, пользователи; библиотека шоу/серий, коллекции, жанры, метаданные
|
||||||
(TMDb/OMDb), обработка медиа (ffmpeg → HLS), изображения; группы, шаблон сетки, стыки и заставки,
|
(TMDb/OMDb), обработка медиа (ffmpeg → HLS), изображения; группы, шаблон сетки, стыки и заставки,
|
||||||
автосборка по профилям, обмен конфигурацией файлом и запросом к ИИ; генерация ленты, live-раздача,
|
автосборка по профилям, обмен конфигурацией файлом и запросом к ИИ; генерация ленты, live-раздача,
|
||||||
публичный просмотр, статистика хранилища. Telegram-бот сознательно не делаем. Конвенции — зеркало
|
публичный просмотр, статистика хранилища. Telegram-бот расписания (подписки на оповещения по каналам, прокси, опрос или вебхук). Конвенции — зеркало
|
||||||
[`PnvPanel`](../PnvPanel) (тот же автор).
|
[`PnvPanel`](../PnvPanel) (тот же автор).
|
||||||
|
|
||||||
**Спеки в `docs/` — источник правды по домену**, читай до правок в подсистеме:
|
**Спеки в `docs/` — источник правды по домену**, читай до правок в подсистеме:
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Api.Common;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Notifications;
|
||||||
|
using TeleWave.Application.Notifications.Link;
|
||||||
|
using TeleWave.Application.Notifications.Settings;
|
||||||
|
using TeleWave.Application.Notifications.Subscribers;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Бот расписания: настройка в админке, привязка чата зрителем и приём обновлений вебхуком.
|
||||||
|
/// </summary>
|
||||||
|
public static class TelegramEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Заголовок, которым Telegram подтверждает, что вызов вебхука — его.</summary>
|
||||||
|
private const string SecretHeader = "X-Telegram-Bot-Api-Secret-Token";
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/telegram").RequireAuthorization("admin");
|
||||||
|
|
||||||
|
admin.MapGet("/settings", GetSettings).Produces<TelegramSettingsDto>();
|
||||||
|
admin.MapPut("/settings", UpdateSettings).Produces<TelegramSettingsDto>();
|
||||||
|
admin
|
||||||
|
.MapGet("/subscribers", ListSubscribers)
|
||||||
|
.Produces<IReadOnlyList<TelegramSubscriberDto>>();
|
||||||
|
|
||||||
|
// Настроен ли бот — публично: по этому флагу зрительская страница решает, показывать ли
|
||||||
|
// кнопку. Ничего, кроме флага и имени бота, здесь не отдаётся.
|
||||||
|
app.MapGet("/api/telegram/status", Status).AllowAnonymous().Produces<TelegramStatusDto>();
|
||||||
|
|
||||||
|
// Ссылка на чат — зрителю, а не админу: код привязывает его собственную учётку.
|
||||||
|
app.MapPost("/api/telegram/link", IssueLink)
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<TelegramLinkDto>();
|
||||||
|
|
||||||
|
// Вебхук открыт наружу по построению: его вызывает Telegram, а не наш фронт. Пускаем
|
||||||
|
// только по секрету из настроек — адрес рано или поздно окажется в чужих логах.
|
||||||
|
app.MapPost("/api/telegram/webhook", Webhook).AllowAnonymous();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Status(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext
|
||||||
|
.TelegramSettings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var link = settings is null ? null : TelegramSettingsService.BotLink(settings);
|
||||||
|
return Results.Ok(new TelegramStatusDto(link is not null, settings?.BotUsername));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetSettings(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetTelegramSettingsQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateSettings(
|
||||||
|
TelegramSettingsBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new UpdateTelegramSettingsCommand(
|
||||||
|
body.IsEnabled,
|
||||||
|
body.Transport,
|
||||||
|
body.BotToken,
|
||||||
|
body.WebhookUrl,
|
||||||
|
body.ProxyKind,
|
||||||
|
body.ProxyHost,
|
||||||
|
body.ProxyPort,
|
||||||
|
body.ProxyUsername,
|
||||||
|
body.ProxyPassword
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListSubscribers(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListTelegramSubscribersQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> IssueLink(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new IssueTelegramLinkCommand(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Приём обновления от Telegram. Разбираем тем же сервисом, что и опрос: транспорт разный,
|
||||||
|
/// разговор один.
|
||||||
|
///
|
||||||
|
/// Отвечаем 200 всегда, когда секрет сошёлся: на ошибку Telegram повторяет доставку, и одно
|
||||||
|
/// кривое обновление зациклило бы вебхук.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<IResult> Webhook(
|
||||||
|
HttpRequest request,
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
TelegramBotService bot,
|
||||||
|
ITelegramApi api,
|
||||||
|
ILoggerFactory loggers,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (
|
||||||
|
settings is null
|
||||||
|
|| !settings.IsOperational
|
||||||
|
|| settings.Transport != TelegramTransport.Webhook
|
||||||
|
)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var secret = request.Headers[SecretHeader].ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.WebhookSecret) || secret != settings.WebhookSecret)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var update = await api.ReadUpdateAsync(request.Body, cancellationToken);
|
||||||
|
if (update is null)
|
||||||
|
return Results.Ok();
|
||||||
|
|
||||||
|
var logger = loggers.CreateLogger("Telegram.Webhook");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.HandleAsync(settings, update, DateTimeOffset.UtcNow, cancellationToken);
|
||||||
|
settings.MarkContact(DateTimeOffset.UtcNow);
|
||||||
|
settings.AdvanceUpdateId(update.UpdateId);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Не разобрано обновление {UpdateId}", update.UpdateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Что зрительской части нужно знать о боте: настроен ли и как он называется.</summary>
|
||||||
|
public sealed record TelegramStatusDto(bool IsConfigured, string? BotUsername);
|
||||||
|
|
||||||
|
/// <summary>Тело правки настроек: секреты необязательны — пусто значит «не менять».</summary>
|
||||||
|
public sealed record TelegramSettingsBody(
|
||||||
|
bool IsEnabled,
|
||||||
|
TelegramTransport Transport,
|
||||||
|
string? BotToken,
|
||||||
|
string? WebhookUrl,
|
||||||
|
TelegramProxyKind ProxyKind,
|
||||||
|
string? ProxyHost,
|
||||||
|
int? ProxyPort,
|
||||||
|
string? ProxyUsername,
|
||||||
|
string? ProxyPassword
|
||||||
|
);
|
||||||
@@ -133,6 +133,7 @@ app.MapTemplateEndpoints();
|
|||||||
app.MapBumperEndpoints();
|
app.MapBumperEndpoints();
|
||||||
app.MapJunctionEndpoints();
|
app.MapJunctionEndpoints();
|
||||||
app.MapChannelEndpoints();
|
app.MapChannelEndpoints();
|
||||||
|
app.MapTelegramEndpoints();
|
||||||
app.MapStreamingEndpoints();
|
app.MapStreamingEndpoints();
|
||||||
app.MapIptvEndpoints();
|
app.MapIptvEndpoints();
|
||||||
app.MapMaintenanceEndpoints();
|
app.MapMaintenanceEndpoints();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using TeleWave.Domain.Broadcast;
|
|||||||
using TeleWave.Domain.Images;
|
using TeleWave.Domain.Images;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Domain.Settings;
|
using TeleWave.Domain.Settings;
|
||||||
|
|
||||||
@@ -32,6 +33,10 @@ public interface IAppDbContext
|
|||||||
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
||||||
DbSet<BumperTemplate> BumperTemplates { get; }
|
DbSet<BumperTemplate> BumperTemplates { get; }
|
||||||
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
||||||
|
DbSet<TelegramSettings> TelegramSettings { get; }
|
||||||
|
DbSet<TelegramSubscriber> TelegramSubscribers { get; }
|
||||||
|
DbSet<TelegramSubscription> TelegramSubscriptions { get; }
|
||||||
|
DbSet<TelegramLinkCode> TelegramLinkCodes { get; }
|
||||||
DbSet<BumperAsset> BumperAssets { get; }
|
DbSet<BumperAsset> BumperAssets { get; }
|
||||||
DbSet<AppSetting> AppSettings { get; }
|
DbSet<AppSetting> AppSettings { get; }
|
||||||
DbSet<Image> Images { get; }
|
DbSet<Image> Images { get; }
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Кнопка инлайн-клавиатуры: подпись и что вернётся в callback.</summary>
|
||||||
|
public sealed record TelegramButton(string Text, string CallbackData);
|
||||||
|
|
||||||
|
/// <summary>Обновление от Telegram — только то, что нам нужно от его модели.</summary>
|
||||||
|
public sealed record TelegramUpdate(
|
||||||
|
long UpdateId,
|
||||||
|
long? ChatId,
|
||||||
|
string? Text,
|
||||||
|
string? From,
|
||||||
|
/// <summary>Идентификатор нажатия: на него надо ответить, иначе кнопка «крутится» у зрителя.</summary>
|
||||||
|
string? CallbackId,
|
||||||
|
string? CallbackData,
|
||||||
|
long? CallbackMessageId
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Кто мы для Telegram — ответ getMe, по нему проверяется токен.</summary>
|
||||||
|
public sealed record TelegramBotInfo(long Id, string Username);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Порт к Bot API. Держим ровно те вызовы, что нужны боту расписания: узнать себя, получить
|
||||||
|
/// обновления, ответить сообщением с кнопками и переключить вебхук.
|
||||||
|
///
|
||||||
|
/// Настройки (токен, прокси) реализация читает сама на каждый вызов: админ меняет их в рантайме,
|
||||||
|
/// и держать клиента, собранного на старте, значило бы требовать перезапуск контейнера.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITelegramApi
|
||||||
|
{
|
||||||
|
Task<TelegramBotInfo?> GetMeAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<TelegramUpdate>> GetUpdatesAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
long offset,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
Task SendMessageAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
long chatId,
|
||||||
|
string text,
|
||||||
|
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Переписывает уже отправленное сообщение — так меню подписок обновляется на месте.</summary>
|
||||||
|
Task EditMessageAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
long chatId,
|
||||||
|
long messageId,
|
||||||
|
string text,
|
||||||
|
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Гасит «часики» на нажатой кнопке; текст — всплывающая подсказка.</summary>
|
||||||
|
Task AnswerCallbackAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
string callbackId,
|
||||||
|
string? text,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Разбирает тело вебхука в то же обновление, что приходит опросом: разговор с ботом один,
|
||||||
|
/// и знать про две модели ему незачем. null — тело не разобралось.
|
||||||
|
/// </summary>
|
||||||
|
Task<TelegramUpdate?> ReadUpdateAsync(Stream body, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task SetWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task DeleteWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ using TeleWave.Application.Media;
|
|||||||
using TeleWave.Application.Media.ManualInbox;
|
using TeleWave.Application.Media.ManualInbox;
|
||||||
using TeleWave.Application.Media.MovieImport;
|
using TeleWave.Application.Media.MovieImport;
|
||||||
using TeleWave.Application.Metadata;
|
using TeleWave.Application.Metadata;
|
||||||
|
using TeleWave.Application.Notifications;
|
||||||
|
using TeleWave.Application.Notifications.Settings;
|
||||||
using TeleWave.Application.Programming.Groups;
|
using TeleWave.Application.Programming.Groups;
|
||||||
using TeleWave.Application.Programming.Groups.Suggest;
|
using TeleWave.Application.Programming.Groups.Suggest;
|
||||||
using TeleWave.Application.Programming.Planning;
|
using TeleWave.Application.Programming.Planning;
|
||||||
@@ -66,6 +68,9 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<GridScheduleGenerator>();
|
services.AddScoped<GridScheduleGenerator>();
|
||||||
services.AddScoped<ChannelDebugCollector>();
|
services.AddScoped<ChannelDebugCollector>();
|
||||||
services.AddScoped<LibraryDebugCollector>();
|
services.AddScoped<LibraryDebugCollector>();
|
||||||
|
services.AddScoped<TelegramSettingsService>();
|
||||||
|
services.AddScoped<TelegramBotService>();
|
||||||
|
services.AddScoped<TelegramNotifier>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Notifications.Link;
|
||||||
|
|
||||||
|
/// <summary>Выдать текущему зрителю ссылку на чат с ботом и одноразовым кодом привязки.</summary>
|
||||||
|
public sealed record IssueTelegramLinkCommand : ICommand<Result<TelegramLinkDto>>;
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Notifications.Settings;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Notifications.Link;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выдаёт одноразовый код привязки и собирает из него ссылку на чат.
|
||||||
|
///
|
||||||
|
/// Код — единственный способ познакомить чат с учёткой: бот не спрашивает логин и не верит
|
||||||
|
/// на слово, а ссылка уходит только авторизованному зрителю. Старые неиспользованные коды
|
||||||
|
/// того же пользователя гасим: две живые ссылки на один аккаунт — это две двери вместо одной.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IssueTelegramLinkCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
TelegramSettingsService settingsService,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<IssueTelegramLinkCommand, Result<TelegramLinkDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<TelegramLinkDto>> Handle(
|
||||||
|
IssueTelegramLinkCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return Result.Failure<TelegramLinkDto>(TelegramErrors.NotConfigured);
|
||||||
|
|
||||||
|
var settings = await settingsService.ReadAsync(cancellationToken);
|
||||||
|
if (settings is null || TelegramSettingsService.BotLink(settings) is not { } botLink)
|
||||||
|
return Result.Failure<TelegramLinkDto>(TelegramErrors.NotConfigured);
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var stale = await dbContext
|
||||||
|
.TelegramLinkCodes.Where(c => c.UserId == userId && c.UsedAt == null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var code in stale)
|
||||||
|
code.MarkUsed(now);
|
||||||
|
|
||||||
|
// Base64url без padding: код едет в ссылке, и «=» с «+» там только мешают.
|
||||||
|
var value = Convert
|
||||||
|
.ToBase64String(RandomNumberGenerator.GetBytes(18))
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_')
|
||||||
|
.TrimEnd('=');
|
||||||
|
|
||||||
|
var link = TelegramLinkCode.Issue(value, userId, now);
|
||||||
|
dbContext.TelegramLinkCodes.Add(link);
|
||||||
|
|
||||||
|
return Result.Success(new TelegramLinkDto($"{botLink}?start={value}", link.ExpiresAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace TeleWave.Application.Notifications.Link;
|
||||||
|
|
||||||
|
/// <param name="Url">Готовая ссылка t.me/бот?start=код — её и открывает кнопка на странице эфира.</param>
|
||||||
|
/// <param name="ExpiresAt">Когда код сгорит: ссылка живёт минуты, а не вечно.</param>
|
||||||
|
public sealed record TelegramLinkDto(string Url, DateTimeOffset ExpiresAt);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Notifications.Subscribers;
|
||||||
|
|
||||||
|
public sealed record ListTelegramSubscribersQuery
|
||||||
|
: IQuery<Result<IReadOnlyList<TelegramSubscriberDto>>>;
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Notifications.Subscribers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Кто подписан и на что. Нужен не ради красоты: рассылка уходит в чужие чаты, и админ должен
|
||||||
|
/// видеть список — иначе «бот кому-то что-то шлёт» проверяется только по логам Telegram.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ListTelegramSubscribersQueryHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identity
|
||||||
|
) : IQueryHandler<ListTelegramSubscribersQuery, Result<IReadOnlyList<TelegramSubscriberDto>>>
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<TelegramSubscriberDto>>> Handle(
|
||||||
|
ListTelegramSubscribersQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var subscribers = await dbContext
|
||||||
|
.TelegramSubscribers.AsNoTracking()
|
||||||
|
.Include(s => s.Subscriptions)
|
||||||
|
.OrderByDescending(s => s.LastSeenAt ?? s.CreatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var channelNames = await dbContext
|
||||||
|
.Channels.AsNoTracking()
|
||||||
|
.ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken);
|
||||||
|
|
||||||
|
// Имена пользователей — через порт: Identity живёт в инфраструктуре, и таблицы его
|
||||||
|
// прикладной слой не видит.
|
||||||
|
var userNames = new Dictionary<Guid, string>();
|
||||||
|
foreach (var userId in subscribers.Select(s => s.UserId).Distinct())
|
||||||
|
{
|
||||||
|
if (await identity.GetProfileAsync(userId, cancellationToken) is { } profile)
|
||||||
|
userNames[userId] = profile.UserName;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = subscribers
|
||||||
|
.Select(s => new TelegramSubscriberDto(
|
||||||
|
s.Id,
|
||||||
|
s.ChatId,
|
||||||
|
s.DisplayName,
|
||||||
|
userNames.GetValueOrDefault(s.UserId),
|
||||||
|
s.IsStopped,
|
||||||
|
s.CreatedAt,
|
||||||
|
s.LastSeenAt,
|
||||||
|
s.Subscriptions.GroupBy(x => x.ChannelId)
|
||||||
|
.Select(g => new TelegramSubscriptionDto(
|
||||||
|
g.Key,
|
||||||
|
// Канал могли удалить — подписки на него остаются висеть, и это надо видеть.
|
||||||
|
channelNames.GetValueOrDefault(g.Key) ?? "—",
|
||||||
|
g.Select(x => x.Kind.ToString()).OrderBy(x => x).ToList()
|
||||||
|
))
|
||||||
|
.ToList()
|
||||||
|
))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Result.Success<IReadOnlyList<TelegramSubscriberDto>>(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace TeleWave.Application.Notifications.Subscribers;
|
||||||
|
|
||||||
|
/// <param name="Channels">На что подписан: имя канала и виды оповещений по нему.</param>
|
||||||
|
public sealed record TelegramSubscriberDto(
|
||||||
|
Guid Id,
|
||||||
|
long ChatId,
|
||||||
|
string? DisplayName,
|
||||||
|
string? UserName,
|
||||||
|
bool IsStopped,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? LastSeenAt,
|
||||||
|
IReadOnlyList<TelegramSubscriptionDto> Channels
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record TelegramSubscriptionDto(
|
||||||
|
Guid ChannelId,
|
||||||
|
string ChannelName,
|
||||||
|
IReadOnlyList<string> Kinds
|
||||||
|
);
|
||||||
@@ -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 = "Канала больше нет";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Notifications;
|
||||||
|
|
||||||
|
public static class TelegramErrors
|
||||||
|
{
|
||||||
|
public static readonly Error NotConfigured = Error.Validation(
|
||||||
|
"Telegram.NotConfigured",
|
||||||
|
"Бот не настроен: укажите токен от @BotFather."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error WebhookUrlRequired = Error.Validation(
|
||||||
|
"Telegram.WebhookUrlRequired",
|
||||||
|
"Для режима вебхука нужен публичный HTTPS-адрес."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error ProxyHostRequired = Error.Validation(
|
||||||
|
"Telegram.ProxyHostRequired",
|
||||||
|
"Для прокси нужен адрес и порт."
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Telegram ответил отказом — текст его же, он объясняет причину лучше нашего.</summary>
|
||||||
|
public static Error Unreachable(string details) =>
|
||||||
|
Error.Validation("Telegram.Unreachable", $"Telegram не ответил: {details}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Рассылка по ленте: «в эфире началось» и «скоро начнётся».
|
||||||
|
///
|
||||||
|
/// Считается по материализованному расписанию, а не по факту раздачи: лента и есть эфир, а ждать
|
||||||
|
/// подтверждения от плеера не от кого — зрителей может не быть вовсе, а оповещение всё равно нужно.
|
||||||
|
///
|
||||||
|
/// Граница разобранного (<see cref="TelegramSettings.NotifiedUntil"/>) двигается всегда, даже когда
|
||||||
|
/// подписчиков нет: иначе первый же подписавшийся получил бы пачку сообщений про всё, что успело
|
||||||
|
/// пройти с момента настройки бота.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TelegramNotifier(IAppDbContext dbContext, ITelegramApi api)
|
||||||
|
{
|
||||||
|
/// <summary>За сколько предупреждать о следующей программе.</summary>
|
||||||
|
public static readonly TimeSpan UpNextLead = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Насколько далеко в прошлое смотреть при первом заходе. Без потолка перезапуск после долгого
|
||||||
|
/// простоя разослал бы «началось» про весь вчерашний эфир.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan MaxCatchUp = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
public async Task<int> RunAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var from =
|
||||||
|
settings.NotifiedUntil is { } marker && now - marker < MaxCatchUp
|
||||||
|
? marker
|
||||||
|
: now - MaxCatchUp;
|
||||||
|
|
||||||
|
var sent = 0;
|
||||||
|
sent += await NotifyStartedAsync(settings, from, now, cancellationToken);
|
||||||
|
sent += await NotifyUpNextAsync(settings, from, now, cancellationToken);
|
||||||
|
|
||||||
|
settings.MarkNotifiedUntil(now);
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Начавшиеся программы. Оповещаем о смене шоу, а не о каждой записи: четыре серии подряд —
|
||||||
|
/// это одна программа в глазах зрителя, и четыре сообщения о ней читаются как спам.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> NotifyStartedAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
DateTimeOffset from,
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var started = await dbContext
|
||||||
|
.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(e =>
|
||||||
|
e.Kind == ScheduleEntryKind.Program
|
||||||
|
&& e.ShowId != null
|
||||||
|
&& e.StartsAtUtc > from
|
||||||
|
&& e.StartsAtUtc <= now
|
||||||
|
)
|
||||||
|
.OrderBy(e => e.StartsAtUtc)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sent = 0;
|
||||||
|
foreach (var entry in started)
|
||||||
|
{
|
||||||
|
if (!await IsShowChangeAsync(entry, cancellationToken))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var title = await ShowNameAsync(entry.ShowId!.Value, cancellationToken);
|
||||||
|
if (title is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
sent += await SendAsync(
|
||||||
|
settings,
|
||||||
|
entry.ChannelId,
|
||||||
|
TelegramNotificationKind.ShowStart,
|
||||||
|
$"▶️ <b>{Escape(title)}</b>\nСейчас в эфире",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Что начнётся через <see cref="UpNextLead"/> — предупреждаем один раз, в своё окно.</summary>
|
||||||
|
private async Task<int> NotifyUpNextAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
DateTimeOffset from,
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var soon = await dbContext
|
||||||
|
.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(e =>
|
||||||
|
e.Kind == ScheduleEntryKind.Program
|
||||||
|
&& e.ShowId != null
|
||||||
|
&& e.StartsAtUtc > from + UpNextLead
|
||||||
|
&& e.StartsAtUtc <= now + UpNextLead
|
||||||
|
)
|
||||||
|
.OrderBy(e => e.StartsAtUtc)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sent = 0;
|
||||||
|
foreach (var entry in soon)
|
||||||
|
{
|
||||||
|
if (!await IsShowChangeAsync(entry, cancellationToken))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var title = await ShowNameAsync(entry.ShowId!.Value, cancellationToken);
|
||||||
|
if (title is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var minutes = Math.Max(1, (int)Math.Round((entry.StartsAtUtc - now).TotalMinutes));
|
||||||
|
sent += await SendAsync(
|
||||||
|
settings,
|
||||||
|
entry.ChannelId,
|
||||||
|
TelegramNotificationKind.UpNext,
|
||||||
|
$"🕔 Через {minutes} мин: <b>{Escape(title)}</b>",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сменилось ли шоу этой записью. Предыдущая запись канала берётся из ленты: врезки и заставки
|
||||||
|
/// в счёт не идут — реклама между сериями не делает следующую серию новой программой.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> IsShowChangeAsync(
|
||||||
|
ScheduleEntry entry,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var previousShowId = await dbContext
|
||||||
|
.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(e =>
|
||||||
|
e.ChannelId == entry.ChannelId
|
||||||
|
&& e.Kind == ScheduleEntryKind.Program
|
||||||
|
&& e.ShowId != null
|
||||||
|
&& e.StartsAtUtc < entry.StartsAtUtc
|
||||||
|
)
|
||||||
|
.OrderByDescending(e => e.StartsAtUtc)
|
||||||
|
.Select(e => e.ShowId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return previousShowId != entry.ShowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<string?> ShowNameAsync(Guid showId, CancellationToken cancellationToken) =>
|
||||||
|
dbContext
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => s.Id == showId)
|
||||||
|
.Select(s => s.Name)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Шлёт всем, кто подписан на этот канал и вид. Возвращает число доставленных сообщений.</summary>
|
||||||
|
private async Task<int> SendAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
Guid channelId,
|
||||||
|
TelegramNotificationKind kind,
|
||||||
|
string text,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var chats = await (
|
||||||
|
from subscription in dbContext.TelegramSubscriptions
|
||||||
|
join subscriber in dbContext.TelegramSubscribers
|
||||||
|
on subscription.SubscriberId equals subscriber.Id
|
||||||
|
where
|
||||||
|
subscription.ChannelId == channelId
|
||||||
|
&& subscription.Kind == kind
|
||||||
|
&& !subscriber.IsStopped
|
||||||
|
select subscriber.ChatId
|
||||||
|
)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var sent = 0;
|
||||||
|
foreach (var chatId in chats)
|
||||||
|
{
|
||||||
|
// Один недоступный чат (бота заблокировали) не должен срывать рассылку остальным.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await api.SendMessageAsync(settings, chatId, text, null, cancellationToken);
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
settings.MarkError(exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Экранирование под HTML-разметку Telegram: в названиях шоу встречаются «&» и кавычки.</summary>
|
||||||
|
private static string Escape(string value) =>
|
||||||
|
value.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Одноразовый код привязки чата к учётке. Зритель нажимает «открыть чат» на странице эфира,
|
||||||
|
/// код уезжает в ссылку <c>t.me/bot?start=КОД</c>, и бот по нему узнаёт, кто пришёл.
|
||||||
|
///
|
||||||
|
/// Живёт минуты и сгорает при первом использовании: ссылка попадает в историю браузера и в чат,
|
||||||
|
/// а вечный код означал бы вечный пропуск к чужим оповещениям.
|
||||||
|
/// </summary>
|
||||||
|
public class TelegramLinkCode
|
||||||
|
{
|
||||||
|
/// <summary>Сколько код действителен.</summary>
|
||||||
|
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
public string Code { get; private set; } = string.Empty;
|
||||||
|
public Guid UserId { get; private set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
public DateTimeOffset ExpiresAt { get; private set; }
|
||||||
|
public DateTimeOffset? UsedAt { get; private set; }
|
||||||
|
|
||||||
|
private TelegramLinkCode() { }
|
||||||
|
|
||||||
|
public static TelegramLinkCode Issue(string code, Guid userId, DateTimeOffset now) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = code,
|
||||||
|
UserId = userId,
|
||||||
|
CreatedAt = now,
|
||||||
|
ExpiresAt = now + Lifetime,
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool IsUsable(DateTimeOffset now) => UsedAt is null && now < ExpiresAt;
|
||||||
|
|
||||||
|
public void MarkUsed(DateTimeOffset now) => UsedAt = now;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// О чём оповещать. Подписка хранится парой «канал + вид», поэтому зритель может следить
|
||||||
|
/// за началом шоу на одном канале и не получать ничего с другого.
|
||||||
|
/// </summary>
|
||||||
|
public enum TelegramNotificationKind
|
||||||
|
{
|
||||||
|
/// <summary>В эфире началось новое шоу (смена программы, а не очередная серия того же).</summary>
|
||||||
|
ShowStart = 0,
|
||||||
|
|
||||||
|
/// <summary>Через несколько минут начнётся следующая программа.</summary>
|
||||||
|
UpNext = 1,
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
/// <summary>Через что ходить к api.telegram.org.</summary>
|
||||||
|
public enum TelegramProxyKind
|
||||||
|
{
|
||||||
|
/// <summary>Напрямую.</summary>
|
||||||
|
None = 0,
|
||||||
|
Http = 1,
|
||||||
|
Socks5 = 2,
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
namespace TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настройки бота расписания — одна строка на установку.
|
||||||
|
///
|
||||||
|
/// Отдельная таблица, а не набор ключей в <c>AppSetting</c>: полей десяток, они меняются вместе
|
||||||
|
/// и вместе же должны быть согласованы («вебхук без адреса» — не состояние, а поломка). Токен
|
||||||
|
/// лежит открытым текстом: шифровать его на диске нечем (Data Protection в проекте сознательно
|
||||||
|
/// не заводили), поэтому наружу он не отдаётся — админка видит только признак «задан».
|
||||||
|
/// </summary>
|
||||||
|
public class TelegramSettings
|
||||||
|
{
|
||||||
|
/// <summary>Единственная строка настроек: идентификатор фиксированный, чтобы не заводить второй.</summary>
|
||||||
|
public static readonly Guid SingletonId = new("11111111-2222-3333-4444-555555555555");
|
||||||
|
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Бот включён: без этого ни опрос, ни рассылка не идут, даже если токен задан.</summary>
|
||||||
|
public bool IsEnabled { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Токен от @BotFather. Пусто — бот не настроен.</summary>
|
||||||
|
public string? BotToken { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Имя бота (@name) — из него собирается ссылка на чат для зрителя.</summary>
|
||||||
|
public string? BotUsername { get; private set; }
|
||||||
|
|
||||||
|
public TelegramTransport Transport { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Публичный адрес для вебхука (https://host/api/telegram/webhook).</summary>
|
||||||
|
public string? WebhookUrl { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Секрет вебхука: Telegram присылает его заголовком, и по нему мы отличаем настоящий вызов
|
||||||
|
/// от постороннего, нашедшего адрес. Генерируется при сохранении, наружу не отдаётся.
|
||||||
|
/// </summary>
|
||||||
|
public string? WebhookSecret { get; private set; }
|
||||||
|
|
||||||
|
public TelegramProxyKind ProxyKind { get; private set; }
|
||||||
|
public string? ProxyHost { get; private set; }
|
||||||
|
public int? ProxyPort { get; private set; }
|
||||||
|
public string? ProxyUsername { get; private set; }
|
||||||
|
public string? ProxyPassword { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Смещение опроса: с какого обновления продолжать после перезапуска.</summary>
|
||||||
|
public long LastUpdateId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Когда бот в последний раз успешно достучался до Telegram — признак живой связи.</summary>
|
||||||
|
public DateTimeOffset? LastContactAt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Последняя ошибка связи: её показывает админка вместо молчаливого «ничего не приходит».</summary>
|
||||||
|
public string? LastError { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// До какого момента лента уже разобрана рассылкой. Без этой отметки перезапуск сервиса
|
||||||
|
/// заново оповестил бы обо всём, что успело начаться.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset? NotifiedUntil { get; private set; }
|
||||||
|
|
||||||
|
private TelegramSettings() { }
|
||||||
|
|
||||||
|
public static TelegramSettings CreateDefault() =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = SingletonId,
|
||||||
|
IsEnabled = false,
|
||||||
|
Transport = TelegramTransport.Polling,
|
||||||
|
ProxyKind = TelegramProxyKind.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Настроен ли бот настолько, чтобы вообще пытаться работать.</summary>
|
||||||
|
public bool IsOperational =>
|
||||||
|
IsEnabled
|
||||||
|
&& !string.IsNullOrWhiteSpace(BotToken)
|
||||||
|
&& (Transport == TelegramTransport.Polling || !string.IsNullOrWhiteSpace(WebhookUrl));
|
||||||
|
|
||||||
|
public void Update(TelegramSettingsUpdate update)
|
||||||
|
{
|
||||||
|
IsEnabled = update.IsEnabled;
|
||||||
|
Transport = update.Transport;
|
||||||
|
WebhookUrl = Normalize(update.WebhookUrl);
|
||||||
|
ProxyKind = update.ProxyKind;
|
||||||
|
ProxyHost = Normalize(update.ProxyHost);
|
||||||
|
ProxyPort = update.ProxyPort;
|
||||||
|
ProxyUsername = Normalize(update.ProxyUsername);
|
||||||
|
|
||||||
|
// Пустое значение секрета означает «не менять»: наружу мы их не отдаём, и форма
|
||||||
|
// возвращает пустоту там, где админ просто ничего не трогал.
|
||||||
|
if (!string.IsNullOrWhiteSpace(update.BotToken))
|
||||||
|
BotToken = update.BotToken.Trim();
|
||||||
|
if (update.ProxyPassword is not null)
|
||||||
|
ProxyPassword = Normalize(update.ProxyPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Снимает токен: бот перестаёт быть настроенным.</summary>
|
||||||
|
public void ClearToken()
|
||||||
|
{
|
||||||
|
BotToken = null;
|
||||||
|
BotUsername = null;
|
||||||
|
IsEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Имя бота, узнанное у Telegram (getMe): по нему строится ссылка на чат.</summary>
|
||||||
|
public void SetBotUsername(string? username) => BotUsername = Normalize(username);
|
||||||
|
|
||||||
|
public void SetWebhookSecret(string? secret) => WebhookSecret = Normalize(secret);
|
||||||
|
|
||||||
|
/// <summary>Двигает смещение опроса. Только вперёд: повторный разбор старых обновлений
|
||||||
|
/// разослал бы уже отправленные ответы заново.</summary>
|
||||||
|
public void AdvanceUpdateId(long updateId)
|
||||||
|
{
|
||||||
|
if (updateId > LastUpdateId)
|
||||||
|
LastUpdateId = updateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Двигает границу разобранной рассылкой ленты — только вперёд.</summary>
|
||||||
|
public void MarkNotifiedUntil(DateTimeOffset at)
|
||||||
|
{
|
||||||
|
if (NotifiedUntil is null || at > NotifiedUntil)
|
||||||
|
NotifiedUntil = at;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkContact(DateTimeOffset at)
|
||||||
|
{
|
||||||
|
LastContactAt = at;
|
||||||
|
LastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkError(string error) => LastError = error.Length > 512 ? error[..512] : error;
|
||||||
|
|
||||||
|
private static string? Normalize(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Что админ меняет в настройках. Токен и пароль прокси необязательны: пустое значение означает
|
||||||
|
/// «оставить как есть» — форма их не показывает и вернуть не может.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TelegramSettingsUpdate(
|
||||||
|
bool IsEnabled,
|
||||||
|
TelegramTransport Transport,
|
||||||
|
string? BotToken,
|
||||||
|
string? WebhookUrl,
|
||||||
|
TelegramProxyKind ProxyKind,
|
||||||
|
string? ProxyHost,
|
||||||
|
int? ProxyPort,
|
||||||
|
string? ProxyUsername,
|
||||||
|
string? ProxyPassword
|
||||||
|
);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
namespace TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Зритель в телеграме: чат, куда шлём, и учётка, которой он представился.
|
||||||
|
///
|
||||||
|
/// Привязка к пользователю обязательна по построению — бот отвечает только на одноразовый код
|
||||||
|
/// из админки. Иначе рассылка уходила бы кому угодно, кто нашёл бота, а отписать его при
|
||||||
|
/// блокировке учётки было бы нечем.
|
||||||
|
/// </summary>
|
||||||
|
public class TelegramSubscriber
|
||||||
|
{
|
||||||
|
private readonly List<TelegramSubscription> _subscriptions = [];
|
||||||
|
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Идентификатор чата в Telegram — им же адресуется отправка.</summary>
|
||||||
|
public long ChatId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Пользователь TeleWave, с которым связан чат.</summary>
|
||||||
|
public Guid UserId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Как зритель подписан в телеграме — только для списка в админке.</summary>
|
||||||
|
public string? DisplayName { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Последняя активность: по ней видно, живой чат или зритель давно ушёл.</summary>
|
||||||
|
public DateTimeOffset? LastSeenAt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чат закрыт: зритель нажал «стоп» или заблокировал бота. Подписки при этом остаются —
|
||||||
|
/// вернувшись, он получит свои каналы, а не пустой список.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsStopped { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<TelegramSubscription> Subscriptions => _subscriptions;
|
||||||
|
|
||||||
|
private TelegramSubscriber() { }
|
||||||
|
|
||||||
|
public static TelegramSubscriber Create(
|
||||||
|
long chatId,
|
||||||
|
Guid userId,
|
||||||
|
string? displayName,
|
||||||
|
DateTimeOffset now
|
||||||
|
) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = chatId,
|
||||||
|
UserId = userId,
|
||||||
|
DisplayName = displayName,
|
||||||
|
CreatedAt = now,
|
||||||
|
LastSeenAt = now,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Тот же чат представился заново — например, по новому коду привязки.</summary>
|
||||||
|
public void Rebind(Guid userId, string? displayName, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
UserId = userId;
|
||||||
|
DisplayName = displayName;
|
||||||
|
LastSeenAt = now;
|
||||||
|
IsStopped = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Touch(DateTimeOffset now) => LastSeenAt = now;
|
||||||
|
|
||||||
|
public void Stop() => IsStopped = true;
|
||||||
|
|
||||||
|
public void Resume() => IsStopped = false;
|
||||||
|
|
||||||
|
/// <summary>Переключает подписку и возвращает её новое состояние.</summary>
|
||||||
|
public bool Toggle(Guid channelId, TelegramNotificationKind kind)
|
||||||
|
{
|
||||||
|
var existing = _subscriptions.FirstOrDefault(s =>
|
||||||
|
s.ChannelId == channelId && s.Kind == kind
|
||||||
|
);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
_subscriptions.Remove(existing);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_subscriptions.Add(TelegramSubscription.Create(Id, channelId, kind));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsSubscribed(Guid channelId, TelegramNotificationKind kind) =>
|
||||||
|
_subscriptions.Any(s => s.ChannelId == channelId && s.Kind == kind);
|
||||||
|
|
||||||
|
/// <summary>Снимает подписки на канал — его удалили, и слать по нему больше нечего.</summary>
|
||||||
|
public void DropChannel(Guid channelId) =>
|
||||||
|
_subscriptions.RemoveAll(s => s.ChannelId == channelId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
/// <summary>Одна подписка: «этот чат, этот канал, этот вид оповещений».</summary>
|
||||||
|
public class TelegramSubscription
|
||||||
|
{
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
public Guid SubscriberId { get; private set; }
|
||||||
|
public Guid ChannelId { get; private set; }
|
||||||
|
public TelegramNotificationKind Kind { get; private set; }
|
||||||
|
|
||||||
|
private TelegramSubscription() { }
|
||||||
|
|
||||||
|
public static TelegramSubscription Create(
|
||||||
|
Guid subscriberId,
|
||||||
|
Guid channelId,
|
||||||
|
TelegramNotificationKind kind
|
||||||
|
) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
SubscriberId = subscriberId,
|
||||||
|
ChannelId = channelId,
|
||||||
|
Kind = kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
/// <summary>Как бот получает обновления от Telegram.</summary>
|
||||||
|
public enum TelegramTransport
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Сам ходит за обновлениями (getUpdates). Работает из-за NAT и через прокси — там, где
|
||||||
|
/// публичного адреса нет, это единственный вариант.
|
||||||
|
/// </summary>
|
||||||
|
Polling = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telegram стучится на наш HTTPS-эндпоинт. Дешевле и быстрее, но требует доступного извне
|
||||||
|
/// домена с сертификатом; прокси здесь не поможет — соединение устанавливает Telegram.
|
||||||
|
/// </summary>
|
||||||
|
Webhook = 1,
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ using TeleWave.Infrastructure.Identity;
|
|||||||
using TeleWave.Infrastructure.Library;
|
using TeleWave.Infrastructure.Library;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Infrastructure.Media;
|
||||||
using TeleWave.Infrastructure.Metadata;
|
using TeleWave.Infrastructure.Metadata;
|
||||||
|
using TeleWave.Infrastructure.Notifications;
|
||||||
using TeleWave.Infrastructure.Persistence;
|
using TeleWave.Infrastructure.Persistence;
|
||||||
using TeleWave.Infrastructure.Settings;
|
using TeleWave.Infrastructure.Settings;
|
||||||
using TeleWave.Infrastructure.Streaming;
|
using TeleWave.Infrastructure.Streaming;
|
||||||
@@ -155,6 +156,9 @@ public static class DependencyInjection
|
|||||||
configuration.GetSection(DebugExportOptions.SectionName)
|
configuration.GetSection(DebugExportOptions.SectionName)
|
||||||
);
|
);
|
||||||
services.AddSingleton<IDebugExportStore, DebugExportStore>();
|
services.AddSingleton<IDebugExportStore, DebugExportStore>();
|
||||||
|
services.AddScoped<ITelegramApi, TelegramApi>();
|
||||||
|
services.AddHostedService<TelegramPollingBackgroundService>();
|
||||||
|
services.AddHostedService<TelegramNotifierBackgroundService>();
|
||||||
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
|
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
|
||||||
|
|
||||||
services.AddSingleton<MediaPathResolver>();
|
services.AddSingleton<MediaPathResolver>();
|
||||||
|
|||||||
+1590
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class TelegramBot : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TelegramLinkCodes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Code = table.Column<string>(
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: false
|
||||||
|
),
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false
|
||||||
|
),
|
||||||
|
ExpiresAt = table.Column<DateTimeOffset>(
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false
|
||||||
|
),
|
||||||
|
UsedAt = table.Column<DateTimeOffset>(
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TelegramLinkCodes", x => x.Id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TelegramSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
BotToken = table.Column<string>(
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
BotUsername = table.Column<string>(
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
Transport = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
WebhookUrl = table.Column<string>(
|
||||||
|
type: "character varying(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
WebhookSecret = table.Column<string>(
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
ProxyKind = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ProxyHost = table.Column<string>(
|
||||||
|
type: "character varying(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
ProxyPort = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
ProxyUsername = table.Column<string>(
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
ProxyPassword = table.Column<string>(
|
||||||
|
type: "character varying(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
LastUpdateId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
LastContactAt = table.Column<DateTimeOffset>(
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
LastError = table.Column<string>(
|
||||||
|
type: "character varying(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TelegramSettings", x => x.Id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TelegramSubscribers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ChatId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
DisplayName = table.Column<string>(
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false
|
||||||
|
),
|
||||||
|
LastSeenAt = table.Column<DateTimeOffset>(
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
IsStopped = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TelegramSubscribers", x => x.Id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TelegramSubscriptions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
SubscriberId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Kind = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TelegramSubscriptions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TelegramSubscriptions_TelegramSubscribers_SubscriberId",
|
||||||
|
column: x => x.SubscriberId,
|
||||||
|
principalTable: "TelegramSubscribers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TelegramLinkCodes_Code",
|
||||||
|
table: "TelegramLinkCodes",
|
||||||
|
column: "Code",
|
||||||
|
unique: true
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TelegramSubscribers_ChatId",
|
||||||
|
table: "TelegramSubscribers",
|
||||||
|
column: "ChatId",
|
||||||
|
unique: true
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TelegramSubscribers_UserId",
|
||||||
|
table: "TelegramSubscribers",
|
||||||
|
column: "UserId"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TelegramSubscriptions_ChannelId_Kind",
|
||||||
|
table: "TelegramSubscriptions",
|
||||||
|
columns: new[] { "ChannelId", "Kind" }
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TelegramSubscriptions_SubscriberId_ChannelId_Kind",
|
||||||
|
table: "TelegramSubscriptions",
|
||||||
|
columns: new[] { "SubscriberId", "ChannelId", "Kind" },
|
||||||
|
unique: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(name: "TelegramLinkCodes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(name: "TelegramSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(name: "TelegramSubscriptions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(name: "TelegramSubscribers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1593
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class TelegramNotifiedMarker : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "NotifiedUntil",
|
||||||
|
table: "TelegramSettings",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(name: "NotifiedUntil", table: "TelegramSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -736,6 +736,157 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.ToTable("MediaAssets");
|
b.ToTable("MediaAssets");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramLinkCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UsedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TelegramLinkCodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("BotToken")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("BotUsername")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastContactAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("LastError")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<long>("LastUpdateId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("NotifiedUntil")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ProxyHost")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<int>("ProxyKind")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ProxyPassword")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<int?>("ProxyPort")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ProxyUsername")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("Transport")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("WebhookSecret")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<string>("WebhookUrl")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("TelegramSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscriber", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<long>("ChatId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsStopped")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChatId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("TelegramSubscribers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscription", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("SubscriberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId", "Kind");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriberId", "ChannelId", "Kind")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TelegramSubscriptions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
|
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1313,6 +1464,15 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscription", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TeleWave.Domain.Notifications.TelegramSubscriber", null)
|
||||||
|
.WithMany("Subscriptions")
|
||||||
|
.HasForeignKey("SubscriberId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
|
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null)
|
b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null)
|
||||||
@@ -1400,6 +1560,11 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Navigation("Genres");
|
b.Navigation("Genres");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscriber", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Subscriptions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
|
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Slots");
|
b.Navigation("Slots");
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bot API поверх обычного <see cref="HttpClient"/>. Сторонняя обёртка не заводится намеренно:
|
||||||
|
/// боту нужно шесть вызовов, а библиотека потянула бы свою модель обновлений, свой цикл опроса
|
||||||
|
/// и своё представление о прокси — то есть ровно то, чем мы управляем сами.
|
||||||
|
///
|
||||||
|
/// Клиент собирается на каждый вызов из настроек: токен и прокси админ меняет в рантайме,
|
||||||
|
/// а закешированный клиент означал бы перезапуск контейнера ради смены прокси.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TelegramApi(ILogger<TelegramApi> logger) : ITelegramApi
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Сколько висеть на getUpdates. Длинный опрос — это одно соединение вместо частых запросов;
|
||||||
|
/// таймаут клиента заведомо больше, иначе он рвал бы соединение раньше Telegram.
|
||||||
|
/// </summary>
|
||||||
|
private const int LongPollSeconds = 25;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions Json = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<TelegramBotInfo?> GetMeAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await CallAsync<MeResult>(settings, "getMe", null, cancellationToken);
|
||||||
|
return result is { Ok: true, Result: { } me }
|
||||||
|
? new TelegramBotInfo(me.Id, me.Username)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<TelegramUpdate>> GetUpdatesAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
long offset,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
offset,
|
||||||
|
timeout = LongPollSeconds,
|
||||||
|
allowed_updates = new[] { "message", "callback_query" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await CallAsync<UpdatesResult>(
|
||||||
|
settings,
|
||||||
|
"getUpdates",
|
||||||
|
payload,
|
||||||
|
cancellationToken,
|
||||||
|
TimeSpan.FromSeconds(LongPollSeconds + 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
return result?.Result?.Select(Map).ToList() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendMessageAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
long chatId,
|
||||||
|
string text,
|
||||||
|
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) =>
|
||||||
|
CallAsync<OkResult>(
|
||||||
|
settings,
|
||||||
|
"sendMessage",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
chat_id = chatId,
|
||||||
|
text,
|
||||||
|
parse_mode = "HTML",
|
||||||
|
reply_markup = Markup(keyboard),
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
public Task EditMessageAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
long chatId,
|
||||||
|
long messageId,
|
||||||
|
string text,
|
||||||
|
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) =>
|
||||||
|
CallAsync<OkResult>(
|
||||||
|
settings,
|
||||||
|
"editMessageText",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
chat_id = chatId,
|
||||||
|
message_id = messageId,
|
||||||
|
text,
|
||||||
|
parse_mode = "HTML",
|
||||||
|
reply_markup = Markup(keyboard),
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
public Task AnswerCallbackAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
string callbackId,
|
||||||
|
string? text,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) =>
|
||||||
|
CallAsync<OkResult>(
|
||||||
|
settings,
|
||||||
|
"answerCallbackQuery",
|
||||||
|
new { callback_query_id = callbackId, text },
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
public async Task<TelegramUpdate?> ReadUpdateAsync(
|
||||||
|
Stream body,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dto = await JsonSerializer.DeserializeAsync<UpdateDto>(
|
||||||
|
body,
|
||||||
|
Json,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return dto is null ? null : Map(dto);
|
||||||
|
}
|
||||||
|
catch (JsonException exception)
|
||||||
|
{
|
||||||
|
// Мусор в теле вебхука — не повод отвечать ошибкой: Telegram начнёт повторять доставку.
|
||||||
|
logger.LogWarning(exception, "Тело вебхука Telegram не разобрано");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SetWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken) =>
|
||||||
|
CallAsync<OkResult>(
|
||||||
|
settings,
|
||||||
|
"setWebhook",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
url = settings.WebhookUrl,
|
||||||
|
secret_token = settings.WebhookSecret,
|
||||||
|
allowed_updates = new[] { "message", "callback_query" },
|
||||||
|
},
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
public Task DeleteWebhookAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) => CallAsync<OkResult>(settings, "deleteWebhook", null, cancellationToken);
|
||||||
|
|
||||||
|
private static object? Markup(IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard) =>
|
||||||
|
keyboard is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
inline_keyboard = keyboard.Select(row =>
|
||||||
|
row.Select(b => new { text = b.Text, callback_data = b.CallbackData })
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<T?> CallAsync<T>(
|
||||||
|
TelegramSettings settings,
|
||||||
|
string method,
|
||||||
|
object? payload,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
TimeSpan? timeout = null
|
||||||
|
)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.BotToken))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
using var handler = CreateHandler(settings);
|
||||||
|
using var client = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://api.telegram.org/"),
|
||||||
|
Timeout = timeout ?? TimeSpan.FromSeconds(30),
|
||||||
|
};
|
||||||
|
|
||||||
|
var url = $"bot{settings.BotToken}/{method}";
|
||||||
|
using var response = payload is null
|
||||||
|
? await client.PostAsync(url, null, cancellationToken)
|
||||||
|
: await client.PostAsJsonAsync(url, payload, Json, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
// Тело ошибки Telegram — это описание причины («Unauthorized», «chat not found»),
|
||||||
|
// и без него в логе остаётся только код, по которому ничего не понять.
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
throw new TelegramApiException($"{method}: {(int)response.StatusCode} {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(Json, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Транспорт с прокси, если он задан. SOCKS5 поддерживает сам <see cref="WebProxy"/> начиная
|
||||||
|
/// с .NET 6 — достаточно схемы в адресе, отдельной библиотеки не нужно.
|
||||||
|
/// </summary>
|
||||||
|
private HttpClientHandler CreateHandler(TelegramSettings settings)
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler();
|
||||||
|
if (
|
||||||
|
settings.ProxyKind == TelegramProxyKind.None
|
||||||
|
|| string.IsNullOrWhiteSpace(settings.ProxyHost)
|
||||||
|
)
|
||||||
|
return handler;
|
||||||
|
|
||||||
|
var scheme = settings.ProxyKind == TelegramProxyKind.Socks5 ? "socks5" : "http";
|
||||||
|
var port = settings.ProxyPort is > 0 ? settings.ProxyPort.Value : 1080;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var proxy = new WebProxy($"{scheme}://{settings.ProxyHost}:{port}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.ProxyUsername))
|
||||||
|
proxy.Credentials = new NetworkCredential(
|
||||||
|
settings.ProxyUsername,
|
||||||
|
settings.ProxyPassword ?? string.Empty
|
||||||
|
);
|
||||||
|
|
||||||
|
handler.Proxy = proxy;
|
||||||
|
handler.UseProxy = true;
|
||||||
|
}
|
||||||
|
catch (UriFormatException exception)
|
||||||
|
{
|
||||||
|
// Кривой адрес прокси — это ошибка настройки, а не повод молча пойти напрямую:
|
||||||
|
// «работает в обход прокси» на канале, ради которого прокси и заводили, хуже отказа.
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"Неверный адрес прокси Telegram: {Host}",
|
||||||
|
settings.ProxyHost
|
||||||
|
);
|
||||||
|
handler.Dispose();
|
||||||
|
throw new TelegramApiException($"Неверный адрес прокси: {settings.ProxyHost}:{port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TelegramUpdate Map(UpdateDto dto) =>
|
||||||
|
new(
|
||||||
|
dto.UpdateId,
|
||||||
|
dto.Message?.Chat?.Id ?? dto.CallbackQuery?.Message?.Chat?.Id,
|
||||||
|
dto.Message?.Text,
|
||||||
|
dto.Message?.From?.Username ?? dto.CallbackQuery?.From?.Username,
|
||||||
|
dto.CallbackQuery?.Id,
|
||||||
|
dto.CallbackQuery?.Data,
|
||||||
|
dto.CallbackQuery?.Message?.MessageId
|
||||||
|
);
|
||||||
|
|
||||||
|
private sealed record OkResult(bool Ok);
|
||||||
|
|
||||||
|
private sealed record MeResult(bool Ok, MeDto? Result);
|
||||||
|
|
||||||
|
private sealed record MeDto(long Id, string Username);
|
||||||
|
|
||||||
|
private sealed record UpdatesResult(bool Ok, List<UpdateDto>? Result);
|
||||||
|
|
||||||
|
private sealed record UpdateDto(
|
||||||
|
long UpdateId,
|
||||||
|
MessageDto? Message,
|
||||||
|
CallbackQueryDto? CallbackQuery
|
||||||
|
);
|
||||||
|
|
||||||
|
private sealed record MessageDto(long MessageId, ChatDto? Chat, UserDto? From, string? Text);
|
||||||
|
|
||||||
|
private sealed record CallbackQueryDto(
|
||||||
|
string Id,
|
||||||
|
UserDto? From,
|
||||||
|
MessageDto? Message,
|
||||||
|
string? Data
|
||||||
|
);
|
||||||
|
|
||||||
|
private sealed record ChatDto(long Id);
|
||||||
|
|
||||||
|
private sealed record UserDto(string? Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ошибка обращения к Bot API: наружу уходит текстом, который показывает админка.</summary>
|
||||||
|
public sealed class TelegramApiException(string message) : Exception(message);
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Notifications;
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тик рассылки: раз в минуту сверяет ленту с подписками.
|
||||||
|
///
|
||||||
|
/// Минута — компромисс между «сейчас в эфире» и нагрузкой: точнее не нужно (сообщение о начале
|
||||||
|
/// программы с задержкой в полминуты остаётся правдой), а чаще — это лишние запросы к ленте
|
||||||
|
/// круглые сутки.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TelegramNotifierBackgroundService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<TelegramNotifierBackgroundService> logger
|
||||||
|
) : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(Interval);
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TickAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Ошибка рассылки Telegram");
|
||||||
|
}
|
||||||
|
} while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TickAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||||
|
|
||||||
|
var settings = await db.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (settings is null || !settings.IsOperational)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var notifier = scope.ServiceProvider.GetRequiredService<TelegramNotifier>();
|
||||||
|
var sent = await notifier.RunAsync(settings, DateTimeOffset.UtcNow, cancellationToken);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (sent > 0)
|
||||||
|
logger.LogInformation("Telegram: отправлено оповещений — {Count}", sent);
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Notifications;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Опрос обновлений (getUpdates) — режим для установки без публичного адреса.
|
||||||
|
///
|
||||||
|
/// Служба крутится всегда, а не заводится по настройке: бота включают и выключают в рантайме,
|
||||||
|
/// и перезапускать контейнер ради этого никто не должен. Пока бот выключен или выбран вебхук,
|
||||||
|
/// цикл просто дремлет.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TelegramPollingBackgroundService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<TelegramPollingBackgroundService> logger
|
||||||
|
) : BackgroundService
|
||||||
|
{
|
||||||
|
/// <summary>Пауза, когда работать нечего: бот выключен, не настроен или выбран вебхук.</summary>
|
||||||
|
private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
/// <summary>Пауза после ошибки — чтобы недоступный прокси не превращался в поток запросов.</summary>
|
||||||
|
private static readonly TimeSpan ErrorDelay = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
TimeSpan delay;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
delay = await TickAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Ошибка опроса Telegram");
|
||||||
|
delay = ErrorDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delay > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Один заход опроса. Возвращает, сколько ждать перед следующим.</summary>
|
||||||
|
private async Task<TimeSpan> TickAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||||
|
|
||||||
|
var settings = await db.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (
|
||||||
|
settings is null
|
||||||
|
|| !settings.IsOperational
|
||||||
|
|| settings.Transport != TelegramTransport.Polling
|
||||||
|
)
|
||||||
|
return IdleDelay;
|
||||||
|
|
||||||
|
var api = scope.ServiceProvider.GetRequiredService<ITelegramApi>();
|
||||||
|
var bot = scope.ServiceProvider.GetRequiredService<TelegramBotService>();
|
||||||
|
|
||||||
|
IReadOnlyList<TelegramUpdate> updates;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Смещение — «следующее за последним разобранным»: так Telegram сам подтверждает приём
|
||||||
|
// и не отдаёт то же обновление второй раз.
|
||||||
|
updates = await api.GetUpdatesAsync(
|
||||||
|
settings,
|
||||||
|
settings.LastUpdateId + 1,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
settings.MarkError(exception.Message);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogWarning(exception, "Telegram недоступен");
|
||||||
|
return ErrorDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.MarkContact(DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
foreach (var update in updates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.HandleAsync(settings, update, DateTimeOffset.UtcNow, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Одно кривое обновление не должно останавливать разбор остальных — и, главное,
|
||||||
|
// не должно повторяться вечно: смещение двигаем в любом случае.
|
||||||
|
logger.LogError(exception, "Не разобрано обновление {UpdateId}", update.UpdateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.AdvanceUpdateId(update.UpdateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Длинный опрос сам держит соединение — своей паузы между заходами не нужно.
|
||||||
|
return TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using TeleWave.Domain.Broadcast;
|
|||||||
using TeleWave.Domain.Images;
|
using TeleWave.Domain.Images;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Domain.Settings;
|
using TeleWave.Domain.Settings;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
@@ -45,6 +46,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
|
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
|
||||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||||
public DbSet<Image> Images => Set<Image>();
|
public DbSet<Image> Images => Set<Image>();
|
||||||
|
public DbSet<TelegramSettings> TelegramSettings => Set<TelegramSettings>();
|
||||||
|
public DbSet<TelegramSubscriber> TelegramSubscribers => Set<TelegramSubscriber>();
|
||||||
|
public DbSet<TelegramSubscription> TelegramSubscriptions => Set<TelegramSubscription>();
|
||||||
|
public DbSet<TelegramLinkCode> TelegramLinkCodes => Set<TelegramLinkCode>();
|
||||||
|
|
||||||
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
|
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
|
||||||
Database.BeginTransactionAsync(cancellationToken);
|
Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class TelegramSettingsConfiguration : IEntityTypeConfiguration<TelegramSettings>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TelegramSettings> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
builder.Property(x => x.BotToken).HasMaxLength(128);
|
||||||
|
builder.Property(x => x.BotUsername).HasMaxLength(64);
|
||||||
|
builder.Property(x => x.WebhookUrl).HasMaxLength(512);
|
||||||
|
builder.Property(x => x.WebhookSecret).HasMaxLength(128);
|
||||||
|
builder.Property(x => x.ProxyHost).HasMaxLength(256);
|
||||||
|
builder.Property(x => x.ProxyUsername).HasMaxLength(128);
|
||||||
|
builder.Property(x => x.ProxyPassword).HasMaxLength(256);
|
||||||
|
builder.Property(x => x.LastError).HasMaxLength(512);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TelegramSubscriberConfiguration : IEntityTypeConfiguration<TelegramSubscriber>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TelegramSubscriber> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
// Один чат — один подписчик: повторный /start перепривязывает существующего, а не плодит копии.
|
||||||
|
builder.HasIndex(x => x.ChatId).IsUnique();
|
||||||
|
builder.HasIndex(x => x.UserId);
|
||||||
|
builder.Property(x => x.DisplayName).HasMaxLength(128);
|
||||||
|
|
||||||
|
builder
|
||||||
|
.HasMany(x => x.Subscriptions)
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey(x => x.SubscriberId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.Navigation(x => x.Subscriptions).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TelegramSubscriptionConfiguration : IEntityTypeConfiguration<TelegramSubscription>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TelegramSubscription> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
// Рассылка идёт «по каналу и виду» — индекс под тот самый запрос.
|
||||||
|
builder.HasIndex(x => new { x.ChannelId, x.Kind });
|
||||||
|
builder
|
||||||
|
.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.SubscriberId,
|
||||||
|
x.ChannelId,
|
||||||
|
x.Kind,
|
||||||
|
})
|
||||||
|
.IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TelegramLinkCodeConfiguration : IEntityTypeConfiguration<TelegramLinkCode>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TelegramLinkCode> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
builder.HasIndex(x => x.Code).IsUnique();
|
||||||
|
builder.Property(x => x.Code).IsRequired().HasMaxLength(64);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Notifications;
|
||||||
|
using TeleWave.Application.Tests.Support;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Разговор с ботом. Главное свойство — подписаться можно только по одноразовому коду: рассылка
|
||||||
|
/// уходит в личные чаты, и «кто нашёл бота, тот и подписался» здесь означало бы чужие оповещения.
|
||||||
|
/// </summary>
|
||||||
|
public class TelegramBotTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
private static TelegramSettings Settings()
|
||||||
|
{
|
||||||
|
var settings = TelegramSettings.CreateDefault();
|
||||||
|
settings.Update(
|
||||||
|
new TelegramSettingsUpdate(
|
||||||
|
true,
|
||||||
|
TelegramTransport.Polling,
|
||||||
|
"123:ABC",
|
||||||
|
null,
|
||||||
|
TelegramProxyKind.None,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TelegramUpdate Message(long chatId, string text) =>
|
||||||
|
new(1, chatId, text, "viewer", null, null, null);
|
||||||
|
|
||||||
|
private static TelegramUpdate Callback(long chatId, string data) =>
|
||||||
|
new(2, chatId, null, "viewer", "cb-1", data, 55);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_WithoutCode_DoesNotSubscribeAnyone()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var api = Substitute.For<ITelegramApi>();
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
await new TelegramBotService(db, api).HandleAsync(
|
||||||
|
Settings(),
|
||||||
|
Message(100, "/start"),
|
||||||
|
Now,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Empty(db.TelegramSubscribers);
|
||||||
|
// Незнакомцу объясняют, где взять код, а не молчат в ответ.
|
||||||
|
await api.Received(1)
|
||||||
|
.SendMessageAsync(
|
||||||
|
Arg.Any<TelegramSettings>(),
|
||||||
|
100,
|
||||||
|
Arg.Is<string>(s => s.Contains("код")),
|
||||||
|
null,
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_WithCode_LinksChatToUserAndBurnsCode()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var code = TelegramLinkCode.Issue("abc123", userId, Now);
|
||||||
|
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||||
|
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.TelegramLinkCodes.Add(code);
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
await new TelegramBotService(db, Substitute.For<ITelegramApi>()).HandleAsync(
|
||||||
|
Settings(),
|
||||||
|
Message(100, "/start abc123"),
|
||||||
|
Now,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
var subscriber = Assert.Single(db.TelegramSubscribers);
|
||||||
|
Assert.Equal(userId, subscriber.UserId);
|
||||||
|
Assert.Equal(100, subscriber.ChatId);
|
||||||
|
// Код одноразовый: ссылка оседает в истории браузера и в чате.
|
||||||
|
Assert.NotNull(db.TelegramLinkCodes.Single().UsedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_WithUsedCode_IsRefused()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var code = TelegramLinkCode.Issue("abc123", Guid.NewGuid(), Now);
|
||||||
|
code.MarkUsed(Now);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.TelegramLinkCodes.Add(code);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
await new TelegramBotService(db, Substitute.For<ITelegramApi>()).HandleAsync(
|
||||||
|
Settings(),
|
||||||
|
Message(100, "/start abc123"),
|
||||||
|
Now,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Empty(db.TelegramSubscribers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Callback_TogglesSubscriptionBothWays()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||||
|
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
||||||
|
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
seed.TelegramSubscribers.Add(subscriber);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = $"{channel.Id}:{TelegramNotificationKind.ShowStart}";
|
||||||
|
|
||||||
|
await using (var db = fixture.New())
|
||||||
|
{
|
||||||
|
await new TelegramBotService(db, Substitute.For<ITelegramApi>()).HandleAsync(
|
||||||
|
Settings(),
|
||||||
|
Callback(100, data),
|
||||||
|
Now,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var check = fixture.New())
|
||||||
|
{
|
||||||
|
Assert.Single(check.TelegramSubscriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = fixture.New())
|
||||||
|
{
|
||||||
|
await new TelegramBotService(db, Substitute.For<ITelegramApi>()).HandleAsync(
|
||||||
|
Settings(),
|
||||||
|
Callback(100, data),
|
||||||
|
Now,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = fixture.New();
|
||||||
|
// Повторное нажатие выключает: кнопка — тумблер, а не «добавить ещё одну подписку».
|
||||||
|
Assert.Empty(verify.TelegramSubscriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Callback_FromUnknownChat_IsRefused()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var api = Substitute.For<ITelegramApi>();
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
await new TelegramBotService(db, api).HandleAsync(
|
||||||
|
Settings(),
|
||||||
|
Callback(100, $"{Guid.NewGuid()}:{TelegramNotificationKind.ShowStart}"),
|
||||||
|
Now,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Empty(db.TelegramSubscriptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Notifications;
|
||||||
|
using TeleWave.Application.Tests.Support;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Рассылка по ленте. Проверяется то, из-за чего бот становится спамом: оповещение шлётся
|
||||||
|
/// на смену шоу, а не на каждую серию, и только тем, кто подписан на этот канал.
|
||||||
|
/// </summary>
|
||||||
|
public class TelegramNotifierTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset Now = new(2026, 8, 1, 20, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
private static TelegramSettings Settings()
|
||||||
|
{
|
||||||
|
var settings = TelegramSettings.CreateDefault();
|
||||||
|
settings.Update(
|
||||||
|
new TelegramSettingsUpdate(
|
||||||
|
true,
|
||||||
|
TelegramTransport.Polling,
|
||||||
|
"123:ABC",
|
||||||
|
null,
|
||||||
|
TelegramProxyKind.None,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScheduleEntry Program(
|
||||||
|
Guid channelId,
|
||||||
|
Guid showId,
|
||||||
|
DateTimeOffset start,
|
||||||
|
int minutes
|
||||||
|
) =>
|
||||||
|
ScheduleEntry.FromSlot(
|
||||||
|
channelId,
|
||||||
|
Guid.NewGuid(),
|
||||||
|
ScheduleEntryKind.Program,
|
||||||
|
start,
|
||||||
|
start.AddMinutes(minutes),
|
||||||
|
new ScheduleEntryOrigin(showId, 0, null, null, null)
|
||||||
|
);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Notifies_OnlySubscribersOfTheChannel_AndOnlyOnShowChange()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||||
|
var other = Channel.Create("Второй", "second", Now);
|
||||||
|
var show = Show.Create("Футурама", ShowKind.Series);
|
||||||
|
var another = Show.Create("Симпсоны", ShowKind.Series);
|
||||||
|
|
||||||
|
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
||||||
|
subscriber.Toggle(channel.Id, TelegramNotificationKind.ShowStart);
|
||||||
|
var stranger = TelegramSubscriber.Create(200, Guid.NewGuid(), "other", Now);
|
||||||
|
stranger.Toggle(other.Id, TelegramNotificationKind.ShowStart);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.Channels.AddRange(channel, other);
|
||||||
|
seed.Shows.AddRange(show, another);
|
||||||
|
seed.TelegramSubscribers.AddRange(subscriber, stranger);
|
||||||
|
// Предыдущая программа того же шоу: следующая серия сменой не считается.
|
||||||
|
seed.ScheduleEntries.Add(Program(channel.Id, show.Id, Now.AddMinutes(-30), 25));
|
||||||
|
seed.ScheduleEntries.Add(Program(channel.Id, show.Id, Now.AddMinutes(-2), 25));
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var api = Substitute.For<ITelegramApi>();
|
||||||
|
var settings = Settings();
|
||||||
|
settings.MarkNotifiedUntil(Now.AddMinutes(-5));
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var sent = await new TelegramNotifier(db, api).RunAsync(
|
||||||
|
settings,
|
||||||
|
Now,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(0, sent);
|
||||||
|
await api.DidNotReceive()
|
||||||
|
.SendMessageAsync(
|
||||||
|
Arg.Any<TelegramSettings>(),
|
||||||
|
Arg.Any<long>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<IReadOnlyList<IReadOnlyList<TelegramButton>>?>(),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Notifies_WhenShowChanges()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||||
|
var previous = Show.Create("Симпсоны", ShowKind.Series);
|
||||||
|
var started = Show.Create("Футурама", ShowKind.Series);
|
||||||
|
|
||||||
|
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
||||||
|
subscriber.Toggle(channel.Id, TelegramNotificationKind.ShowStart);
|
||||||
|
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
seed.Shows.AddRange(previous, started);
|
||||||
|
seed.TelegramSubscribers.Add(subscriber);
|
||||||
|
seed.ScheduleEntries.Add(Program(channel.Id, previous.Id, Now.AddMinutes(-30), 25));
|
||||||
|
seed.ScheduleEntries.Add(Program(channel.Id, started.Id, Now.AddMinutes(-1), 25));
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var api = Substitute.For<ITelegramApi>();
|
||||||
|
var settings = Settings();
|
||||||
|
settings.MarkNotifiedUntil(Now.AddMinutes(-5));
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var sent = await new TelegramNotifier(db, api).RunAsync(
|
||||||
|
settings,
|
||||||
|
Now,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(1, sent);
|
||||||
|
await api.Received(1)
|
||||||
|
.SendMessageAsync(
|
||||||
|
Arg.Any<TelegramSettings>(),
|
||||||
|
100,
|
||||||
|
Arg.Is<string>(s => s.Contains("Футурама")),
|
||||||
|
null,
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Граница разобранного двигается всегда — иначе следующий тик оповестил бы о том же.
|
||||||
|
Assert.Equal(Now, settings.NotifiedUntil);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Бот расписания в Telegram
|
||||||
|
|
||||||
|
Оповещения зрителю: «на канале началось шоу» и «скоро начнётся». Настраивается в админке
|
||||||
|
(вкладка «Телеграм»), подписки собирает сам зритель кнопками в чате.
|
||||||
|
|
||||||
|
## Что решено и почему
|
||||||
|
|
||||||
|
**Подписка только по одноразовому коду.** Кнопка «Открыть чат с ботом» на странице эфира выдаёт
|
||||||
|
ссылку `t.me/бот?start=КОД`; код живёт 15 минут, сгорает при первом использовании и привязывает
|
||||||
|
чат к учётной записи TeleWave. Анонимный бот («кто нашёл, тот и подписался») означал бы рассылку
|
||||||
|
неизвестно кому и невозможность отписать чат вместе с блокировкой пользователя. Ссылка выдаётся
|
||||||
|
по нажатию, а не лежит в разметке: в ней пропуск к чужим оповещениям.
|
||||||
|
|
||||||
|
**Два транспорта, переключателем.** `Polling` — сервер сам ходит за обновлениями: работает из-за
|
||||||
|
NAT и через прокси, и это единственный вариант там, где публичного адреса нет. `Webhook` — Telegram
|
||||||
|
стучится на наш HTTPS-эндпоинт: дешевле и быстрее, но требует доступного извне домена, и прокси
|
||||||
|
здесь не поможет — соединение устанавливает Telegram. Разговор с ботом при этом один и тот же:
|
||||||
|
оба транспорта приносят одинаковое обновление и разбираются общим кодом.
|
||||||
|
|
||||||
|
**Прокси — часть настроек, а не окружения.** HTTP или SOCKS5 с логином и паролем; клиент собирается
|
||||||
|
на каждый вызов, поэтому смена прокси не требует перезапуска контейнера. Кривой адрес — ошибка, а
|
||||||
|
не повод молча пойти напрямую: «работает в обход прокси» на канале, ради которого прокси и заводили,
|
||||||
|
хуже отказа.
|
||||||
|
|
||||||
|
**Секреты в базе, наружу не отдаются.** Токен и пароль прокси лежат открытым текстом: шифровать их
|
||||||
|
нечем — Data Protection в проекте сознательно не заводили (см. CLAUDE.md). Наружу уходит только
|
||||||
|
признак «задан», а пустое поле в форме означает «оставить как есть». Секрет вебхука генерируется
|
||||||
|
сам и проверяется заголовком `X-Telegram-Bot-Api-Secret-Token`: адрес рано или поздно окажется
|
||||||
|
в чужих логах, и без секрета туда постучится кто угодно.
|
||||||
|
|
||||||
|
**Проверка связи — на сохранении.** Настройки сразу спрашивают у Telegram `getMe`: неверный токен
|
||||||
|
или мёртвый прокси иначе всплыли бы через сутки молчания бота. Оттуда же приходит имя бота — без
|
||||||
|
него не собрать ссылку на чат, поэтому кнопка у зрителя появляется только после успешного контакта.
|
||||||
|
Вебхук ставится и снимается тем же сохранением: пока он стоит, Telegram не отдаёт обновления
|
||||||
|
опросом, и переключение режима без этого оставило бы бота немым.
|
||||||
|
|
||||||
|
## Оповещения
|
||||||
|
|
||||||
|
Считаются по материализованной ленте, а не по факту раздачи: лента и есть эфир, а ждать
|
||||||
|
подтверждения от плеера не от кого — зрителей может не быть вовсе.
|
||||||
|
|
||||||
|
- **Начало шоу** — на смену шоу, а не на каждую запись: четыре серии подряд это одна программа
|
||||||
|
в глазах зрителя, и четыре сообщения о ней читаются как спам.
|
||||||
|
- **Скоро в эфире** — за пять минут до следующей программы.
|
||||||
|
|
||||||
|
Граница разобранного (`TelegramSettings.NotifiedUntil`) двигается на каждом тике, даже когда
|
||||||
|
подписчиков нет: иначе первый же подписавшийся получил бы пачку сообщений про всё, что успело
|
||||||
|
пройти. Догон ограничен пятнадцатью минутами — после долгого простоя бот не станет пересказывать
|
||||||
|
вчерашний эфир.
|
||||||
|
|
||||||
|
## Меню в чате
|
||||||
|
|
||||||
|
`/start` с кодом привязывает чат и открывает меню; `/start` без кода — открывает меню уже
|
||||||
|
привязанному. Меню живёт **одним сообщением**: нажатие кнопки переписывает его на месте, иначе
|
||||||
|
чат заполняется одинаковыми «ваши подписки» после каждого клика. Кнопка — тумблер: повторное
|
||||||
|
нажатие снимает подписку. `/stop` останавливает рассылку, не стирая подписки: вернувшись, зритель
|
||||||
|
получит свои каналы, а не пустой список.
|
||||||
|
|
||||||
|
## Границы
|
||||||
|
|
||||||
|
- Бот не отвечает на произвольные вопросы и не показывает программу передач — только подписки.
|
||||||
|
- Групповые чаты не поддерживаются: подписка привязана к учётке, а в группе её владельца нет.
|
||||||
|
- Один чат — один подписчик; повторная привязка по новому коду меняет учётку, а не плодит копии.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useApiError } from '@/shared/lib/use-api-error'
|
||||||
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { getTelegramSettings, listTelegramSubscribers, updateTelegramSettings } from './api'
|
||||||
|
import type { TelegramProxyKind, TelegramSettingsBody, TelegramTransport } from './types'
|
||||||
|
|
||||||
|
const EMPTY: TelegramSettingsBody = {
|
||||||
|
isEnabled: false,
|
||||||
|
transport: 'Polling',
|
||||||
|
botToken: null,
|
||||||
|
webhookUrl: null,
|
||||||
|
proxyKind: 'None',
|
||||||
|
proxyHost: null,
|
||||||
|
proxyPort: null,
|
||||||
|
proxyUsername: null,
|
||||||
|
proxyPassword: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пустая строка формы — это «не задано», а для секретов ещё и «не менять». */
|
||||||
|
function trimmed(value: string): string | null {
|
||||||
|
const next = value.trim()
|
||||||
|
return next.length === 0 ? null : next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Бот расписания: токен, способ доставки обновлений, прокси и список подписчиков.
|
||||||
|
*
|
||||||
|
* Сохранение сразу проверяет связь — Telegram отвечает на getMe, и неверный токен или мёртвый
|
||||||
|
* прокси видно тут же, а не через сутки молчания бота.
|
||||||
|
*/
|
||||||
|
export function TelegramPanel() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const onError = useApiError()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form, setForm] = useState<TelegramSettingsBody>(EMPTY)
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['telegram', 'settings'],
|
||||||
|
queryFn: getTelegramSettings,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: subscribers } = useQuery({
|
||||||
|
queryKey: ['telegram', 'subscribers'],
|
||||||
|
queryFn: listTelegramSubscribers,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Форма наполняется тем, что вернул сервер; секреты остаются пустыми — их он не отдаёт.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings) return
|
||||||
|
setForm({
|
||||||
|
isEnabled: settings.isEnabled,
|
||||||
|
transport: settings.transport,
|
||||||
|
botToken: null,
|
||||||
|
webhookUrl: settings.webhookUrl,
|
||||||
|
proxyKind: settings.proxyKind,
|
||||||
|
proxyHost: settings.proxyHost,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
proxyUsername: settings.proxyUsername,
|
||||||
|
proxyPassword: null,
|
||||||
|
})
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => updateTelegramSettings(form),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['telegram'] })
|
||||||
|
toast.success(t('admin.telegram.saved'))
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const patch = (part: Partial<TelegramSettingsBody>) => setForm((prev) => ({ ...prev, ...part }))
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 className="crt-glow text-xl font-semibold">{t('admin.telegram.title')}</h2>
|
||||||
|
{settings?.botUsername && <Badge variant="muted">@{settings.botUsername}</Badge>}
|
||||||
|
{settings?.isEnabled ? (
|
||||||
|
<Badge>{t('admin.telegram.enabled')}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="muted">{t('admin.telegram.disabled')}</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="muted">
|
||||||
|
{t('admin.telegram.subscribers')}: {settings?.subscribers ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings?.lastError && (
|
||||||
|
<p className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
||||||
|
{settings.lastError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="crt-panel flex flex-col gap-4 rounded-md p-4 text-sm">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.isEnabled}
|
||||||
|
onChange={(e) => patch({ isEnabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t('admin.telegram.enable')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.telegram.token')}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={settings?.hasToken ? t('admin.telegram.tokenKept') : '123456:ABC...'}
|
||||||
|
value={form.botToken ?? ''}
|
||||||
|
onChange={(e) => patch({ botToken: trimmed(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.telegram.tokenHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.telegram.transport')}</Label>
|
||||||
|
<Select
|
||||||
|
value={form.transport}
|
||||||
|
onValueChange={(v) => patch({ transport: v as TelegramTransport })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Polling">{t('admin.telegram.transports.Polling')}</SelectItem>
|
||||||
|
<SelectItem value="Webhook">{t('admin.telegram.transports.Webhook')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(`admin.telegram.transportHint.${form.transport}`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.transport === 'Webhook' && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.telegram.webhookUrl')}</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="https://tv.example.com/api/telegram/webhook"
|
||||||
|
value={form.webhookUrl ?? ''}
|
||||||
|
onChange={(e) => patch({ webhookUrl: trimmed(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.telegram.webhookHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.telegram.proxy')}</Label>
|
||||||
|
<Select
|
||||||
|
value={form.proxyKind}
|
||||||
|
onValueChange={(v) => patch({ proxyKind: v as TelegramProxyKind })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="None">{t('admin.telegram.proxies.None')}</SelectItem>
|
||||||
|
<SelectItem value="Http">HTTP</SelectItem>
|
||||||
|
<SelectItem value="Socks5">SOCKS5</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.telegram.proxyHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.proxyKind !== 'None' && (
|
||||||
|
<div className="grid grid-cols-[1fr_7rem] gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.telegram.proxyHost')}</Label>
|
||||||
|
<Input
|
||||||
|
value={form.proxyHost ?? ''}
|
||||||
|
onChange={(e) => patch({ proxyHost: trimmed(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.telegram.proxyPort')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={form.proxyPort ?? ''}
|
||||||
|
onChange={(e) => patch({ proxyPort: Number(e.target.value) || null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.proxyKind !== 'None' && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.telegram.proxyUser')}</Label>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
value={form.proxyUsername ?? ''}
|
||||||
|
onChange={(e) => patch({ proxyUsername: trimmed(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.telegram.proxyPassword')}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={settings?.hasProxyPassword ? t('admin.telegram.tokenKept') : undefined}
|
||||||
|
value={form.proxyPassword ?? ''}
|
||||||
|
onChange={(e) => patch({ proxyPassword: trimmed(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
{settings?.lastContactAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.telegram.lastContact')}: {new Date(settings.lastContactAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('admin.telegram.subscribers')}
|
||||||
|
</h3>
|
||||||
|
<div className="crt-panel overflow-x-auto rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">{t('admin.telegram.user')}</th>
|
||||||
|
<th className="px-4 py-2 font-medium">{t('admin.telegram.chat')}</th>
|
||||||
|
<th className="px-4 py-2 font-medium">{t('admin.telegram.channels')}</th>
|
||||||
|
<th className="px-4 py-2 font-medium">{t('admin.telegram.lastSeen')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(subscribers ?? []).map((s) => (
|
||||||
|
<tr key={s.id} className="border-b border-border last:border-0">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{s.userName ?? '—'}
|
||||||
|
{s.isStopped && (
|
||||||
|
<Badge variant="muted" className="ml-2">
|
||||||
|
{t('admin.telegram.stopped')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
|
{s.displayName ? `@${s.displayName}` : s.chatId}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
|
{s.channels.length === 0
|
||||||
|
? '—'
|
||||||
|
: s.channels.map((c) => `${c.channelName} (${c.kinds.length})`).join(', ')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
|
{s.lastSeenAt ? new Date(s.lastSeenAt).toLocaleString() : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{(subscribers ?? []).length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground" colSpan={4}>
|
||||||
|
{t('admin.telegram.noSubscribers')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { apiRequest } from '@/shared/api/client'
|
||||||
|
import type { TelegramSettingsBody, TelegramSettingsDto, TelegramSubscriberDto } from './types'
|
||||||
|
|
||||||
|
export function getTelegramSettings() {
|
||||||
|
return apiRequest<TelegramSettingsDto>('/admin/telegram/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTelegramSettings(body: TelegramSettingsBody) {
|
||||||
|
return apiRequest<TelegramSettingsDto>('/admin/telegram/settings', { method: 'PUT', body })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTelegramSubscribers() {
|
||||||
|
return apiRequest<TelegramSubscriberDto[]>('/admin/telegram/subscribers')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ссылка на чат с одноразовым кодом — её открывает кнопка на странице эфира. */
|
||||||
|
export function issueTelegramLink() {
|
||||||
|
return apiRequest<{ url: string; expiresAt: string }>('/telegram/link', { method: 'POST' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type TelegramTransport = 'Polling' | 'Webhook'
|
||||||
|
export type TelegramProxyKind = 'None' | 'Http' | 'Socks5'
|
||||||
|
|
||||||
|
export type TelegramSettingsDto = {
|
||||||
|
isEnabled: boolean
|
||||||
|
/** Токен наружу не отдаётся — только признак, что он задан. */
|
||||||
|
hasToken: boolean
|
||||||
|
botUsername: string | null
|
||||||
|
botLink: string | null
|
||||||
|
transport: TelegramTransport
|
||||||
|
webhookUrl: string | null
|
||||||
|
proxyKind: TelegramProxyKind
|
||||||
|
proxyHost: string | null
|
||||||
|
proxyPort: number | null
|
||||||
|
proxyUsername: string | null
|
||||||
|
hasProxyPassword: boolean
|
||||||
|
lastContactAt: string | null
|
||||||
|
lastError: string | null
|
||||||
|
subscribers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пустые токен и пароль означают «оставить как есть»: форма их не показывает. */
|
||||||
|
export type TelegramSettingsBody = {
|
||||||
|
isEnabled: boolean
|
||||||
|
transport: TelegramTransport
|
||||||
|
botToken: string | null
|
||||||
|
webhookUrl: string | null
|
||||||
|
proxyKind: TelegramProxyKind
|
||||||
|
proxyHost: string | null
|
||||||
|
proxyPort: number | null
|
||||||
|
proxyUsername: string | null
|
||||||
|
proxyPassword: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TelegramSubscriberDto = {
|
||||||
|
id: string
|
||||||
|
chatId: number
|
||||||
|
displayName: string | null
|
||||||
|
userName: string | null
|
||||||
|
isStopped: boolean
|
||||||
|
createdAt: string
|
||||||
|
lastSeenAt: string | null
|
||||||
|
channels: { channelId: string; channelName: string; kinds: string[] }[]
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Download, Radio, RotateCw } from 'lucide-react'
|
import { Download, Send, Radio, RotateCw } from 'lucide-react'
|
||||||
import { qk } from '@/shared/api/query-keys'
|
import { qk } from '@/shared/api/query-keys'
|
||||||
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
@@ -11,6 +11,8 @@ import { toast } from '@/shared/ui/toast-store'
|
|||||||
import { ChannelPlayer } from './ChannelPlayer'
|
import { ChannelPlayer } from './ChannelPlayer'
|
||||||
import {
|
import {
|
||||||
downloadIptvPlaylist,
|
downloadIptvPlaylist,
|
||||||
|
getTelegramStatus,
|
||||||
|
issueTelegramLink,
|
||||||
getEpg,
|
getEpg,
|
||||||
getViewerFeatures,
|
getViewerFeatures,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@@ -80,6 +82,25 @@ export function AirPage() {
|
|||||||
})
|
})
|
||||||
const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures })
|
const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures })
|
||||||
|
|
||||||
|
// Настройки бота читаем публичной частью: кнопка нужна только когда бот действительно настроен.
|
||||||
|
const { data: telegram } = useQuery({
|
||||||
|
queryKey: qk.air.telegram,
|
||||||
|
queryFn: getTelegramStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ссылка на чат выдаётся по нажатию, а не заранее: в ней одноразовый код привязки, и держать
|
||||||
|
* его в разметке страницы всё время незачем.
|
||||||
|
*/
|
||||||
|
const openBot = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await issueTelegramLink()
|
||||||
|
window.open(url, '_blank', 'noopener')
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.telegram.linkFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const numbersEnabled = features?.channelNumbersEnabled ?? false
|
const numbersEnabled = features?.channelNumbersEnabled ?? false
|
||||||
const currentChannel = channels?.find((c) => c.slug === selected)
|
const currentChannel = channels?.find((c) => c.slug === selected)
|
||||||
|
|
||||||
@@ -193,6 +214,12 @@ export function AirPage() {
|
|||||||
{numbersEnabled && (
|
{numbersEnabled && (
|
||||||
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
|
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
|
||||||
)}
|
)}
|
||||||
|
{telegram?.isConfigured && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void openBot()}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
{t('admin.telegram.openChat')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -45,3 +45,13 @@ export function getEpg(slug: string, from?: Date, to?: Date) {
|
|||||||
const suffix = qs ? `?${qs}` : ''
|
const suffix = qs ? `?${qs}` : ''
|
||||||
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${suffix}`)
|
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${suffix}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Настроен ли бот расписания — по этому флагу показывается кнопка «Открыть чат». */
|
||||||
|
export function getTelegramStatus() {
|
||||||
|
return apiRequest<{ isConfigured: boolean; botUsername: string | null }>('/telegram/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ссылка на чат с одноразовым кодом привязки: выдаётся по нажатию, живёт минуты. */
|
||||||
|
export function issueTelegramLink() {
|
||||||
|
return apiRequest<{ url: string; expiresAt: string }>('/telegram/link', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
|||||||
import { Route as AdminSettingsRouteImport } from './routes/admin/settings'
|
import { Route as AdminSettingsRouteImport } from './routes/admin/settings'
|
||||||
import { Route as AdminShowsRouteImport } from './routes/admin/shows'
|
import { Route as AdminShowsRouteImport } from './routes/admin/shows'
|
||||||
import { Route as AdminStorageRouteImport } from './routes/admin/storage'
|
import { Route as AdminStorageRouteImport } from './routes/admin/storage'
|
||||||
|
import { Route as AdminTelegramRouteImport } from './routes/admin/telegram'
|
||||||
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
||||||
import { Route as AdminChannelsIndexRouteImport } from './routes/admin/channels.index'
|
import { Route as AdminChannelsIndexRouteImport } from './routes/admin/channels.index'
|
||||||
import { Route as AdminChannelsChannelIdRouteImport } from './routes/admin/channels.$channelId'
|
import { Route as AdminChannelsChannelIdRouteImport } from './routes/admin/channels.$channelId'
|
||||||
@@ -145,6 +146,11 @@ const AdminStorageRoute = AdminStorageRouteImport.update({
|
|||||||
path: '/storage',
|
path: '/storage',
|
||||||
getParentRoute: () => AdminRoute,
|
getParentRoute: () => AdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminTelegramRoute = AdminTelegramRouteImport.update({
|
||||||
|
id: '/telegram',
|
||||||
|
path: '/telegram',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const AdminUsersRoute = AdminUsersRouteImport.update({
|
const AdminUsersRoute = AdminUsersRouteImport.update({
|
||||||
id: '/users',
|
id: '/users',
|
||||||
path: '/users',
|
path: '/users',
|
||||||
@@ -213,6 +219,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin/settings': typeof AdminSettingsRoute
|
'/admin/settings': typeof AdminSettingsRoute
|
||||||
'/admin/shows': typeof AdminShowsRouteWithChildren
|
'/admin/shows': typeof AdminShowsRouteWithChildren
|
||||||
'/admin/storage': typeof AdminStorageRoute
|
'/admin/storage': typeof AdminStorageRoute
|
||||||
|
'/admin/telegram': typeof AdminTelegramRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
'/admin/': typeof AdminIndexRoute
|
'/admin/': typeof AdminIndexRoute
|
||||||
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
||||||
@@ -240,6 +247,7 @@ export interface FileRoutesByTo {
|
|||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
'/admin/settings': typeof AdminSettingsRoute
|
'/admin/settings': typeof AdminSettingsRoute
|
||||||
'/admin/storage': typeof AdminStorageRoute
|
'/admin/storage': typeof AdminStorageRoute
|
||||||
|
'/admin/telegram': typeof AdminTelegramRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
'/admin': typeof AdminIndexRoute
|
'/admin': typeof AdminIndexRoute
|
||||||
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
||||||
@@ -273,6 +281,7 @@ export interface FileRoutesById {
|
|||||||
'/admin/settings': typeof AdminSettingsRoute
|
'/admin/settings': typeof AdminSettingsRoute
|
||||||
'/admin/shows': typeof AdminShowsRouteWithChildren
|
'/admin/shows': typeof AdminShowsRouteWithChildren
|
||||||
'/admin/storage': typeof AdminStorageRoute
|
'/admin/storage': typeof AdminStorageRoute
|
||||||
|
'/admin/telegram': typeof AdminTelegramRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
'/admin/': typeof AdminIndexRoute
|
'/admin/': typeof AdminIndexRoute
|
||||||
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
||||||
@@ -307,6 +316,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/settings'
|
| '/admin/settings'
|
||||||
| '/admin/shows'
|
| '/admin/shows'
|
||||||
| '/admin/storage'
|
| '/admin/storage'
|
||||||
|
| '/admin/telegram'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
| '/admin/'
|
| '/admin/'
|
||||||
| '/admin/channels/$channelId'
|
| '/admin/channels/$channelId'
|
||||||
@@ -334,6 +344,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
| '/admin/settings'
|
| '/admin/settings'
|
||||||
| '/admin/storage'
|
| '/admin/storage'
|
||||||
|
| '/admin/telegram'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/admin/channels/$channelId'
|
| '/admin/channels/$channelId'
|
||||||
@@ -366,6 +377,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/settings'
|
| '/admin/settings'
|
||||||
| '/admin/shows'
|
| '/admin/shows'
|
||||||
| '/admin/storage'
|
| '/admin/storage'
|
||||||
|
| '/admin/telegram'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
| '/admin/'
|
| '/admin/'
|
||||||
| '/admin/channels/$channelId'
|
| '/admin/channels/$channelId'
|
||||||
@@ -536,6 +548,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AdminStorageRouteImport
|
preLoaderRoute: typeof AdminStorageRouteImport
|
||||||
parentRoute: typeof AdminRoute
|
parentRoute: typeof AdminRoute
|
||||||
}
|
}
|
||||||
|
'/admin/telegram': {
|
||||||
|
id: '/admin/telegram'
|
||||||
|
path: '/telegram'
|
||||||
|
fullPath: '/admin/telegram'
|
||||||
|
preLoaderRoute: typeof AdminTelegramRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/admin/users': {
|
'/admin/users': {
|
||||||
id: '/admin/users'
|
id: '/admin/users'
|
||||||
path: '/users'
|
path: '/users'
|
||||||
@@ -672,6 +691,7 @@ interface AdminRouteChildren {
|
|||||||
AdminSettingsRoute: typeof AdminSettingsRoute
|
AdminSettingsRoute: typeof AdminSettingsRoute
|
||||||
AdminShowsRoute: typeof AdminShowsRouteWithChildren
|
AdminShowsRoute: typeof AdminShowsRouteWithChildren
|
||||||
AdminStorageRoute: typeof AdminStorageRoute
|
AdminStorageRoute: typeof AdminStorageRoute
|
||||||
|
AdminTelegramRoute: typeof AdminTelegramRoute
|
||||||
AdminUsersRoute: typeof AdminUsersRoute
|
AdminUsersRoute: typeof AdminUsersRoute
|
||||||
AdminIndexRoute: typeof AdminIndexRoute
|
AdminIndexRoute: typeof AdminIndexRoute
|
||||||
}
|
}
|
||||||
@@ -691,6 +711,7 @@ const AdminRouteChildren: AdminRouteChildren = {
|
|||||||
AdminSettingsRoute: AdminSettingsRoute,
|
AdminSettingsRoute: AdminSettingsRoute,
|
||||||
AdminShowsRoute: AdminShowsRouteWithChildren,
|
AdminShowsRoute: AdminShowsRouteWithChildren,
|
||||||
AdminStorageRoute: AdminStorageRoute,
|
AdminStorageRoute: AdminStorageRoute,
|
||||||
|
AdminTelegramRoute: AdminTelegramRoute,
|
||||||
AdminUsersRoute: AdminUsersRoute,
|
AdminUsersRoute: AdminUsersRoute,
|
||||||
AdminIndexRoute: AdminIndexRoute,
|
AdminIndexRoute: AdminIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,13 @@ function AdminLayout() {
|
|||||||
>
|
>
|
||||||
{t('admin.maintenance.title')}
|
{t('admin.maintenance.title')}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin/telegram"
|
||||||
|
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||||
|
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||||
|
>
|
||||||
|
{t('admin.telegram.title')}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/admin/settings"
|
to="/admin/settings"
|
||||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { TelegramPanel } from '@/features/admin/telegram/TelegramPanel'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/telegram')({ component: TelegramPanel })
|
||||||
@@ -10,6 +10,7 @@ export const qk = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
air: {
|
air: {
|
||||||
|
telegram: ['air', 'telegram'] as const,
|
||||||
channels: ['air', 'channels'] as const,
|
channels: ['air', 'channels'] as const,
|
||||||
features: ['air', 'features'] as const,
|
features: ['air', 'features'] as const,
|
||||||
epg: (slug: string | null) => ['air', 'epg', slug] as const,
|
epg: (slug: string | null) => ['air', 'epg', slug] as const,
|
||||||
|
|||||||
@@ -989,6 +989,42 @@ export const en = {
|
|||||||
Other: 'files outside the known directories — usually zero',
|
Other: 'files outside the known directories — usually zero',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
telegram: {
|
||||||
|
title: 'Telegram',
|
||||||
|
enable: 'Bot enabled',
|
||||||
|
enabled: 'Enabled',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
saved: 'Saved, connection verified',
|
||||||
|
token: 'Bot token',
|
||||||
|
tokenKept: 'set — leave as is',
|
||||||
|
tokenHint: 'Issued by @BotFather. Never returned: only the fact that it is set.',
|
||||||
|
transport: 'Delivery',
|
||||||
|
transports: { Polling: 'Polling (getUpdates)', Webhook: 'Webhook' },
|
||||||
|
transportHint: {
|
||||||
|
Polling: 'The server pulls updates itself. Works without a public address and via a proxy.',
|
||||||
|
Webhook:
|
||||||
|
'Telegram calls your HTTPS endpoint. Needs a public domain; a proxy will not help.',
|
||||||
|
},
|
||||||
|
webhookUrl: 'Webhook URL',
|
||||||
|
webhookHint: 'Full https URL down to /api/telegram/webhook. The secret is generated for you.',
|
||||||
|
proxy: 'Proxy',
|
||||||
|
proxies: { None: 'No proxy' },
|
||||||
|
proxyHint: 'How to reach api.telegram.org.',
|
||||||
|
proxyHost: 'Host',
|
||||||
|
proxyPort: 'Port',
|
||||||
|
proxyUser: 'User',
|
||||||
|
proxyPassword: 'Password',
|
||||||
|
lastContact: 'Last contact',
|
||||||
|
subscribers: 'Subscribers',
|
||||||
|
noSubscribers: 'Nobody subscribed yet.',
|
||||||
|
user: 'User',
|
||||||
|
chat: 'Chat',
|
||||||
|
channels: 'Channels',
|
||||||
|
lastSeen: 'Last seen',
|
||||||
|
stopped: 'stopped',
|
||||||
|
openChat: 'Open bot chat',
|
||||||
|
linkFailed: 'Bot is not configured',
|
||||||
|
},
|
||||||
maintenance: {
|
maintenance: {
|
||||||
title: 'Maintenance',
|
title: 'Maintenance',
|
||||||
warning: 'These actions are irreversible — data and files are deleted permanently.',
|
warning: 'These actions are irreversible — data and files are deleted permanently.',
|
||||||
|
|||||||
@@ -984,6 +984,43 @@ export const ru = {
|
|||||||
Other: 'файлы мимо известных каталогов — обычно ноль',
|
Other: 'файлы мимо известных каталогов — обычно ноль',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
telegram: {
|
||||||
|
title: 'Телеграм',
|
||||||
|
enable: 'Бот включён',
|
||||||
|
enabled: 'Включён',
|
||||||
|
disabled: 'Выключен',
|
||||||
|
saved: 'Настройки сохранены, связь проверена',
|
||||||
|
token: 'Токен бота',
|
||||||
|
tokenKept: 'задан — оставить как есть',
|
||||||
|
tokenHint: 'Выдаётся @BotFather. Наружу не отдаётся: видно только, что он задан.',
|
||||||
|
transport: 'Способ доставки',
|
||||||
|
transports: { Polling: 'Опрос (getUpdates)', Webhook: 'Вебхук' },
|
||||||
|
transportHint: {
|
||||||
|
Polling: 'Сервер сам ходит за обновлениями. Работает без публичного адреса и через прокси.',
|
||||||
|
Webhook:
|
||||||
|
'Telegram стучится на ваш HTTPS-адрес. Нужен доступный извне домен; прокси тут не поможет.',
|
||||||
|
},
|
||||||
|
webhookUrl: 'Адрес вебхука',
|
||||||
|
webhookHint:
|
||||||
|
'Полный https-адрес до /api/telegram/webhook. Секрет генерируется автоматически.',
|
||||||
|
proxy: 'Прокси',
|
||||||
|
proxies: { None: 'Без прокси' },
|
||||||
|
proxyHint: 'Через что ходить к api.telegram.org.',
|
||||||
|
proxyHost: 'Адрес',
|
||||||
|
proxyPort: 'Порт',
|
||||||
|
proxyUser: 'Пользователь',
|
||||||
|
proxyPassword: 'Пароль',
|
||||||
|
lastContact: 'Последняя связь',
|
||||||
|
subscribers: 'Подписчики',
|
||||||
|
noSubscribers: 'Пока никто не подписан.',
|
||||||
|
user: 'Пользователь',
|
||||||
|
chat: 'Чат',
|
||||||
|
channels: 'Каналы',
|
||||||
|
lastSeen: 'Активность',
|
||||||
|
stopped: 'остановлен',
|
||||||
|
openChat: 'Открыть чат с ботом',
|
||||||
|
linkFailed: 'Бот не настроен',
|
||||||
|
},
|
||||||
maintenance: {
|
maintenance: {
|
||||||
title: 'Обслуживание',
|
title: 'Обслуживание',
|
||||||
warning: 'Операции необратимы — удаляют данные и файлы навсегда.',
|
warning: 'Операции необратимы — удаляют данные и файлы навсегда.',
|
||||||
|
|||||||
Reference in New Issue
Block a user