feat(telegram): InputDebounce option and message reactions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user