Enhance Telegram bot functionality with message management and new API methods
ci / build-backend (push) Successful in 3m27s
ci / build-frontend (push) Successful in 1m5s
ci / tests (push) Successful in 4m0s
ci / sonar (push) Successful in 8m22s

Updated the ITelegramApi interface to include a method for deleting messages and modified SendMessageAsync to return the message ID. Enhanced the TelegramBotService to manage message visibility by replacing previous messages with new ones, ensuring a cleaner user experience. Introduced properties in TelegramSubscriber to track the latest menu and notice message IDs. Updated related tests to verify the new message management behavior and adjusted documentation to reflect these changes.
This commit is contained in:
Leonid Pershin
2026-07-31 08:24:42 +03:00
parent 8a439f1351
commit d6bc2b15ac
9 changed files with 1835 additions and 24 deletions
@@ -40,7 +40,8 @@ public interface ITelegramApi
CancellationToken cancellationToken
);
Task SendMessageAsync(
/// <summary>Отправляет сообщение и возвращает его идентификатор — по нему потом правят и удаляют.</summary>
Task<long?> SendMessageAsync(
TelegramSettings settings,
long chatId,
string text,
@@ -58,6 +59,17 @@ public interface ITelegramApi
CancellationToken cancellationToken
);
/// <summary>
/// Снимает своё сообщение. В личном чате бот вправе удалять только собственные — команды
/// зрителя остаются, и это не в нашей власти.
/// </summary>
Task DeleteMessageAsync(
TelegramSettings settings,
long chatId,
long messageId,
CancellationToken cancellationToken
);
/// <summary>Гасит «часики» на нажатой кнопке; текст — всплывающая подсказка.</summary>
Task AnswerCallbackAsync(
TelegramSettings settings,
@@ -60,9 +60,13 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
// Любое другое сообщение — это «я не знаю, что нажать»: показываем меню, если чат известен.
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);
{
await NoticeAsync(settings, null, chatId, Texts.NeedLink, cancellationToken);
return;
}
await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
/// <summary>
@@ -89,20 +93,14 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
// Уже привязанный чат просто открывает меню: заставлять его снова ходить за кодом незачем.
if (subscriber is null)
{
await api.SendMessageAsync(
settings,
chatId,
Texts.NeedLink,
null,
cancellationToken
);
await NoticeAsync(settings, null, chatId, Texts.NeedLink, cancellationToken);
return;
}
subscriber.Resume();
subscriber.Touch(now);
await dbContext.SaveChangesAsync(cancellationToken);
await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return;
}
@@ -112,7 +110,7 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
);
if (link is null || !link.IsUsable(now))
{
await api.SendMessageAsync(settings, chatId, Texts.BadCode, null, cancellationToken);
await NoticeAsync(settings, subscriber, chatId, Texts.BadCode, cancellationToken);
return;
}
@@ -128,9 +126,9 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
subscriber.Rebind(link.UserId, from, now);
}
await dbContext.SaveChangesAsync(cancellationToken);
await api.SendMessageAsync(settings, chatId, Texts.Linked, null, cancellationToken);
await NoticeAsync(settings, subscriber, chatId, Texts.Linked, cancellationToken);
await ShowMenuAsync(settings, subscriber, chatId, null, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task HandleStopAsync(
@@ -145,8 +143,8 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
// Подписки не трогаем: вернувшись по /start, зритель получит свои каналы, а не пустой список.
subscriber.Stop();
await NoticeAsync(settings, subscriber, chatId, Texts.Stopped, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await api.SendMessageAsync(settings, chatId, Texts.Stopped, null, cancellationToken);
}
private async Task HandleCallbackAsync(
@@ -188,7 +186,8 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
subscriber.Touch(now);
await dbContext.SaveChangesAsync(cancellationToken);
await AnswerAsync(settings, update, null, cancellationToken);
await SendScheduleAsync(settings, channel, chatId, now, cancellationToken);
await SendScheduleAsync(settings, subscriber, channel, chatId, now, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return;
}
@@ -220,6 +219,28 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
update.CallbackMessageId,
cancellationToken
);
await dbContext.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Одноразовое сообщение бота: программа передач, подтверждение привязки, отказ. Предыдущее
/// такое же снимается — три программы на разное время в чате не нужны никому.
/// </summary>
private async Task NoticeAsync(
TelegramSettings settings,
TelegramSubscriber? subscriber,
long chatId,
string text,
CancellationToken cancellationToken
)
{
if (subscriber?.NoticeMessageId is { } previous)
await api.DeleteMessageAsync(settings, chatId, previous, cancellationToken);
var messageId = await api.SendMessageAsync(settings, chatId, text, null, cancellationToken);
// Незнакомому чату помнить нечего: подписчика ещё нет, и следующее сообщение будет первым.
subscriber?.SetNoticeMessage(messageId);
}
private Task AnswerAsync(
@@ -254,7 +275,7 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
if (channels.Count == 0)
{
await api.SendMessageAsync(settings, chatId, Texts.NoChannels, null, cancellationToken);
await NoticeAsync(settings, subscriber, chatId, Texts.NoChannels, cancellationToken);
return;
}
@@ -273,7 +294,10 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
]);
}
// Нажали кнопку — правим то же сообщение. Пришли заново — старое меню снимаем: две панели
// с разными галочками в одном чате означают, что одна из них врёт.
if (messageId is { } id)
{
await api.EditMessageAsync(
settings,
chatId,
@@ -282,8 +306,21 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
keyboard,
cancellationToken
);
else
await api.SendMessageAsync(settings, chatId, Texts.Menu, keyboard, cancellationToken);
subscriber.SetMenuMessage(id);
return;
}
if (subscriber.MenuMessageId is { } previous)
await api.DeleteMessageAsync(settings, chatId, previous, cancellationToken);
var sent = await api.SendMessageAsync(
settings,
chatId,
Texts.Menu,
keyboard,
cancellationToken
);
subscriber.SetMenuMessage(sent);
}
private static TelegramButton Button(
@@ -310,6 +347,7 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
/// </summary>
private async Task SendScheduleAsync(
TelegramSettings settings,
TelegramSubscriber subscriber,
Channel channel,
long chatId,
DateTimeOffset now,
@@ -340,7 +378,7 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
if (entries.Count == 0)
{
await api.SendMessageAsync(settings, chatId, Texts.NoSchedule, null, cancellationToken);
await NoticeAsync(settings, subscriber, chatId, Texts.NoSchedule, cancellationToken);
return;
}
@@ -396,6 +434,9 @@ public sealed class TelegramBotService(IAppDbContext dbContext, ITelegramApi api
public const string Menu =
"<b>Оповещения</b>\nНажмите, чтобы включить или выключить. ✅ — включено.\n"
// Кнопка-заголовок ничем не выдаёт, что она кнопка: про программу передач надо сказать
// словами, иначе её найдут только промахнувшись мимо тумблера.
+ "Программа передач — нажмите на название канала.\n"
+ "Остановить всё — командой /stop.";
public const string NoChannels = "Пока нет ни одного включённого канала.";
@@ -33,6 +33,19 @@ public class TelegramSubscriber
/// </summary>
public bool IsStopped { get; private set; }
/// <summary>
/// Последнее меню подписок в чате. Бот убирает за собой: при новом <c>/start</c> старое меню
/// снимается, иначе чат превращается в ленту одинаковых панелей, и непонятно, какая из них
/// показывает правду.
/// </summary>
public long? MenuMessageId { get; private set; }
/// <summary>
/// Последнее одноразовое сообщение бота — программа передач, подтверждение привязки. Живёт
/// до следующего такого же: держать в чате три программы на разное время бессмысленно.
/// </summary>
public long? NoticeMessageId { get; private set; }
public IReadOnlyList<TelegramSubscription> Subscriptions => _subscriptions;
private TelegramSubscriber() { }
@@ -64,6 +77,11 @@ public class TelegramSubscriber
public void Touch(DateTimeOffset now) => LastSeenAt = now;
/// <summary>Запоминает, каким сообщением сейчас показано меню (null — его в чате нет).</summary>
public void SetMenuMessage(long? messageId) => MenuMessageId = messageId;
public void SetNoticeMessage(long? messageId) => NoticeMessageId = messageId;
public void Stop() => IsStopped = true;
public void Resume() => IsStopped = false;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class TelegramBotMessages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "MenuMessageId",
table: "TelegramSubscribers",
type: "bigint",
nullable: true
);
migrationBuilder.AddColumn<long>(
name: "NoticeMessageId",
table: "TelegramSubscribers",
type: "bigint",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "MenuMessageId", table: "TelegramSubscribers");
migrationBuilder.DropColumn(name: "NoticeMessageId", table: "TelegramSubscribers");
}
}
}
@@ -850,6 +850,12 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("MenuMessageId")
.HasColumnType("bigint");
b.Property<long?>("NoticeMessageId")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
@@ -65,14 +65,15 @@ public sealed class TelegramApi(ILogger<TelegramApi> logger) : ITelegramApi
return result?.Result?.Select(Map).ToList() ?? [];
}
public Task SendMessageAsync(
public async Task<long?> SendMessageAsync(
TelegramSettings settings,
long chatId,
string text,
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
CancellationToken cancellationToken
) =>
CallAsync<OkResult>(
)
{
var result = await CallAsync<MessageResult>(
settings,
"sendMessage",
new
@@ -85,6 +86,33 @@ public sealed class TelegramApi(ILogger<TelegramApi> logger) : ITelegramApi
cancellationToken
);
return result?.Result?.MessageId;
}
public async Task DeleteMessageAsync(
TelegramSettings settings,
long chatId,
long messageId,
CancellationToken cancellationToken
)
{
try
{
await CallAsync<OkResult>(
settings,
"deleteMessage",
new { chat_id = chatId, message_id = messageId },
cancellationToken
);
}
catch (TelegramApiException exception)
{
// Сообщение мог удалить сам зритель, или ему больше 48 часов — Telegram столько
// и хранит право на удаление. Уборка не та задача, ради которой стоит рвать разговор.
logger.LogDebug(exception, "Не удалось снять сообщение {MessageId}", messageId);
}
}
public Task EditMessageAsync(
TelegramSettings settings,
long chatId,
@@ -269,6 +297,8 @@ public sealed class TelegramApi(ILogger<TelegramApi> logger) : ITelegramApi
private sealed record OkResult(bool Ok);
private sealed record MessageResult(bool Ok, MessageDto? Result);
private sealed record MeResult(bool Ok, MeDto? Result);
private sealed record MeDto(long Id, string Username);