diff --git a/OneShelf.Common/OneShelf.Common.OpenAi/Models/Memory/ChatBotMemoryPointWithDeserializableTraces.cs b/OneShelf.Common/OneShelf.Common.OpenAi/Models/Memory/ChatBotMemoryPointWithDeserializableTraces.cs new file mode 100644 index 00000000..2b322f09 --- /dev/null +++ b/OneShelf.Common/OneShelf.Common.OpenAi/Models/Memory/ChatBotMemoryPointWithDeserializableTraces.cs @@ -0,0 +1,8 @@ +namespace OneShelf.Common.OpenAi.Models.Memory; + +public class ChatBotMemoryPointWithDeserializableTraces : ChatBotMemoryPoint +{ + public List ImageTraces { get; init; } = new(); + + public List ImageUrlTraces { get; init; } = new(); +} \ No newline at end of file diff --git a/OneShelf.Common/OneShelf.Common.OpenAi/Models/Memory/ChatBotMemoryPointWithTraces.cs b/OneShelf.Common/OneShelf.Common.OpenAi/Models/Memory/ChatBotMemoryPointWithTraces.cs index 4af46867..b2bc7000 100644 --- a/OneShelf.Common/OneShelf.Common.OpenAi/Models/Memory/ChatBotMemoryPointWithTraces.cs +++ b/OneShelf.Common/OneShelf.Common.OpenAi/Models/Memory/ChatBotMemoryPointWithTraces.cs @@ -1,10 +1,6 @@ namespace OneShelf.Common.OpenAi.Models.Memory; -public class ChatBotMemoryPointWithTraces : ChatBotMemoryPoint +public class ChatBotMemoryPointWithTraces : ChatBotMemoryPointWithDeserializableTraces { public List Traces { get; init; } = new(); - - public List ImageTraces { get; init; } = new(); - - public List ImageUrlTraces { get; init; } = new(); } \ No newline at end of file diff --git a/OneShelf.Common/OneShelf.Common.OpenAi/Models/ValueHolder.cs b/OneShelf.Common/OneShelf.Common.OpenAi/Models/ValueHolder.cs new file mode 100644 index 00000000..118452ba --- /dev/null +++ b/OneShelf.Common/OneShelf.Common.OpenAi/Models/ValueHolder.cs @@ -0,0 +1,7 @@ +namespace OneShelf.Common.OpenAi.Models; + +public class ValueHolder(T value = default) + where T : struct +{ + public T Value { get; set; } = value; +} \ No newline at end of file diff --git a/OneShelf.Common/OneShelf.Common.OpenAi/Services/DialogRunner.cs b/OneShelf.Common/OneShelf.Common.OpenAi/Services/DialogRunner.cs index 3309d60e..99d465c0 100644 --- a/OneShelf.Common/OneShelf.Common.OpenAi/Services/DialogRunner.cs +++ b/OneShelf.Common/OneShelf.Common.OpenAi/Services/DialogRunner.cs @@ -30,7 +30,7 @@ public DialogRunner(IOptions options, ILogger logge } public async Task<(DialogResult result, ChatBotMemoryPointWithTraces newMessagePoint)> Execute( - IReadOnlyList existingMemory, DialogConfiguration configuration, CancellationToken cancellationToken = default, DateTime? imagesUnavailableUntil = null) + IReadOnlyList existingMemory, DialogConfiguration configuration, CancellationToken cancellationToken = default, DateTime? imagesUnavailableUntil = null, ValueHolder? isPhoto = null) { var lastTopicChange = existingMemory.WithIndices().LastOrDefault(x => x.x is ChatBotMemoryPoint { @@ -89,6 +89,8 @@ public DialogRunner(IOptions options, ILogger logge if (cancellationToken.IsCancellationRequested) throw new TaskCanceledException(); if (imagesDeserialized.Any() && !imagesUnavailableUntil.HasValue) { + if (isPhoto != null) isPhoto.Value = true; + urlsTask = GenerateImages(imagesDeserialized, configuration, cancellationToken); } diff --git a/OneShelf.OneDog/OneShelf.OneDog.Processor/Services/PipelineHandlers/AiDialogHandler.cs b/OneShelf.OneDog/OneShelf.OneDog.Processor/Services/PipelineHandlers/AiDialogHandler.cs index fe737a68..0cc84707 100644 --- a/OneShelf.OneDog/OneShelf.OneDog.Processor/Services/PipelineHandlers/AiDialogHandler.cs +++ b/OneShelf.OneDog/OneShelf.OneDog.Processor/Services/PipelineHandlers/AiDialogHandler.cs @@ -29,7 +29,7 @@ public AiDialogHandler( _dogContext = dogContext; } - protected override void OnInitializing(Update update) + protected override void OnInitializing(long userId, long chatId) { _dogDatabase.InitializeInteractionsRepositoryScope(_dogContext.DomainId); } diff --git a/OneShelf.OneDragon/OneShelf.OneDragon.Processor/PipelineHandlers/AiDialogHandler.cs b/OneShelf.OneDragon/OneShelf.OneDragon.Processor/PipelineHandlers/AiDialogHandler.cs index ec8fe15d..a7c3ff09 100644 --- a/OneShelf.OneDragon/OneShelf.OneDragon.Processor/PipelineHandlers/AiDialogHandler.cs +++ b/OneShelf.OneDragon/OneShelf.OneDragon.Processor/PipelineHandlers/AiDialogHandler.cs @@ -38,16 +38,16 @@ public AiDialogHandler( _options = options; } - protected override void OnInitializing(Update update) + protected override void OnInitializing(long userId, long chatId) { - _dragonDatabase.InitializeInteractionsRepositoryScope(update.Message!.From!.Id, update.Message.Chat.Id); + _dragonDatabase.InitializeInteractionsRepositoryScope(userId, chatId); } - protected override bool TraceImages => _options.Value.IsAdmin(_dragonScope.UserId); + protected override bool TraceImages => true; protected override IInteraction CreateInteraction(Update update) => new Interaction { - ChatId = update.Message!.Chat.Id, + ChatId = update.Message?.Chat.Id ?? update.CallbackQuery!.Message!.Chat.Id, UpdateId = update.UpdateId, }; diff --git a/OneShelf.OneDragon/OneShelf.OneDragon.Processor/PipelineHandlers/UpdatesCollector.cs b/OneShelf.OneDragon/OneShelf.OneDragon.Processor/PipelineHandlers/UpdatesCollector.cs index 31d57186..c3c45686 100644 --- a/OneShelf.OneDragon/OneShelf.OneDragon.Processor/PipelineHandlers/UpdatesCollector.cs +++ b/OneShelf.OneDragon/OneShelf.OneDragon.Processor/PipelineHandlers/UpdatesCollector.cs @@ -34,7 +34,7 @@ protected override async Task HandleSync(Update update) _dragonDatabase.Updates.Add(dbUpdate); await _dragonDatabase.SaveChangesAsync(); - _scope.Initialize(dbUpdate.Id, update.Message?.Chat.Id, update.Message?.From?.Id); + _scope.Initialize(dbUpdate.Id, update.Message?.Chat.Id ?? update.CallbackQuery?.Message?.Chat.Id, update.Message?.From?.Id ?? update.CallbackQuery?.From.Id); return false; } } \ No newline at end of file diff --git a/OneShelf.Telegram/OneShelf.Telegram.Ai/OneShelf.Telegram.Ai.csproj b/OneShelf.Telegram/OneShelf.Telegram.Ai/OneShelf.Telegram.Ai.csproj index d6f2350b..4ffa0249 100644 --- a/OneShelf.Telegram/OneShelf.Telegram.Ai/OneShelf.Telegram.Ai.csproj +++ b/OneShelf.Telegram/OneShelf.Telegram.Ai/OneShelf.Telegram.Ai.csproj @@ -17,4 +17,8 @@ + + + + diff --git a/OneShelf.Telegram/OneShelf.Telegram.Ai/PipelineHandlers/AiDialogHandlerBase.cs b/OneShelf.Telegram/OneShelf.Telegram.Ai/PipelineHandlers/AiDialogHandlerBase.cs index ecefd31d..d2616beb 100644 --- a/OneShelf.Telegram/OneShelf.Telegram.Ai/PipelineHandlers/AiDialogHandlerBase.cs +++ b/OneShelf.Telegram/OneShelf.Telegram.Ai/PipelineHandlers/AiDialogHandlerBase.cs @@ -1,25 +1,32 @@ using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; +using Nito.AsyncEx; using OneShelf.Common; using OneShelf.Common.OpenAi.Models; using OneShelf.Common.OpenAi.Models.Memory; using OneShelf.Common.OpenAi.Services; using OneShelf.Telegram.Ai.Model; -using OneShelf.Telegram.Options; using OneShelf.Telegram.Services.Base; using Telegram.BotAPI; using Telegram.BotAPI.AvailableMethods; using Telegram.BotAPI.AvailableTypes; using Telegram.BotAPI.GettingUpdates; +using Telegram.BotAPI.UpdatingMessages; namespace OneShelf.Telegram.Ai.PipelineHandlers; public abstract class AiDialogHandlerBase : PipelineHandler { private TelegramBotClient? _api; - + private readonly Dictionary _databaseLocks = new(); + + private const string Numerals = "①②③④⑤⑥⑦⑧⑨⑩"; + private const string ShowImageActionsCallbackCommand = "imagegroup"; + private const string ImageCloseCallbackCommand = "imageclose"; + private const string ImageCallbackCommand = "image"; + private const string CallbackDataSeparator = ", "; + protected readonly ILogger> _logger; protected readonly IInteractionsRepository _repository; protected readonly DialogRunner _dialogRunner; @@ -80,37 +87,42 @@ protected TelegramBotClient GetApi() return _api ??= new(ScopedAbstractions.GetBotToken()); } - protected async Task SendMessage(Update respondTo, IReadOnlyList images, bool reply) + protected async Task SendMessage(long chatId, int? messageThreadId, int messageId, IReadOnlyList images, bool reply) { await GetApi().SendMediaGroupAsync( - new(respondTo.Message!.Chat.Id, images.Select(x => new InputMediaPhoto(x.ToString()))) + new(chatId, images.Select(x => new InputMediaPhoto(x.ToString()))) { - MessageThreadId = respondTo.Message!.MessageThreadId, + MessageThreadId = messageThreadId, ReplyParameters = !reply ? null : new() { - MessageId = respondTo.Message.MessageId, + MessageId = messageId, AllowSendingWithoutReply = false, }, DisableNotification = true, }); } - protected async Task SendMessage(Update respondTo, string text, IReadOnlyList images, bool reply) + protected async Task SendMessage(long chatId, int? messageThreadId, int messageId, string text, IReadOnlyList images, bool reply, ReplyMarkup? replyMarkup = null) { - var (messageEntities, result) = GetMessageEntities(text); + if (replyMarkup != null) + { + await SendSeparately(chatId, messageThreadId, messageId, text, images, reply, replyMarkup); + return; + } try { - await GetApi().SendMediaGroupAsync(new(respondTo.Message!.Chat.Id, images.WithIndices().Select(x => new InputMediaPhoto(x.x.ToString()) + var (messageEntities, result) = GetMessageEntities(text); + await GetApi().SendMediaGroupAsync(new(chatId, images.WithIndices().Select(x => new InputMediaPhoto(x.x.ToString()) { Caption = x.i == 0 ? result : null, CaptionEntities = x.i == 0 ? messageEntities : null, })) { - MessageThreadId = respondTo.Message!.MessageThreadId, + MessageThreadId = messageThreadId, ReplyParameters = !reply ? null : new() { - MessageId = respondTo.Message.MessageId, + MessageId = messageId, AllowSendingWithoutReply = false, }, DisableNotification = true, @@ -118,22 +130,26 @@ protected async Task SendMessage(Update respondTo, string text, IReadOnlyList images, bool reply, ReplyMarkup? replyMarkup = null) + { + await SendMessage(chatId, messageThreadId, messageId, images, reply); + await SendMessage(chatId, messageThreadId, messageId, text, reply, replyMarkup); + } + + protected async Task SendMessage(long chatId, int? messageThreadId, int messageId, string text, bool reply, ReplyMarkup? replyMarkup = null) { var (messageEntities, result) = GetMessageEntities(text); - await GetApi().SendMessageAsync(new(respondTo.Message!.Chat.Id, result) + await GetApi().SendMessageAsync(new(chatId, result) { - MessageThreadId = respondTo.Message!.MessageThreadId, + MessageThreadId = messageThreadId, ReplyParameters = !reply ? null : new() { - MessageId = respondTo.Message.MessageId, + MessageId = messageId, AllowSendingWithoutReply = false, }, DisableNotification = true, @@ -141,7 +157,8 @@ await GetApi().SendMessageAsync(new(respondTo.Message!.Chat.Id, result) { IsDisabled = true, }, - Entities = messageEntities + Entities = messageEntities, + ReplyMarkup = replyMarkup, }); } @@ -177,18 +194,18 @@ protected static (List messageEntities, string result) GetMessage return (messageEntities, result); } - protected async Task Typing(Update update) + protected async Task Typing(long chatId, int? messageThreadId, ValueHolder? isPhoto = null) { - await GetApi().SendChatActionAsync(update.Message!.Chat.Id, ChatActions.Typing, messageThreadId: update.Message.MessageThreadId); + await GetApi().SendChatActionAsync(chatId, isPhoto?.Value == true ? ChatActions.UploadPhoto : ChatActions.Typing, messageThreadId: messageThreadId); } - protected async void LongTyping(Update update, CancellationToken cancellationToken) + protected async void LongTyping(long chatId, int? messageThreadId, CancellationToken cancellationToken, ValueHolder? isPhoto = null) { try { while (!cancellationToken.IsCancellationRequested) { - await Typing(update); + await Typing(chatId, messageThreadId, isPhoto); await Task.Delay(3000, cancellationToken); } } @@ -203,16 +220,22 @@ protected async void LongTyping(Update update, CancellationToken cancellationTok protected override async Task HandleSync(Update update) { + if (update.CallbackQuery is { Data: not null, Message: not null }) + { + OnInitializing(update.CallbackQuery.From.Id, update.CallbackQuery.Message!.Chat.Id); + return await HandleCallback(update); + } + if (update.Message?.From == null) return false; if (!CheckRelevant(update)) return false; - OnInitializing(update); + OnInitializing(update.Message!.From!.Id, update.Message.Chat.Id); var chatUnavailableUntil = await GetChatUnavailableUntil(); if (chatUnavailableUntil.HasValue) { - Queued(SendMessage(update, string.Format(UnavailableUntilTemplate, chatUnavailableUntil.Value.ToString("f")), false)); + Queued(SendMessage(update.Message!.Chat.Id, update.Message.MessageThreadId, update.Message.MessageId, string.Format(UnavailableUntilTemplate, chatUnavailableUntil.Value.ToString("f")), false)); return true; } @@ -227,9 +250,155 @@ protected override async Task HandleSync(Update update) return false; } + private async Task HandleCallback(Update update) + { + var callbackQuery = update.CallbackQuery!; + + var data = callbackQuery.Data!.Split(CallbackDataSeparator); + + if (data is [ShowImageActionsCallbackCommand, { } imageGroupInteractionIdValue, { } imageGroupImageIndexValue, { } imageGroupImagesCountValue] + && int.TryParse(imageGroupInteractionIdValue, out var interactionId) + && int.TryParse(imageGroupImageIndexValue, out var imageIndex) + && int.TryParse(imageGroupImagesCountValue, out var imagesCount)) + { + QueueApi( + callbackQuery.From.Id.ToString(), + async api => await api.EditMessageReplyMarkupAsync( + callbackQuery.Message!.Chat.Id, + callbackQuery.Message.MessageId, + replyMarkup: new InlineKeyboardMarkup( + [ + GetMainMarkup(imagesCount, interactionId).InlineKeyboard.Single(), + [ + new($"✖️ {GetImageNumber(imageIndex)}") { CallbackData = GetCallbackData(ImageCloseCallbackCommand, interactionId, imagesCount) }, + new("👀") { CallbackData = GetCallbackData(ImageCallbackCommand, interactionId, imageIndex, 0) }, + new("👀↵") { CallbackData = GetCallbackData(ImageCallbackCommand, interactionId, imageIndex, -1) }, + ], + Enumerable.Range(1, 5).Select(x => new InlineKeyboardButton($"+{x}") { CallbackData = GetCallbackData(ImageCallbackCommand, interactionId, imageIndex, x) }) + ]))); + + return true; + } + + if (data is [ImageCloseCallbackCommand, { } imageBackInteractionIdValue, { } imageBackImagesCountValue] + && int.TryParse(imageBackInteractionIdValue, out interactionId) + && int.TryParse(imageBackImagesCountValue, out imagesCount)) + { + QueueApi( + callbackQuery.From.Id.ToString(), + async api => await api.EditMessageReplyMarkupAsync( + callbackQuery.Message!.Chat.Id, + callbackQuery.Message.MessageId, + replyMarkup: GetMainMarkup(imagesCount, interactionId))); + + return true; + } + + if (data is not [ImageCallbackCommand, { } interactionIdValue, { } imageIndexValue, { } timesValue] + || !int.TryParse(interactionIdValue, out interactionId) + || !int.TryParse(imageIndexValue, out imageIndex) + || !int.TryParse(timesValue, out var times)) + { + return false; + } + + var interaction = (await _repository.Get(x => x.Where(x => x.Id == interactionId))).Single(); + DateTime? imagesUnavailableUntil = null; + if (times > 0) + { + var @lock = _databaseLocks.GetValueOrDefault(callbackQuery.From.Id) ?? (_databaseLocks[callbackQuery.From.Id] = new()); + using var _ = await @lock.LockAsync(); + imagesUnavailableUntil = await GetImagesUnavailableUntil(DateTime.Now); + if (!imagesUnavailableUntil.HasValue) + { + var interaction2 = CreateInteraction(update); + interaction2.CreatedOn = DateTime.Now; + interaction2.InteractionType = _repository.ImagesSuccess; + interaction2.Serialized = timesValue; + interaction2.ShortInfoSerialized = callbackQuery.Data; + interaction2.UserId = callbackQuery.From.Id; + await _repository.Add(interaction2.Once().ToList()); + } + } + + QueueApi(null, api => ImageCallback(api, callbackQuery, interaction, imageIndex, times, imagesUnavailableUntil)); + return true; + } + + private async Task ImageCallback(TelegramBotClient api, CallbackQuery callbackQuery, IInteraction interaction, int imageIndex, int times, DateTime? imagesUnavailableUntil) + { + var prompt = JsonSerializer.Deserialize(interaction.Serialized)!.ImageTraces[imageIndex]; + var text = times switch + { + -1 => null, + 0 => prompt, + _ when imagesUnavailableUntil.HasValue => string.Format(RegenerationUnavailableUntilTemplate, imagesUnavailableUntil.Value.ToString("f")), + _ => RegenerationTemplate + }; + + try + { + await api.AnswerCallbackQueryAsync(new AnswerCallbackQueryArgs(callbackQuery.Id) + { + ShowAlert = text != null, + Text = text, + }); + } + catch (BotRequestException e) when (e.Message.Contains("query is too old and response timeout expired or query ID is invalid")) + { + _logger.LogWarning(e, "Couldn't interactively respond to the image callback query. {chat}, {user}.", callbackQuery.Message!.Chat, callbackQuery.From.Id); + } + + if (times == -1) + { + await SendMessage(callbackQuery.Message!.Chat.Id, null, callbackQuery.Message!.MessageId, $"{GetImageNumber(imageIndex)}: {prompt}", true); + return; + } + + if (!imagesUnavailableUntil.HasValue && times > 0) + { + var cancellationTokenSource = new CancellationTokenSource(); + try + { + LongTyping(callbackQuery.Message!.Chat.Id, null, cancellationTokenSource.Token, new(true)); + var aiParameters = await GetAiParameters(); + var images = await _dialogRunner.GenerateImages( + Enumerable.Repeat(prompt, times).ToList(), + new() + { + ImagesVersion = aiParameters.imagesVersion, + UserId = callbackQuery.From.Id, + DomainId = -1, + Version = aiParameters.version!, + ChatId = callbackQuery.Message.Chat.Id, + UseCase = "direct regeneration", + AdditionalBillingInfo = "images regeneration", + SystemMessage = "no message", + }, + default); + + images = images.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (images.Any()) + { + await SendMessage(callbackQuery.Message!.Chat.Id, null, callbackQuery.Message!.MessageId, images!, true); + } + else + { + await SendMessage(callbackQuery.Message!.Chat.Id, null, callbackQuery.Message!.MessageId, "Не получилось нарисовать новые изображения.", true); + } + } + finally + { + await cancellationTokenSource.CancelAsync(); + } + } + } + protected abstract string UnavailableUntilTemplate { get; } + protected virtual string RegenerationUnavailableUntilTemplate => "Я пока отдыхаю, а ты возвращайся {0} UTC."; + protected virtual string RegenerationTemplate => "Минутку, процесс идёт..."; - protected virtual void OnInitializing(Update update) + protected virtual void OnInitializing(long userId, long chatId) { } @@ -263,7 +432,8 @@ protected async Task Respond(Update update) using var callingApis = new CancellationTokenSource(); using var checkingIsStillLast = new CancellationTokenSource(); - LongTyping(update, callingApis.Token); + ValueHolder isPhoto = new(); + LongTyping(update.Message!.Chat.Id, update.Message.MessageThreadId, callingApis.Token, isPhoto); var checking = CheckNoUpdates(checkingIsStillLast, callingApis.Token, interactions.Last(x => Equals(x.InteractionType, _repository.OwnChatterMessage)).Id); ChatBotMemoryPointWithTraces newMessagePoint; @@ -292,12 +462,12 @@ protected async Task Respond(Update update) FrequencyPenalty = frequencyPenalty, PresencePenalty = presencePenalty, ImagesVersion = imagesVersion, - UserId = update.Message!.From!.Id, + UserId = update.Message.From!.Id, UseCase = "own chatter", AdditionalBillingInfo = additionalBillingInfo, DomainId = domainId, - ChatId = update.Message!.Chat.Id, - }, checkingIsStillLast.Token, imagesUnavailableUntil); + ChatId = update.Message.Chat.Id, + }, checkingIsStillLast.Token, imagesUnavailableUntil, isPhoto); if (checkingIsStillLast.IsCancellationRequested) { @@ -311,7 +481,7 @@ protected async Task Respond(Update update) catch (Exception e) { _logger.LogError(e, "Error requesting the data."); - await SendMessage(update, ResponseError, true); + await SendMessage(update.Message!.Chat.Id, update.Message.MessageThreadId, update.Message.MessageId, ResponseError, true); return; } finally @@ -333,6 +503,7 @@ protected async Task Respond(Update update) interaction.ShortInfoSerialized = JsonSerializer.Serialize(result); interaction.UserId = update.Message.From.Id; await _repository.Add(interaction.Once().ToList()); + var memoryPointInteractionId = interaction.Id; if (result.Images.Any()) { @@ -354,25 +525,25 @@ protected async Task Respond(Update update) { try { - if (TraceImages) + InlineKeyboardMarkup? replyMarkup = null; + if (TraceImages && IsPrivate(update.Message.Chat)) { - try + if (string.IsNullOrWhiteSpace(text)) { - await SendMessage(update, string.Join(Environment.NewLine, newMessagePoint.ImageTraces.Select(x => $"- {x}").Prepend(string.Empty).Prepend("Traces:")), false); - } - catch (Exception e) - { - _logger.LogError(e, "Error writing the traces."); + text = "⚙ управление"; } + + var imagesCount = result.Images.Count; + replyMarkup = GetMainMarkup(imagesCount, memoryPointInteractionId); } if (!string.IsNullOrWhiteSpace(text)) { - await SendMessage(update, text, result.Images, false); + await SendMessage(update.Message!.Chat.Id, update.Message.MessageThreadId, update.Message.MessageId, text, result.Images, false, replyMarkup); } else { - await SendMessage(update, result.Images, false); + await SendMessage(update.Message!.Chat.Id, update.Message!.MessageThreadId, update.Message!.MessageId, result.Images, false); } return; @@ -385,10 +556,24 @@ protected async Task Respond(Update update) if (!string.IsNullOrWhiteSpace(text)) { - await SendMessage(update, text, false); + await SendMessage(update.Message!.Chat.Id, update.Message.MessageThreadId, update.Message.MessageId, text, false); } } + private static InlineKeyboardMarkup GetMainMarkup(int imagesCount, int memoryPointInteractionId) + { + return new([ + Enumerable.Range(0, imagesCount).Select(i => new InlineKeyboardButton(GetImageNumber(i)) { CallbackData = GetCallbackData(ShowImageActionsCallbackCommand, memoryPointInteractionId, i, imagesCount) }) + ]); + } + + private static string GetCallbackData(params object[] args) => string.Join(CallbackDataSeparator, args); + + private static string GetImageNumber(int i) + { + return i < Numerals.Length ? Numerals[i].ToString() : (i + 1).ToString(); + } + protected abstract bool CheckRelevant(Update update); protected abstract Task GetImagesUnavailableUntil(DateTime now);