mirror of
https://github.com/barelyprofessional/KfChatDotNet.git
synced 2026-04-30 03:22:04 -04:00
236 lines
10 KiB
C#
236 lines
10 KiB
C#
using System.Text.RegularExpressions;
|
|
using Humanizer;
|
|
using Humanizer.Localisation;
|
|
using KfChatDotNetBot.Commands;
|
|
using KfChatDotNetBot.Extensions;
|
|
using KfChatDotNetBot.Models;
|
|
using KfChatDotNetBot.Models.DbModels;
|
|
using KfChatDotNetBot.Settings;
|
|
using KfChatDotNetWsClient.Models.Events;
|
|
using NLog;
|
|
|
|
namespace KfChatDotNetBot.Services;
|
|
|
|
// Took one look at GambaSesh's code, and it made my head explode
|
|
// This implementation is inspired by similar bot I wrote years ago
|
|
internal class BotCommands
|
|
{
|
|
private ChatBot _bot;
|
|
private Logger _logger = LogManager.GetCurrentClassLogger();
|
|
private char CommandPrefix = '!';
|
|
private IEnumerable<ICommand> Commands;
|
|
private CancellationToken _cancellationToken;
|
|
|
|
internal BotCommands(ChatBot bot, CancellationToken ctx = default)
|
|
{
|
|
_cancellationToken = ctx;
|
|
_bot = bot;
|
|
var interfaceType = typeof(ICommand);
|
|
Commands =
|
|
AppDomain.CurrentDomain.GetAssemblies()
|
|
.SelectMany(x => x.GetTypes())
|
|
.Where(x => interfaceType.IsAssignableFrom(x) && x is { IsInterface: false, IsAbstract: false })
|
|
.Select(Activator.CreateInstance).Cast<ICommand>();
|
|
|
|
foreach (var command in Commands)
|
|
{
|
|
_logger.Debug($"Found command {command.GetType().Name}");
|
|
}
|
|
|
|
_ = CleanupExpiredRateLimitEntriesTask();
|
|
}
|
|
|
|
internal void ProcessMessage(MessageModel message)
|
|
{
|
|
if (string.IsNullOrEmpty(message.MessageRaw))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!message.MessageRaw.StartsWith(CommandPrefix))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var messageTrimmed = message.MessageRaw.TrimStart(CommandPrefix);
|
|
foreach (var command in Commands)
|
|
{
|
|
foreach (var regex in command.Patterns)
|
|
{
|
|
var match = regex.Match(messageTrimmed);
|
|
if (!match.Success) continue;
|
|
_logger.Debug($"Message matches {regex}");
|
|
using var db = new ApplicationDbContext();
|
|
var user = db.Users.FirstOrDefault(u => u.KfId == message.Author.Id);
|
|
// This should never happen as brand-new users are created upon join
|
|
if (user == null) return;
|
|
if (user.Ignored) return;
|
|
var continueAfterProcess = HasAttribute<AllowAdditionalMatches>(command);
|
|
var kasinoCommand = HasAttribute<KasinoCommand>(command);
|
|
var wagerCommand = HasAttribute<WagerCommand>(command);
|
|
if (kasinoCommand)
|
|
{
|
|
var kasinoEnabled = SettingsProvider.GetValueAsync(BuiltIn.Keys.MoneyEnabled).Result.ToBoolean();
|
|
if (!kasinoEnabled) return;
|
|
}
|
|
|
|
if (kasinoCommand && Money.IsPermanentlyBannedAsync(user, _cancellationToken).Result)
|
|
{
|
|
_bot.SendChatMessage($"@{message.Author.Username}, you've been permanently banned from the kasino. Contact support for more information.", true);
|
|
return;
|
|
}
|
|
|
|
if (wagerCommand)
|
|
{
|
|
// GetGamblerEntity will only return null if the user is permanbanned
|
|
// and we have a check further up the chain for that hence ignoring the null
|
|
var gambler = Money.GetGamblerEntityAsync(user, ct: _cancellationToken).Result;
|
|
var exclusion = Money.GetActiveExclusionAsync(gambler!, ct: _cancellationToken).Result;
|
|
if (exclusion != null)
|
|
{
|
|
_bot.SendChatMessage(
|
|
$"@{message.Author.Username}, you're self excluded from the kasino for another {(exclusion.Expires - DateTimeOffset.UtcNow).Humanize(precision: 3)}", true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (user.UserRight < command.RequiredRight)
|
|
{
|
|
_bot.SendChatMessage($"@{message.Author.Username}, you do not have access to use this command. Your rank: {user.UserRight.Humanize()}; Required rank: {command.RequiredRight.Humanize()}", true);
|
|
if (continueAfterProcess) continue;
|
|
break;
|
|
}
|
|
|
|
if (command.RateLimitOptions != null)
|
|
{
|
|
var isRateLimited = RateLimitService.IsRateLimited(user, command, message.MessageRawHtmlDecoded);
|
|
if (isRateLimited.IsRateLimited)
|
|
{
|
|
_ = SendCooldownResponse(user, command.RateLimitOptions, isRateLimited.OldestEntryExpires!.Value, command.GetType().Name);
|
|
continue;
|
|
}
|
|
RateLimitService.AddEntry(user, command, message.MessageRawHtmlDecoded);
|
|
}
|
|
_ = ProcessMessageAsync(command, message, user, match.Groups);
|
|
if (!continueAfterProcess) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ProcessMessageAsync(ICommand command, MessageModel message, UserDbModel user, GroupCollection arguments)
|
|
{
|
|
var task = Task.Run(() => command.RunCommand(_bot, message, user, arguments, _cancellationToken), _cancellationToken);
|
|
try
|
|
{
|
|
await task.WaitAsync(command.Timeout, _cancellationToken);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.Error("Caught an exception while waiting for the command to complete");
|
|
_logger.Error(e);
|
|
return;
|
|
}
|
|
if (task.IsFaulted)
|
|
{
|
|
_logger.Error("Command task failed");
|
|
_logger.Error(task.Exception);
|
|
return;
|
|
}
|
|
|
|
if (!(await SettingsProvider.GetValueAsync(BuiltIn.Keys.MoneyEnabled)).ToBoolean()) return;
|
|
var wagerCommand = HasAttribute<WagerCommand>(command);
|
|
if (!wagerCommand) return;
|
|
var gambler = await Money.GetGamblerEntityAsync(user, ct: _cancellationToken);
|
|
if (gambler == null) return;
|
|
if (gambler.TotalWagered < gambler.NextVipLevelWagerRequirement) return;
|
|
// The reason for doing this instead of passing in TotalWagered is that otherwise VIP levels might
|
|
// get skipped if the user is a low VIP level but wagering very large amounts
|
|
var newLevel = Money.GetNextVipLevel(gambler.NextVipLevelWagerRequirement);
|
|
if (newLevel == null) return;
|
|
var payout = await Money.UpgradeVipLevelAsync(gambler, newLevel, _cancellationToken);
|
|
await _bot.SendChatMessageAsync(
|
|
$"🤑🤑 {user.FormatUsername()} has leveled up to to {newLevel.VipLevel.Icon} {newLevel.VipLevel.Name} Tier {newLevel.Tier} " +
|
|
$"and received a bonus of {await payout.FormatKasinoCurrencyAsync()}", true);
|
|
}
|
|
|
|
private async Task SendCooldownResponse(UserDbModel user, RateLimitOptionsModel options, DateTimeOffset oldestEntryExpires, string commandName)
|
|
{
|
|
if (options.Flags.HasFlag(RateLimitFlags.NoResponse))
|
|
{
|
|
_logger.Info("No response flag set. Ignoring");
|
|
return;
|
|
}
|
|
_logger.Info($"Oldest entry: {oldestEntryExpires:o}");
|
|
var timeRemaining = oldestEntryExpires - DateTimeOffset.UtcNow;
|
|
var message = await _bot.SendChatMessageAsync($"{user.FormatUsername()}, please wait {timeRemaining.Humanize(maxUnit: TimeUnit.Minute, minUnit: TimeUnit.Millisecond, precision: 2)} before attempting to run {commandName} again.", true);
|
|
if (options.Flags.HasFlag(RateLimitFlags.NoAutoDeleteCooldownResponse))
|
|
{
|
|
_logger.Info("Not going to cleanup cooldown response");
|
|
return;
|
|
}
|
|
var i = 0;
|
|
while (message.ChatMessageId == null)
|
|
{
|
|
i++;
|
|
await Task.Delay(250, _cancellationToken);
|
|
if (i > 30)
|
|
{
|
|
_logger.Error("Gave up waiting for Sneedchat to give us the message ID for removing a cooldown notification");
|
|
return;
|
|
}
|
|
|
|
if (message.Status is SentMessageTrackerStatus.NotSending or SentMessageTrackerStatus.Lost)
|
|
{
|
|
_logger.Error("Cooldown message was suppressed or lost");
|
|
return;
|
|
}
|
|
}
|
|
|
|
var autoDeleteInterval =
|
|
(await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotRateLimitCooldownAutoDeleteDelay)).ToType<int>();
|
|
await Task.Delay(autoDeleteInterval, _cancellationToken);
|
|
await _bot.KfClient.DeleteMessageAsync(message.ChatMessageId.Value);
|
|
}
|
|
|
|
private async Task CleanupExpiredRateLimitEntriesTask()
|
|
{
|
|
while (!_cancellationToken.IsCancellationRequested)
|
|
{
|
|
var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotRateLimitExpiredEntryCleanupInterval))
|
|
.ToType<int>();
|
|
await Task.Delay(TimeSpan.FromSeconds(interval), _cancellationToken);
|
|
_logger.Info("Cleaning up expired rate limit entries");
|
|
RateLimitService.CleanupExpiredEntries();
|
|
}
|
|
}
|
|
|
|
private static bool HasAttribute<T>(ICommand command) where T : Attribute
|
|
{
|
|
return Attribute.GetCustomAttribute(command.GetType(), typeof(T)) != null;
|
|
}
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normally if a command is matched and executed, the loop breaks and no further commands are processed
|
|
/// Use this attribute if you want to continue attempting to match and run other commands after this one
|
|
/// Keep in mind since commands are executed in a throwaway task and not awaited, they will run concurrently
|
|
/// </summary>
|
|
[AttributeUsage(AttributeTargets.Class)]
|
|
internal class AllowAdditionalMatches : Attribute;
|
|
|
|
/// <summary>
|
|
/// Use this on commands where a wager is taking place.
|
|
/// This will cause the bot to check total wagered and see if the gambler has leveled up.
|
|
/// It'll also check whether the gambler is currently temp excluded before running the command.
|
|
/// </summary>
|
|
[AttributeUsage(AttributeTargets.Class)]
|
|
internal class WagerCommand : Attribute;
|
|
|
|
/// <summary>
|
|
/// Use this on all commands that interact with the gambling / monetary system
|
|
/// When used, this will check if the system is globally enabled before running the command.
|
|
/// It'll also check whether the user is permanently banned before running the command.
|
|
/// </summary>
|
|
[AttributeUsage(AttributeTargets.Class)]
|
|
internal class KasinoCommand : Attribute; |