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);
}
}
}