namespace Supercell.Laser.Bot.Bot.Commands { using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Telegram.Bot; using Telegram.Bot.Exceptions; using Telegram.Bot.Types; using Telegram.Bot.Types.ReplyMarkups; using Supercell.Laser.Bot; using Supercell.Laser.Bot.DataBase; using Supercell.Laser.Bot.Utils; /// Что админ делает с ресурсом. public enum ResourceAction { Give, Take, View } /// /// Общий сценарий работы с ресурсом: меню действий -> ввод тэга -> ввод количества -> итог. /// Сам сценарий одинаковый у всех ресурсов, поэтому лежит здесь, а конкретные тексты /// и способ изменить баланс — в наследниках (GemsCommand, GoldCommand). /// /// Диалог у пользователя всегда один: открыть меню другого ресурса — значит отменить /// предыдущий незаконченный ввод. /// public abstract class ResourceCommand { public const string ResourcesMenuText = "💰 Ресурсы\nВыберите ресурс:"; protected const string CancelButton = "❌ Отмена"; protected const string BackButton = "⬅️ Назад"; protected const string MainMenuButton = "➡️ Главное меню"; protected const string AmountNotANumberMessage = "❌ Количество должно быть числом, введите еще раз:"; protected const string AmountTooSmallMessage = "❌ Минимальное количество должно быть от 1, введите еще раз:"; protected const string AmountTooBigMessage = "❌ Слишком большое количество, введите еще раз:"; protected const string ChangeFailedMessage = "❌ Не удалось изменить баланс — аккаунт не найден."; private static ResourceCommand[] _all; /// Все ресурсы в том порядке, в котором они показываются в меню "💰 Ресурсы". public static ResourceCommand[] All => _all ??= new ResourceCommand[] { GemsCommand.Instance, GoldCommand.Instance }; // Незаконченные диалоги: ключ — чат + пользователь, чтобы два админа в одном // чате не мешали друг другу. private static readonly ConcurrentDictionary Dialogs = new(); private enum DialogStep { Tag, Amount } private sealed class Dialog { public ResourceCommand Owner; public ResourceAction Action; public DialogStep Step; public long ChatId; /// Сообщение бота, которое сейчас показывает текущий шаг. public int PromptMessageId; /// Заполняется после успешного ввода тэга. public long AccountId; } // ---------- то, что задаёт конкретный ресурс ---------- /// Короткий ключ ресурса — из него строятся все callback_data ("gems", "gold"). protected abstract string Key { get; } /// Кнопка в меню "💰 Ресурсы". public abstract string MenuButtonText { get; } protected abstract string ActionsText { get; } protected abstract string GiveButtonText { get; } protected abstract string TakeButtonText { get; } protected abstract string ViewButtonText { get; } protected abstract string GiveAmountText(PlayerInfo player); protected abstract string TakeAmountText(PlayerInfo player); protected abstract string ViewText(PlayerInfo player); protected abstract string GiveResultText(ResourceChange change); protected abstract string TakeResultText(ResourceChange change); /// Меняет баланс на delta (delta < 0 — забрать). null — аккаунта нет. protected abstract ResourceChange Change(long accountId, int delta); // ---------- callback_data ---------- public string CbOpen => $"res_{Key}"; private string CbGive => $"res_{Key}_give"; private string CbTake => $"res_{Key}_take"; private string CbView => $"res_{Key}_view"; private string CbCancel => $"res_{Key}_cancel"; private string CbBackToTag => $"res_{Key}_backtag"; // ---------- маршрутизация из TelegramBot ---------- /// Обрабатывает нажатие кнопки. false — кнопка не наша. public static async Task HandleCallbackAsync(ITelegramBotClient bot, CallbackQuery query, CancellationToken ct) { foreach (ResourceCommand command in All) { if (await command.HandleOwnCallbackAsync(bot, query, ct)) return true; } return false; } /// /// Обрабатывает обычный текст как ответ на текущий шаг диалога. /// false — у этого пользователя нет незаконченного диалога. /// public static async Task HandleTextAsync(ITelegramBotClient bot, Message message, CancellationToken ct) { long chatId = message.Chat.Id; long userId = message.From?.Id ?? 0; if (!Dialogs.TryGetValue(DialogKey(chatId, userId), out Dialog dialog)) return false; if (dialog.Step == DialogStep.Tag) await dialog.Owner.OnTagAsync(bot, dialog, userId, message.Text, ct); else await dialog.Owner.OnAmountAsync(bot, dialog, userId, message.Text, ct); return true; } /// Сбрасывает незаконченный диалог (например, когда пришла команда /start). public static void Cancel(long chatId, long userId) { Dialogs.TryRemove(DialogKey(chatId, userId), out _); } private static string DialogKey(long chatId, long userId) => $"{chatId}:{userId}"; // ---------- шаги ---------- private async Task HandleOwnCallbackAsync(ITelegramBotClient bot, CallbackQuery query, CancellationToken ct) { string data = query.Data; long chatId = query.Message.Chat.Id; long userId = query.From.Id; int messageId = query.Message.MessageId; if (data == CbOpen) { Cancel(chatId, userId); await Edit(bot, chatId, messageId, ActionsText, ActionsKeyboard(), ct); await Ack(bot, query, ct); return true; } if (data == CbGive || data == CbTake || data == CbView) { ResourceAction action = data == CbGive ? ResourceAction.Give : data == CbTake ? ResourceAction.Take : ResourceAction.View; // Шаг показываем прямо в нажатом сообщении — оно и становится // "живым" сообщением диалога. Dialogs[DialogKey(chatId, userId)] = new Dialog { Owner = this, Action = action, Step = DialogStep.Tag, ChatId = chatId, PromptMessageId = messageId }; await Edit(bot, chatId, messageId, LogicTag.EnterTagMessage, CancelKeyboard(), ct); await Ack(bot, query, ct); return true; } if (data == CbBackToTag) { // "⬅️ Назад" с ввода количества: возвращаемся к вводу тэга, действие сохраняем. if (Dialogs.TryGetValue(DialogKey(chatId, userId), out Dialog dialog) && dialog.Owner == this) { dialog.Step = DialogStep.Tag; dialog.AccountId = 0; dialog.PromptMessageId = messageId; await Edit(bot, chatId, messageId, LogicTag.EnterTagMessage, CancelKeyboard(), ct); } else { // Диалог уже потерян (перезапуск сервера, старое сообщение) — просто меню. await Edit(bot, chatId, messageId, ActionsText, ActionsKeyboard(), ct); } await Ack(bot, query, ct); return true; } if (data == CbCancel) { Cancel(chatId, userId); await Edit(bot, chatId, messageId, ActionsText, ActionsKeyboard(), ct); await Ack(bot, query, ct, "Отменено"); return true; } return false; } private async Task OnTagAsync(ITelegramBotClient bot, Dialog dialog, long userId, string input, CancellationToken ct) { TagCheck check = LogicTag.Check(input); if (!check.IsValid) { await Prompt(bot, dialog, check.ErrorMessage, CancelKeyboard(), ct); return; } PlayerInfo player = Start.GetPlayer(check.AccountId); if (player == null) { await Prompt(bot, dialog, LogicTag.PlayerNotFoundMessage, CancelKeyboard(), ct); return; } if (dialog.Action == ResourceAction.View) { Cancel(dialog.ChatId, userId); BotData.LogAdminAction(userId, LogActionName(ResourceAction.View), LogicTag.WithHash(player.Tag)); await Prompt(bot, dialog, ViewText(player), MainMenuKeyboard(), ct); return; } dialog.AccountId = player.AccountId; dialog.Step = DialogStep.Amount; string text = dialog.Action == ResourceAction.Give ? GiveAmountText(player) : TakeAmountText(player); await Prompt(bot, dialog, text, AmountKeyboard(), ct); } private async Task OnAmountAsync(ITelegramBotClient bot, Dialog dialog, long userId, string input, CancellationToken ct) { if (!TryParseAmount(input, out int amount, out string error)) { await Prompt(bot, dialog, error, CancelKeyboard(), ct); return; } int delta = dialog.Action == ResourceAction.Give ? amount : -amount; ResourceChange change = Change(dialog.AccountId, delta); Cancel(dialog.ChatId, userId); if (change?.Player == null) { await Prompt(bot, dialog, ChangeFailedMessage, MainMenuKeyboard(), ct); return; } BotData.LogAdminAction(userId, LogActionName(dialog.Action), $"{LogicTag.WithHash(change.Player.Tag)} {change.OldValue}->{change.NewValue}"); string text = dialog.Action == ResourceAction.Give ? GiveResultText(change) : TakeResultText(change); await Prompt(bot, dialog, text, MainMenuKeyboard(), ct); } /// /// Разбирает введённое количество. Пробелы внутри числа терпим ("10 000"), /// всё остальное — ошибка с готовым текстом для пользователя. /// private static bool TryParseAmount(string input, out int amount, out string error) { amount = 0; error = null; //   — неразрывный пробел: именно он приезжает, если число скопировали // из уже отправленного ботом сообщения или из другого чата. string raw = (input ?? string.Empty).Replace(" ", string.Empty).Replace(" ", string.Empty).Trim(); if (!long.TryParse(raw, out long parsed)) { error = AmountNotANumberMessage; return false; } if (parsed < 1) { error = AmountTooSmallMessage; return false; } if (parsed > int.MaxValue) { error = AmountTooBigMessage; return false; } amount = (int)parsed; return true; } private string LogActionName(ResourceAction action) => $"{Key}_{action.ToString().ToLowerInvariant()}"; // ---------- отправка сообщений ---------- /// /// Показывает следующий шаг после текста от пользователя: старое сообщение бота /// удаляем и отправляем новое, иначе шаг остался бы выше только что написанного /// пользователем сообщения, и его пришлось бы искать прокруткой. /// private static async Task Prompt(ITelegramBotClient bot, Dialog dialog, string text, InlineKeyboardMarkup keyboard, CancellationToken ct) { if (dialog.PromptMessageId != 0) { try { await bot.DeleteMessageAsync(dialog.ChatId, dialog.PromptMessageId, ct); } catch (Exception) { // Сообщение могли удалить руками или оно слишком старое — не страшно. } dialog.PromptMessageId = 0; } Message sent = await bot.SendTextMessageAsync(dialog.ChatId, text, replyMarkup: keyboard, cancellationToken: ct); dialog.PromptMessageId = sent.MessageId; } private static async Task Edit(ITelegramBotClient bot, long chatId, int messageId, string text, InlineKeyboardMarkup keyboard, CancellationToken ct) { try { await bot.EditMessageTextAsync(chatId, messageId, text, replyMarkup: keyboard, cancellationToken: ct); } catch (ApiRequestException) { // Обычно "message is not modified" — нажали ту же кнопку дважды. } } private static Task Ack(ITelegramBotClient bot, CallbackQuery query, CancellationToken ct, string text = null) { return bot.AnswerCallbackQueryAsync(query.Id, text, cancellationToken: ct); } // ---------- клавиатуры ---------- /// Меню "💰 Ресурсы": по кнопке на ресурс + назад в панель админа. public static InlineKeyboardMarkup ResourcesMenuKeyboard() { var rows = new List>(); foreach (ResourceCommand command in All) rows.Add(new List { InlineKeyboardButton.WithCallbackData(command.MenuButtonText, command.CbOpen) }); rows.Add(new List { InlineKeyboardButton.WithCallbackData(BackButton, TelegramBot.CbBackToAdmin) }); return new InlineKeyboardMarkup(rows); } private InlineKeyboardMarkup ActionsKeyboard() { return new InlineKeyboardMarkup(new List> { new() { InlineKeyboardButton.WithCallbackData(GiveButtonText, CbGive) }, new() { InlineKeyboardButton.WithCallbackData(TakeButtonText, CbTake) }, new() { InlineKeyboardButton.WithCallbackData(ViewButtonText, CbView) }, new() { InlineKeyboardButton.WithCallbackData(BackButton, TelegramBot.CbResources) } }); } private InlineKeyboardMarkup CancelKeyboard() { return new InlineKeyboardMarkup(new[] { new[] { InlineKeyboardButton.WithCallbackData(CancelButton, CbCancel) } }); } private InlineKeyboardMarkup AmountKeyboard() { return new InlineKeyboardMarkup(new List> { new() { InlineKeyboardButton.WithCallbackData(CancelButton, CbCancel) }, new() { InlineKeyboardButton.WithCallbackData(BackButton, CbBackToTag) } }); } private static InlineKeyboardMarkup MainMenuKeyboard() { return new InlineKeyboardMarkup(new[] { new[] { InlineKeyboardButton.WithCallbackData(MainMenuButton, TelegramBot.CbBackToMain) } }); } // ---------- мелочи для текстов ---------- /// Ник игрока для вывода — у только что созданного аккаунта его может не быть. protected static string Nick(PlayerInfo player) { return string.IsNullOrWhiteSpace(player?.Name) ? "(без ника)" : player.Name; } protected static string Tag(PlayerInfo player) { return LogicTag.WithHash(player?.Tag); } } }