Enhance Telegram bot functionality with message management and new API methods
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:
@@ -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;
|
||||
|
||||
Generated
+1599
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);
|
||||
|
||||
Reference in New Issue
Block a user