feat(telegram): InputDebounce option and message reactions

This commit is contained in:
EugeneTes
2026-09-26 19:58:55 +02:00
parent 8e31dc61b9
commit 7eb9f84faa
6 changed files with 166 additions and 0 deletions

View File

@@ -114,6 +114,20 @@ public sealed class TelegramSender(ITelegramBotClient bot, ILogger<TelegramSende
}
}
/// <summary>React to a message with <paramref name="emoji"/>. Best-effort: a failure is logged at Debug and swallowed.</summary>
public async Task ReactAsync(long chatId, int messageId, string emoji, CancellationToken cancellationToken = default)
{
try
{
await bot.SetMessageReaction(chatId, messageId, [new ReactionTypeEmoji { Emoji = emoji }], cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogDebug(ex, "Reacting to message {MessageId} in chat {ChatId} failed", messageId, chatId);
}
}
public async Task AnswerCallbackAsync(string callbackQueryId, string? text = null, CancellationToken cancellationToken = default)
{
try

View File

@@ -25,6 +25,12 @@ public sealed class TelegramOptions
/// <summary>How often the "typing…" action is repeated while the agent runs (Telegram shows it for 5 s).</summary>
public TimeSpan TypingInterval { get; set; } = TimeSpan.FromSeconds(4);
/// <summary>
/// How long a chat's inbox waits after a message for more before sending the batch to the agent as one request (a
/// forward and its comment, an album). Zero sends on the next timer tick.
/// </summary>
public TimeSpan InputDebounce { get; set; } = TimeSpan.FromMilliseconds(600);
/// <summary>First delay after a failed poll; doubles per consecutive failure up to <see cref="ReconnectMaxDelay"/>.</summary>
public TimeSpan ReconnectInitialDelay { get; set; } = TimeSpan.FromSeconds(1);
@@ -55,6 +61,7 @@ public sealed class TelegramOptions
if (EditInterval <= TimeSpan.Zero || TypingInterval <= TimeSpan.Zero) errors.Add("Telegram intervals must be positive.");
if (ReconnectInitialDelay <= TimeSpan.Zero || ReconnectMaxDelay < ReconnectInitialDelay)
errors.Add("Telegram reconnect delays must be positive with max >= initial.");
if (InputDebounce < TimeSpan.Zero) errors.Add("Telegram:InputDebounce must not be negative.");
return errors;
}

View File

@@ -69,6 +69,20 @@ public sealed class FormattingAndOptionsTests
Assert.Contains(errors, e => e.Contains("allowed users", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Input_debounce_defaults_to_600_ms_and_must_not_be_negative()
{
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
Assert.Equal(TimeSpan.FromMilliseconds(600), options.InputDebounce);
Assert.Empty(options.Validate());
options.InputDebounce = TimeSpan.Zero;
Assert.Empty(options.Validate());
options.InputDebounce = TimeSpan.FromMilliseconds(-1);
Assert.Contains(options.Validate(), e => e.Contains("InputDebounce", StringComparison.Ordinal));
}
[Fact]
public void Environment_shortcuts_fill_the_token_and_users()
{

View File

@@ -36,6 +36,7 @@ public sealed class FakeBotClient : ITelegramBotClient
private readonly List<int?> _pollOffsets = [];
public IReadOnlyList<SendMessageRequest> Sent => Requests.OfType<SendMessageRequest>().ToList();
public IReadOnlyList<EditMessageTextRequest> Edits => Requests.OfType<EditMessageTextRequest>().ToList();
public IReadOnlyList<SetMessageReactionRequest> Reactions => Requests.OfType<SetMessageReactionRequest>().ToList();
/// <summary>The text each message still in the chat shows now (after edits and deletes), by message id.</summary>
public IReadOnlyDictionary<int, string> CurrentTexts => new Dictionary<int, string>(_texts);
@@ -98,6 +99,9 @@ public sealed class FakeBotClient : ITelegramBotClient
_texts.TryRemove(delete.MessageId, out _);
result = true;
break;
case GetFileRequest getFile:
result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"photos/{getFile.FileId}.jpg", FileSize = 3 };
break;
default:
result = request is GetMeRequest ? new User { Id = BotId, IsBot = true, FirstName = "Fake", Username = "fake_bot" } : true;
break;

View File

@@ -41,6 +41,98 @@ public static class Updates
public static Update WithId(int id) => new() { Id = id, Message = Text("#" + id).Message };
/// <summary>The fake bot's user id (<see cref="FakeBotClient.BotId"/>).</summary>
public const long BotUserId = 42;
private static Message NewMessage(int? id, long userId = AllowedUser) => new()
{
Id = id ?? Interlocked.Increment(ref _nextMessageId),
From = new User { Id = userId, FirstName = "Test" },
Chat = new Chat { Id = userId, Type = ChatType.Private },
Date = DateTime.UtcNow,
};
private static Update Wrap(Message message) => new() { Id = Interlocked.Increment(ref _nextUpdateId), Message = message };
/// <summary>An earlier message sent by the bot, for <see cref="Reply"/> and <see cref="Quote"/>.</summary>
public static Message BotMessage(string text, int? id = null)
{
var m = NewMessage(id);
m.From = new User { Id = BotUserId, IsBot = true, FirstName = "Fake", Username = "fake_bot" };
m.Text = text;
return m;
}
/// <summary>A text message replying to <paramref name="replyTo"/>.</summary>
public static Update Reply(string text, Message replyTo, int? id = null)
{
var m = NewMessage(id);
m.Text = text;
m.ReplyToMessage = replyTo;
return Wrap(m);
}
/// <summary>A text message replying to <paramref name="replyTo"/> with a selected <paramref name="quote"/>.</summary>
public static Update Quote(string text, Message replyTo, string quote, int? id = null)
{
var update = Reply(text, replyTo, id);
update.Message!.Quote = new TextQuote { Text = quote, Position = 0 };
return update;
}
/// <summary>A text message replying to a message from another chat (Telegram's "reply in another chat").</summary>
public static Update ExternalReply(string text, MessageOrigin origin, string? quote = null, int? id = null)
{
var m = NewMessage(id);
m.Text = text;
m.ExternalReply = new ExternalReplyInfo { Origin = origin };
if (quote is not null) m.Quote = new TextQuote { Text = quote, Position = 0 };
return Wrap(m);
}
/// <summary>A forwarded text message.</summary>
public static Update Forward(string text, MessageOrigin origin, int? id = null)
{
var m = NewMessage(id);
m.Text = text;
m.ForwardOrigin = origin;
return Wrap(m);
}
/// <summary>One photo of an album (media group <paramref name="groupId"/>), with its own file id.</summary>
public static Update AlbumPhoto(string groupId, string? caption = null, MessageOrigin? forwardedFrom = null, int? id = null)
{
var m = NewMessage(id);
m.MediaGroupId = groupId;
m.Photo = [new PhotoSize { FileId = $"photo-{m.Id}", FileUniqueId = $"photo-{m.Id}-u", Width = 10, Height = 10 }];
m.Caption = caption;
m.ForwardOrigin = forwardedFrom;
return Wrap(m);
}
public static Update StickerMessage(string emoji = "👍")
{
var m = NewMessage(null);
m.Sticker = new Sticker { FileId = "sticker", FileUniqueId = "sticker-u", Type = StickerType.Regular, Width = 1, Height = 1, Emoji = emoji };
return Wrap(m);
}
/// <summary>A channel post origin, optionally signed by its author.</summary>
public static MessageOriginChannel Channel(string title, string? signature = null) => new()
{
Chat = new Chat { Id = -100, Type = ChatType.Channel, Title = title },
MessageId = 1,
AuthorSignature = signature,
Date = DateTime.UtcNow,
};
/// <summary>A visible user origin. Named <c>UserOrigin</c> so it doesn't hide the <see cref="User"/> type in this class.</summary>
public static MessageOriginUser UserOrigin(string username, string first, string? last = null) => new()
{
SenderUser = new User { Id = 7, FirstName = first, LastName = last, Username = username },
Date = DateTime.UtcNow,
};
public static Update Callback(string data, long chatId, long userId = AllowedUser) => new()
{
Id = Interlocked.Increment(ref _nextUpdateId),

View File

@@ -0,0 +1,35 @@
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Requests;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Tests.Support;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class TelegramSenderTests
{
[Fact]
public async Task ReactAsync_sets_an_emoji_reaction()
{
var bot = new FakeBotClient();
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance);
await sender.ReactAsync(1001, 55, "👀");
var reaction = Assert.Single(bot.Reactions);
Assert.Equal(1001, reaction.ChatId.Identifier);
Assert.Equal(55, reaction.MessageId);
Assert.Equal("👀", Assert.IsType<ReactionTypeEmoji>(Assert.Single(reaction.Reaction!)).Emoji);
}
[Fact]
public async Task ReactAsync_swallows_failures()
{
var bot = new FakeBotClient { OnSend = r => { if (r is SetMessageReactionRequest) throw FakeBotClient.NetworkDown(); } };
var sender = new TelegramSender(bot, NullLogger<TelegramSender>.Instance);
await sender.ReactAsync(1001, 55, "👀"); // does not throw
Assert.Single(bot.Reactions);
}
}