mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-11 17:58:26 -04:00
5.1
This commit is contained in:
@@ -61,17 +61,66 @@ public sealed class CleanupService : ICleanupService, IReadyExecutor, INService
|
||||
}));
|
||||
}
|
||||
|
||||
// delete guild configs
|
||||
await ctx.GetTable<GuildConfig>()
|
||||
.Where(x => !tempTable.Select(x => x.GuildId)
|
||||
.Contains(x.GuildId))
|
||||
.DeleteAsync();
|
||||
|
||||
|
||||
|
||||
// delete guild xp
|
||||
await ctx.GetTable<UserXpStats>()
|
||||
.Where(x => !tempTable.Select(x => x.GuildId)
|
||||
.Contains(x.GuildId))
|
||||
.DeleteAsync();
|
||||
|
||||
|
||||
// delete expressions
|
||||
await ctx.GetTable<NadekoExpression>()
|
||||
.Where(x => x.GuildId != null && !tempTable.Select(x => x.GuildId)
|
||||
.Contains(x.GuildId.Value))
|
||||
.DeleteAsync();
|
||||
|
||||
// delete quotes
|
||||
await ctx.GetTable<Quote>()
|
||||
.Where(x => !tempTable.Select(x => x.GuildId)
|
||||
.Contains(x.GuildId))
|
||||
.DeleteAsync();
|
||||
|
||||
// delete planted currencies
|
||||
await ctx.GetTable<PlantedCurrency>()
|
||||
.Where(x => !tempTable.Select(x => x.GuildId)
|
||||
.Contains(x.GuildId))
|
||||
.DeleteAsync();
|
||||
|
||||
// delete image only channels
|
||||
await ctx.GetTable<ImageOnlyChannel>()
|
||||
.Where(x => !tempTable.Select(x => x.GuildId)
|
||||
.Contains(x.GuildId))
|
||||
.DeleteAsync();
|
||||
|
||||
// delete reaction roles
|
||||
await ctx.GetTable<ReactionRoleV2>()
|
||||
.Where(x => !tempTable.Select(x => x.GuildId)
|
||||
.Contains(x.GuildId))
|
||||
.DeleteAsync();
|
||||
|
||||
// delete ignored users
|
||||
await ctx.GetTable<DiscordPermOverride>()
|
||||
.Where(x => x.GuildId != null && !tempTable.Select(x => x.GuildId)
|
||||
.Contains(x.GuildId.Value))
|
||||
.DeleteAsync();
|
||||
|
||||
// delete perm overrides
|
||||
await ctx.GetTable<DiscordPermOverride>()
|
||||
.Where(x => x.GuildId != null && !tempTable.Select(x => x.GuildId)
|
||||
.Contains(x.GuildId.Value))
|
||||
.DeleteAsync();
|
||||
|
||||
// delete repeaters
|
||||
await ctx.GetTable<Repeater>()
|
||||
.Where(x => !tempTable.Select(x => x.GuildId)
|
||||
.Contains(x.GuildId))
|
||||
.DeleteAsync();
|
||||
|
||||
return new()
|
||||
{
|
||||
GuildCount = guildIds.Keys.Count,
|
||||
|
@@ -45,23 +45,43 @@ public partial class Administration
|
||||
var progressMsg = await Response().Pending(strs.prune_progress(0, 100)).SendAsync();
|
||||
var progress = GetProgressTracker(progressMsg);
|
||||
|
||||
PruneResult result;
|
||||
if (opts.Safe)
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
100,
|
||||
x => x.Author.Id == user.Id && !x.IsPinned,
|
||||
progress,
|
||||
opts.After);
|
||||
else
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
100,
|
||||
x => x.Author.Id == user.Id,
|
||||
progress,
|
||||
opts.After);
|
||||
|
||||
ctx.Message.DeleteAfter(3);
|
||||
|
||||
await SendResult(result);
|
||||
await progressMsg.DeleteAsync();
|
||||
}
|
||||
|
||||
private async Task SendResult(PruneResult result)
|
||||
{
|
||||
switch (result)
|
||||
{
|
||||
case PruneResult.Success:
|
||||
break;
|
||||
case PruneResult.AlreadyRunning:
|
||||
break;
|
||||
case PruneResult.FeatureLimit:
|
||||
await Response().Pending(strs.feature_limit_reached_owner).SendAsync();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(result), result, null);
|
||||
}
|
||||
}
|
||||
|
||||
// prune x
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
@@ -83,19 +103,21 @@ public partial class Administration
|
||||
var progressMsg = await Response().Pending(strs.prune_progress(0, count)).SendAsync();
|
||||
var progress = GetProgressTracker(progressMsg);
|
||||
|
||||
PruneResult result;
|
||||
if (opts.Safe)
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
count,
|
||||
x => !x.IsPinned && x.Id != progressMsg.Id,
|
||||
progress,
|
||||
opts.After);
|
||||
else
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
count,
|
||||
x => x.Id != progressMsg.Id,
|
||||
progress,
|
||||
opts.After);
|
||||
|
||||
await SendResult(result);
|
||||
await progressMsg.DeleteAsync();
|
||||
}
|
||||
|
||||
@@ -155,9 +177,10 @@ public partial class Administration
|
||||
var progressMsg = await Response().Pending(strs.prune_progress(0, count)).SendAsync();
|
||||
var progress = GetProgressTracker(progressMsg);
|
||||
|
||||
PruneResult result;
|
||||
if (opts.Safe)
|
||||
{
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
count,
|
||||
m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks && !m.IsPinned,
|
||||
progress,
|
||||
@@ -166,7 +189,7 @@ public partial class Administration
|
||||
}
|
||||
else
|
||||
{
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
count,
|
||||
m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks,
|
||||
progress,
|
||||
@@ -174,6 +197,7 @@ public partial class Administration
|
||||
);
|
||||
}
|
||||
|
||||
await SendResult(result);
|
||||
await progressMsg.DeleteAsync();
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,9 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public enum PruneResult
|
||||
{
|
||||
Success,
|
||||
AlreadyRunning,
|
||||
FeatureLimit,
|
||||
}
|
@@ -1,4 +1,6 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Patronage;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public class PruneService : INService
|
||||
@@ -7,11 +9,15 @@ public class PruneService : INService
|
||||
private readonly ConcurrentDictionary<ulong, CancellationTokenSource> _pruningGuilds = new();
|
||||
private readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14);
|
||||
private readonly ILogCommandService _logService;
|
||||
private readonly IPatronageService _ps;
|
||||
|
||||
public PruneService(ILogCommandService logService)
|
||||
=> _logService = logService;
|
||||
public PruneService(ILogCommandService logService, IPatronageService ps)
|
||||
{
|
||||
_logService = logService;
|
||||
_ps = ps;
|
||||
}
|
||||
|
||||
public async Task PruneWhere(
|
||||
public async Task<PruneResult> PruneWhere(
|
||||
ITextChannel channel,
|
||||
int amount,
|
||||
Func<IMessage, bool> predicate,
|
||||
@@ -26,8 +32,13 @@ public class PruneService : INService
|
||||
|
||||
using var cancelSource = new CancellationTokenSource();
|
||||
if (!_pruningGuilds.TryAdd(channel.GuildId, cancelSource))
|
||||
return;
|
||||
return PruneResult.AlreadyRunning;
|
||||
|
||||
if (!await _ps.LimitHitAsync(LimitedFeatureName.Prune, channel.Guild.OwnerId))
|
||||
{
|
||||
return PruneResult.FeatureLimit;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -47,7 +58,7 @@ public class PruneService : INService
|
||||
.ToArray();
|
||||
|
||||
if (!msgs.Any())
|
||||
return;
|
||||
return PruneResult.Success;
|
||||
|
||||
lastMessage = msgs[^1];
|
||||
|
||||
@@ -88,6 +99,8 @@ public class PruneService : INService
|
||||
{
|
||||
_pruningGuilds.TryRemove(channel.GuildId, out _);
|
||||
}
|
||||
|
||||
return PruneResult.Success;
|
||||
}
|
||||
|
||||
public async Task<bool> CancelAsync(ulong guildId)
|
||||
|
@@ -18,7 +18,7 @@ public interface IReactionRoleService
|
||||
/// <param name="group"></param>
|
||||
/// <param name="levelReq"></param>
|
||||
/// <returns>The result of the operation</returns>
|
||||
Task<OneOf<Success, FeatureLimit>> AddReactionRole(
|
||||
Task<OneOf<Success, Error>> AddReactionRole(
|
||||
IGuild guild,
|
||||
IMessage msg,
|
||||
string emote,
|
||||
|
@@ -55,12 +55,10 @@ public partial class Administration
|
||||
|
||||
await res.Match(
|
||||
_ => ctx.OkAsync(),
|
||||
fl =>
|
||||
async fl =>
|
||||
{
|
||||
_ = msg.RemoveReactionAsync(emote, ctx.Client.CurrentUser);
|
||||
return !fl.IsPatronLimit
|
||||
? Response().Error(strs.limit_reached(fl.Quota)).SendAsync()
|
||||
: Response().Pending(strs.feature_limit_reached_owner(fl.Quota, fl.Name)).SendAsync();
|
||||
await Response().Pending(strs.feature_limit_reached_owner).SendAsync();
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -21,22 +21,16 @@ public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionR
|
||||
private readonly SemaphoreSlim _assignementLock = new(1, 1);
|
||||
private readonly IPatronageService _ps;
|
||||
|
||||
private static readonly FeatureLimitKey _reroFLKey = new()
|
||||
{
|
||||
Key = "rero:max_count",
|
||||
PrettyName = "Reaction Role"
|
||||
};
|
||||
|
||||
public ReactionRolesService(
|
||||
DiscordSocketClient client,
|
||||
IPatronageService ps,
|
||||
DbService db,
|
||||
IBotCredentials creds,
|
||||
IPatronageService ps)
|
||||
IBotCredentials creds)
|
||||
{
|
||||
_db = db;
|
||||
_ps = ps;
|
||||
_client = client;
|
||||
_creds = creds;
|
||||
_ps = ps;
|
||||
_cache = new();
|
||||
}
|
||||
|
||||
@@ -242,7 +236,7 @@ public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionR
|
||||
/// <param name="group"></param>
|
||||
/// <param name="levelReq"></param>
|
||||
/// <returns>The result of the operation</returns>
|
||||
public async Task<OneOf<Success, FeatureLimit>> AddReactionRole(
|
||||
public async Task<OneOf<Success, Error>> AddReactionRole(
|
||||
IGuild guild,
|
||||
IMessage msg,
|
||||
string emote,
|
||||
@@ -260,10 +254,13 @@ public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionR
|
||||
var activeReactionRoles = await ctx.GetTable<ReactionRoleV2>()
|
||||
.Where(x => x.GuildId == guild.Id)
|
||||
.CountAsync();
|
||||
|
||||
var limit = await _ps.GetUserLimit(LimitedFeatureName.ReactionRole, guild.OwnerId);
|
||||
|
||||
var result = await _ps.TryGetFeatureLimitAsync(_reroFLKey, guild.OwnerId, 50);
|
||||
if (result.Quota != -1 && activeReactionRoles >= result.Quota)
|
||||
return result;
|
||||
if (!_creds.IsOwner(guild.OwnerId) && (activeReactionRoles >= limit.Quota && limit.Quota >= 0))
|
||||
{
|
||||
return new Error();
|
||||
}
|
||||
|
||||
await ctx.GetTable<ReactionRoleV2>()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
|
@@ -3,6 +3,7 @@ using NadekoBot.Common.TypeReaders;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Common.Blackjack;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using NadekoBot.Modules.Utility;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
|
@@ -14,6 +14,7 @@ using System.Text;
|
||||
using NadekoBot.Modules.Gambling.Rps;
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using NadekoBot.Modules.Patronage;
|
||||
using NadekoBot.Modules.Utility;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
@@ -27,9 +28,9 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
private readonly DownloadTracker _tracker;
|
||||
private readonly GamblingConfigService _configService;
|
||||
private readonly IBankService _bank;
|
||||
private readonly IPatronageService _ps;
|
||||
private readonly IRemindService _remind;
|
||||
private readonly GamblingTxTracker _gamblingTxTracker;
|
||||
private readonly IPatronageService _ps;
|
||||
|
||||
private IUserMessage rdMsg;
|
||||
|
||||
@@ -41,8 +42,8 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
DownloadTracker tracker,
|
||||
GamblingConfigService configService,
|
||||
IBankService bank,
|
||||
IPatronageService ps,
|
||||
IRemindService remind,
|
||||
IPatronageService patronage,
|
||||
GamblingTxTracker gamblingTxTracker)
|
||||
: base(configService)
|
||||
{
|
||||
@@ -51,9 +52,9 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
_cs = currency;
|
||||
_client = client;
|
||||
_bank = bank;
|
||||
_ps = ps;
|
||||
_remind = remind;
|
||||
_gamblingTxTracker = gamblingTxTracker;
|
||||
_ps = patronage;
|
||||
|
||||
_enUsCulture = new CultureInfo("en-US", false).NumberFormat;
|
||||
_enUsCulture.NumberDecimalDigits = 0;
|
||||
@@ -133,12 +134,6 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
await Response().Embed(embed).SendAsync();
|
||||
}
|
||||
|
||||
private static readonly FeatureLimitKey _timelyKey = new FeatureLimitKey()
|
||||
{
|
||||
Key = "timely:extra_percent",
|
||||
PrettyName = "Timely"
|
||||
};
|
||||
|
||||
private async Task RemindTimelyAction(SocketMessageComponent smc, DateTime when)
|
||||
{
|
||||
var tt = TimestampTag.FromDateTime(when, TimestampTagStyles.Relative);
|
||||
@@ -191,9 +186,12 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _ps.TryGetFeatureLimitAsync(_timelyKey, ctx.User.Id, 0);
|
||||
|
||||
val = (int)(val * (1 + (result.Quota! * 0.01f)));
|
||||
var patron = await _ps.GetPatronAsync(ctx.User.Id);
|
||||
|
||||
var percentBonus = (_ps.PercentBonus(patron) / 100f);
|
||||
|
||||
val += (int)(val * percentBonus);
|
||||
|
||||
await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim"));
|
||||
|
||||
@@ -892,6 +890,7 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
private static readonly ImmutableArray<string> _emojis =
|
||||
new[] { "⬆", "↖", "⬅", "↙", "⬇", "↘", "➡", "↗" }.ToImmutableArray();
|
||||
|
||||
|
||||
[Cmd]
|
||||
public async Task LuckyLadder([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
|
||||
{
|
||||
|
@@ -247,7 +247,14 @@ public partial class Gambling
|
||||
}
|
||||
else
|
||||
{
|
||||
var cmd = entry.Command.Replace("%you%", ctx.User.Id.ToString());
|
||||
var buyer = (IGuildUser)ctx.User;
|
||||
var cmd = entry.Command
|
||||
.Replace("%you%", buyer.Mention)
|
||||
.Replace("%you.mention%", buyer.Mention)
|
||||
.Replace("%you.username%", buyer.Username)
|
||||
.Replace("%you.name%", buyer.GlobalName ?? buyer.Username)
|
||||
.Replace("%you.nick%", buyer.DisplayName);
|
||||
|
||||
var eb = _sender.CreateEmbed()
|
||||
.WithPendingColor()
|
||||
.WithTitle("Executing shop command")
|
||||
@@ -259,6 +266,7 @@ public partial class Gambling
|
||||
GetProfitAmount(entry.Price),
|
||||
new("shop", "sell", entry.Name));
|
||||
|
||||
await Task.Delay(250);
|
||||
await _cmdHandler.TryRunCommand(guild,
|
||||
channel,
|
||||
new DoAsUserMessage(
|
||||
|
@@ -9,6 +9,7 @@ using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using NadekoBot.Modules.Gambling;
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using NadekoBot.Modules.Utility;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
|
@@ -18,7 +18,8 @@ public partial class Games
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
public async Task Cleverbot()
|
||||
[NoPublicBot]
|
||||
public async Task CleverBot()
|
||||
{
|
||||
var channel = (ITextChannel)ctx.Channel;
|
||||
|
||||
@@ -30,7 +31,7 @@ public partial class Games
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await Response().Confirm(strs.cleverbot_disabled).SendAsync();
|
||||
await Response().Confirm(strs.chatbot_disabled).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,7 +43,7 @@ public partial class Games
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await Response().Confirm(strs.cleverbot_enabled).SendAsync();
|
||||
await Response().Confirm(strs.chatbot_enabled).SendAsync();
|
||||
}
|
||||
}
|
||||
}
|
@@ -15,43 +15,32 @@ public class ChatterBotService : IExecOnMessage
|
||||
public int Priority
|
||||
=> 1;
|
||||
|
||||
private readonly FeatureLimitKey _flKey;
|
||||
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IPermissionChecker _perms;
|
||||
private readonly CommandHandler _cmd;
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly IPatronageService _ps;
|
||||
private readonly GamesConfigService _gcs;
|
||||
private readonly IMessageSenderService _sender;
|
||||
public readonly IPatronageService _ps;
|
||||
|
||||
public ChatterBotService(
|
||||
DiscordSocketClient client,
|
||||
IPermissionChecker perms,
|
||||
IBot bot,
|
||||
CommandHandler cmd,
|
||||
IPatronageService ps,
|
||||
IHttpClientFactory factory,
|
||||
IBotCredentials creds,
|
||||
IPatronageService ps,
|
||||
GamesConfigService gcs,
|
||||
IMessageSenderService sender)
|
||||
{
|
||||
_client = client;
|
||||
_perms = perms;
|
||||
_cmd = cmd;
|
||||
_creds = creds;
|
||||
_sender = sender;
|
||||
_httpFactory = factory;
|
||||
_ps = ps;
|
||||
_perms = perms;
|
||||
_gcs = gcs;
|
||||
|
||||
_flKey = new FeatureLimitKey()
|
||||
{
|
||||
Key = CleverBotResponseStr.CLEVERBOT_RESPONSE,
|
||||
PrettyName = "Cleverbot Replies"
|
||||
};
|
||||
_ps = ps;
|
||||
|
||||
ChatterBotGuilds = new(bot.AllGuildConfigs
|
||||
.Where(gc => gc.CleverbotEnabled)
|
||||
@@ -69,9 +58,9 @@ public class ChatterBotService : IExecOnMessage
|
||||
|
||||
Log.Information("Cleverbot will not work as the api key is missing");
|
||||
return null;
|
||||
case ChatBotImplementation.Gpt3:
|
||||
case ChatBotImplementation.Gpt:
|
||||
if (!string.IsNullOrWhiteSpace(_creds.Gpt3ApiKey))
|
||||
return new OfficialGpt3Session(_creds.Gpt3ApiKey,
|
||||
return new OfficialGptSession(_creds.Gpt3ApiKey,
|
||||
_gcs.Data.ChatGpt.ModelName,
|
||||
_gcs.Data.ChatGpt.ChatHistory,
|
||||
_gcs.Data.ChatGpt.MaxTokens,
|
||||
@@ -87,19 +76,18 @@ public class ChatterBotService : IExecOnMessage
|
||||
}
|
||||
}
|
||||
|
||||
public string PrepareMessage(IUserMessage msg, out IChatterBotSession cleverbot)
|
||||
public IChatterBotSession GetOrCreateSession(ulong guildId)
|
||||
{
|
||||
var channel = msg.Channel as ITextChannel;
|
||||
cleverbot = null;
|
||||
if (ChatterBotGuilds.TryGetValue(guildId, out var lazyChatBot))
|
||||
return lazyChatBot.Value;
|
||||
|
||||
if (channel is null)
|
||||
return null;
|
||||
|
||||
if (!ChatterBotGuilds.TryGetValue(channel.Guild.Id, out var lazyCleverbot))
|
||||
return null;
|
||||
|
||||
cleverbot = lazyCleverbot.Value;
|
||||
lazyChatBot = new(() => CreateSession(), true);
|
||||
ChatterBotGuilds.TryAdd(guildId, lazyChatBot);
|
||||
return lazyChatBot.Value;
|
||||
}
|
||||
|
||||
public string PrepareMessage(IUserMessage msg)
|
||||
{
|
||||
var nadekoId = _client.CurrentUser.Id;
|
||||
var normalMention = $"<@{nadekoId}> ";
|
||||
var nickMention = $"<@!{nadekoId}> ";
|
||||
@@ -119,13 +107,31 @@ public class ChatterBotService : IExecOnMessage
|
||||
if (guild is not SocketGuild sg)
|
||||
return false;
|
||||
|
||||
var channel = usrMsg.Channel as ITextChannel;
|
||||
if (channel is null)
|
||||
return false;
|
||||
|
||||
if (!ChatterBotGuilds.TryGetValue(channel.Guild.Id, out var lazyChatBot))
|
||||
return false;
|
||||
|
||||
var chatBot = lazyChatBot.Value;
|
||||
var message = PrepareMessage(usrMsg);
|
||||
if (message is null)
|
||||
return false;
|
||||
|
||||
return await RunChatterBot(sg, usrMsg, channel, chatBot, message);
|
||||
}
|
||||
|
||||
public async Task<bool> RunChatterBot(
|
||||
SocketGuild guild,
|
||||
IUserMessage usrMsg,
|
||||
ITextChannel channel,
|
||||
IChatterBotSession chatBot,
|
||||
string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = PrepareMessage(usrMsg, out var cbs);
|
||||
if (message is null || cbs is null)
|
||||
return false;
|
||||
|
||||
var res = await _perms.CheckPermsAsync(sg,
|
||||
var res = await _perms.CheckPermsAsync(guild,
|
||||
usrMsg.Channel,
|
||||
usrMsg.Author,
|
||||
CleverBotResponseStr.CLEVERBOT_RESPONSE,
|
||||
@@ -134,59 +140,33 @@ public class ChatterBotService : IExecOnMessage
|
||||
if (!res.IsAllowed)
|
||||
return false;
|
||||
|
||||
var channel = (ITextChannel)usrMsg.Channel;
|
||||
var conf = _ps.GetConfig();
|
||||
if (!_creds.IsOwner(sg.OwnerId) && conf.IsEnabled)
|
||||
if (!await _ps.LimitHitAsync(LimitedFeatureName.ChatBot, usrMsg.Author.Id, 2048 / 2))
|
||||
{
|
||||
var quota = await _ps.TryGetFeatureLimitAsync(_flKey, sg.OwnerId, 0);
|
||||
|
||||
uint? daily = quota.Quota is int dVal and < 0
|
||||
? (uint)-dVal
|
||||
: null;
|
||||
|
||||
uint? monthly = quota.Quota is int mVal and >= 0
|
||||
? (uint)mVal
|
||||
: null;
|
||||
|
||||
var maybeLimit = await _ps.TryIncrementQuotaCounterAsync(sg.OwnerId,
|
||||
sg.OwnerId == usrMsg.Author.Id,
|
||||
FeatureType.Limit,
|
||||
_flKey.Key,
|
||||
null,
|
||||
daily,
|
||||
monthly);
|
||||
|
||||
if (maybeLimit.TryPickT1(out var ql, out var counters))
|
||||
{
|
||||
if (ql.Quota == 0)
|
||||
{
|
||||
await _sender.Response(channel)
|
||||
.Error(null,
|
||||
text:
|
||||
"In order to use the cleverbot feature, the owner of this server should be [Patron Tier X](https://patreon.com/join/nadekobot) on patreon.",
|
||||
footer:
|
||||
"You may disable the cleverbot feature, and this message via '.cleverbot' command")
|
||||
.SendAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
await _sender.Response(channel)
|
||||
.Error(
|
||||
null!,
|
||||
$"You've reached your quota limit of **{ql.Quota}** responses {ql.QuotaPeriod.ToFullName()} for the cleverbot feature.",
|
||||
footer: "You may wait for the quota reset or .")
|
||||
.SendAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
// limit exceeded
|
||||
return false;
|
||||
}
|
||||
|
||||
_ = channel.TriggerTypingAsync();
|
||||
var response = await cbs.Think(message, usrMsg.Author.ToString());
|
||||
await _sender.Response(channel)
|
||||
.Confirm(response)
|
||||
.SendAsync();
|
||||
var response = await chatBot.Think(message, usrMsg.Author.ToString());
|
||||
|
||||
if (response.TryPickT0(out var result, out var error))
|
||||
{
|
||||
// calculate the diff in case we overestimated user's usage
|
||||
var inTokens = (result.TokensIn - 2048) / 2;
|
||||
|
||||
// add the output tokens to the limit
|
||||
await _ps.LimitForceHit(LimitedFeatureName.ChatBot,
|
||||
usrMsg.Author.Id,
|
||||
(inTokens) + (result.TokensOut / 2 * 3));
|
||||
|
||||
await _sender.Response(channel)
|
||||
.Confirm(result.Text)
|
||||
.SendAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Error in chatterbot: {Error}", error);
|
||||
}
|
||||
|
||||
Log.Information("""
|
||||
CleverBot Executed
|
@@ -3,10 +3,25 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Games.Common.ChatterBot;
|
||||
|
||||
public class Gpt3Response
|
||||
public class OpenAiCompletionResponse
|
||||
{
|
||||
[JsonPropertyName("choices")]
|
||||
public Choice[] Choices { get; set; }
|
||||
|
||||
[JsonPropertyName("usage")]
|
||||
public OpenAiUsageData Usage { get; set; }
|
||||
}
|
||||
|
||||
public class OpenAiUsageData
|
||||
{
|
||||
[JsonPropertyName("prompt_tokens")]
|
||||
public int PromptTokens { get; set; }
|
||||
|
||||
[JsonPropertyName("completion_tokens")]
|
||||
public int CompletionTokens { get; set; }
|
||||
|
||||
[JsonPropertyName("total_tokens")]
|
||||
public int TotalTokens { get; set; }
|
||||
}
|
||||
|
||||
public class Choice
|
||||
|
@@ -1,7 +1,10 @@
|
||||
#nullable disable
|
||||
using OneOf;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace NadekoBot.Modules.Games.Common.ChatterBot;
|
||||
|
||||
public interface IChatterBotSession
|
||||
{
|
||||
Task<string> Think(string input, string username);
|
||||
Task<OneOf<ThinkResult, Error<string>>> Think(string input, string username);
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
using OneOf;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace NadekoBot.Modules.Games.Common.ChatterBot;
|
||||
|
||||
@@ -18,7 +20,7 @@ public class OfficialCleverbotSession : IChatterBotSession
|
||||
_httpFactory = factory;
|
||||
}
|
||||
|
||||
public async Task<string> Think(string input, string username)
|
||||
public async Task<OneOf<ThinkResult, Error<string>>> Think(string input, string username)
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var dataString = await http.GetStringAsync(string.Format(QueryString, input, cs ?? ""));
|
||||
@@ -27,12 +29,17 @@ public class OfficialCleverbotSession : IChatterBotSession
|
||||
var data = JsonConvert.DeserializeObject<CleverbotResponse>(dataString);
|
||||
|
||||
cs = data?.Cs;
|
||||
return data?.Output;
|
||||
return new ThinkResult
|
||||
{
|
||||
Text = data?.Output,
|
||||
TokensIn = 2,
|
||||
TokensOut = 1
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Unexpected cleverbot response received: {ResponseString}", dataString);
|
||||
return null;
|
||||
Log.Warning("Unexpected response from CleverBot: {ResponseString}", dataString);
|
||||
return new Error<string>("Unexpected CleverBot response received");
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,105 +0,0 @@
|
||||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
using System.Net.Http.Json;
|
||||
using SharpToken;
|
||||
|
||||
namespace NadekoBot.Modules.Games.Common.ChatterBot;
|
||||
|
||||
public class OfficialGpt3Session : IChatterBotSession
|
||||
{
|
||||
private string Uri
|
||||
=> $"https://api.openai.com/v1/chat/completions";
|
||||
|
||||
private readonly string _apiKey;
|
||||
private readonly string _model;
|
||||
private readonly int _maxHistory;
|
||||
private readonly int _maxTokens;
|
||||
private readonly int _minTokens;
|
||||
private readonly string _nadekoUsername;
|
||||
private readonly GptEncoding _encoding;
|
||||
private List<GPTMessage> messages = new();
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
|
||||
|
||||
public OfficialGpt3Session(
|
||||
string apiKey,
|
||||
ChatGptModel model,
|
||||
int chatHistory,
|
||||
int maxTokens,
|
||||
int minTokens,
|
||||
string personality,
|
||||
string nadekoUsername,
|
||||
IHttpClientFactory factory)
|
||||
{
|
||||
_apiKey = apiKey;
|
||||
_httpFactory = factory;
|
||||
switch (model)
|
||||
{
|
||||
case ChatGptModel.Gpt35Turbo:
|
||||
_model = "gpt-3.5-turbo";
|
||||
break;
|
||||
case ChatGptModel.Gpt4:
|
||||
_model = "gpt-4";
|
||||
break;
|
||||
case ChatGptModel.Gpt432k:
|
||||
_model = "gpt-4-32k";
|
||||
break;
|
||||
}
|
||||
_maxHistory = chatHistory;
|
||||
_maxTokens = maxTokens;
|
||||
_minTokens = minTokens;
|
||||
_nadekoUsername = nadekoUsername;
|
||||
_encoding = GptEncoding.GetEncodingForModel(_model);
|
||||
messages.Add(new GPTMessage(){Role = "user", Content = personality, Name = _nadekoUsername});
|
||||
}
|
||||
|
||||
public async Task<string> Think(string input, string username)
|
||||
{
|
||||
messages.Add(new GPTMessage(){Role = "user", Content = input, Name = username});
|
||||
while(messages.Count > _maxHistory + 2){
|
||||
messages.RemoveAt(1);
|
||||
}
|
||||
int tokensUsed = 0;
|
||||
foreach(GPTMessage message in messages){
|
||||
tokensUsed += _encoding.Encode(message.Content).Count;
|
||||
}
|
||||
tokensUsed *= 2; //Unsure why this is the case, but the token count chatgpt reports back is double what I calculate.
|
||||
//check if we have the minimum number of tokens available to use. Remove messages until we have enough, otherwise exit out and inform the user why.
|
||||
while(_maxTokens - tokensUsed <= _minTokens){
|
||||
if(messages.Count > 2){
|
||||
int tokens = _encoding.Encode(messages[1].Content).Count * 2;
|
||||
tokensUsed -= tokens;
|
||||
messages.RemoveAt(1);
|
||||
}
|
||||
else{
|
||||
return "Token count exceeded, please increase the number of tokens in the bot config and restart.";
|
||||
}
|
||||
}
|
||||
using var http = _httpFactory.CreateClient();
|
||||
http.DefaultRequestHeaders.Authorization = new("Bearer", _apiKey);
|
||||
var data = await http.PostAsJsonAsync(Uri, new Gpt3ApiRequest()
|
||||
{
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
MaxTokens = _maxTokens - tokensUsed,
|
||||
Temperature = 1,
|
||||
});
|
||||
var dataString = await data.Content.ReadAsStringAsync();
|
||||
try
|
||||
{
|
||||
var response = JsonConvert.DeserializeObject<Gpt3Response>(dataString);
|
||||
string message = response?.Choices[0]?.Message?.Content;
|
||||
//Can't rely on the return to except, now that we need to add it to the messages list.
|
||||
_ = message ?? throw new ArgumentNullException(nameof(message));
|
||||
messages.Add(new GPTMessage(){Role = "assistant", Content = message, Name = _nadekoUsername});
|
||||
return message;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Unexpected GPT-3 response received: {ResponseString}", dataString);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,141 @@
|
||||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
using OneOf.Types;
|
||||
using System.Net.Http.Json;
|
||||
using SharpToken;
|
||||
|
||||
namespace NadekoBot.Modules.Games.Common.ChatterBot;
|
||||
|
||||
public class OfficialGptSession : IChatterBotSession
|
||||
{
|
||||
private string Uri
|
||||
=> $"https://api.openai.com/v1/chat/completions";
|
||||
|
||||
private readonly string _apiKey;
|
||||
private readonly string _model;
|
||||
private readonly int _maxHistory;
|
||||
private readonly int _maxTokens;
|
||||
private readonly int _minTokens;
|
||||
private readonly string _nadekoUsername;
|
||||
private readonly GptEncoding _encoding;
|
||||
private List<GPTMessage> messages = new();
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
|
||||
public OfficialGptSession(
|
||||
string apiKey,
|
||||
ChatGptModel model,
|
||||
int chatHistory,
|
||||
int maxTokens,
|
||||
int minTokens,
|
||||
string personality,
|
||||
string nadekoUsername,
|
||||
IHttpClientFactory factory)
|
||||
{
|
||||
_apiKey = apiKey;
|
||||
_httpFactory = factory;
|
||||
|
||||
_model = model switch
|
||||
{
|
||||
ChatGptModel.Gpt35Turbo => "gpt-3.5-turbo",
|
||||
ChatGptModel.Gpt4o => "gpt-4o",
|
||||
_ => throw new ArgumentException("Unknown, unsupported or obsolete model", nameof(model))
|
||||
};
|
||||
|
||||
_maxHistory = chatHistory;
|
||||
_maxTokens = maxTokens;
|
||||
_minTokens = minTokens;
|
||||
_nadekoUsername = nadekoUsername;
|
||||
_encoding = GptEncoding.GetEncodingForModel(_model);
|
||||
messages.Add(new()
|
||||
{
|
||||
Role = "system",
|
||||
Content = personality,
|
||||
Name = _nadekoUsername
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<OneOf.OneOf<ThinkResult, Error<string>>> Think(string input, string username)
|
||||
{
|
||||
messages.Add(new()
|
||||
{
|
||||
Role = "user",
|
||||
Content = input,
|
||||
Name = username
|
||||
});
|
||||
while (messages.Count > _maxHistory + 2)
|
||||
{
|
||||
messages.RemoveAt(1);
|
||||
}
|
||||
|
||||
var tokensUsed = messages.Sum(message => _encoding.Encode(message.Content).Count);
|
||||
|
||||
tokensUsed *= 2;
|
||||
|
||||
//check if we have the minimum number of tokens available to use. Remove messages until we have enough, otherwise exit out and inform the user why.
|
||||
while (_maxTokens - tokensUsed <= _minTokens)
|
||||
{
|
||||
if (messages.Count > 2)
|
||||
{
|
||||
var tokens = _encoding.Encode(messages[1].Content).Count * 2;
|
||||
tokensUsed -= tokens;
|
||||
messages.RemoveAt(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new Error<string>("Token count exceeded, please increase the number of tokens in the bot config and restart.");
|
||||
}
|
||||
}
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
http.DefaultRequestHeaders.Authorization = new("Bearer", _apiKey);
|
||||
|
||||
var data = await http.PostAsJsonAsync(Uri,
|
||||
new Gpt3ApiRequest()
|
||||
{
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
MaxTokens = _maxTokens - tokensUsed,
|
||||
Temperature = 1,
|
||||
});
|
||||
|
||||
var dataString = await data.Content.ReadAsStringAsync();
|
||||
try
|
||||
{
|
||||
var response = JsonConvert.DeserializeObject<OpenAiCompletionResponse>(dataString);
|
||||
var res = response?.Choices?[0];
|
||||
var message = res?.Message?.Content;
|
||||
|
||||
if (message is null)
|
||||
{
|
||||
return new Error<string>("ChatGpt: Received no response.");
|
||||
}
|
||||
|
||||
messages.Add(new()
|
||||
{
|
||||
Role = "assistant",
|
||||
Content = message,
|
||||
Name = _nadekoUsername
|
||||
});
|
||||
|
||||
return new ThinkResult()
|
||||
{
|
||||
Text = message,
|
||||
TokensIn = response.Usage.PromptTokens,
|
||||
TokensOut = response.Usage.CompletionTokens
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Unexpected response received from OpenAI: {ResponseString}", dataString);
|
||||
return new Error<string>("Unexpected response received");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ThinkResult
|
||||
{
|
||||
public string Text { get; set; }
|
||||
public int TokensIn { get; set; }
|
||||
public int TokensOut { get; set; }
|
||||
}
|
@@ -8,7 +8,7 @@ namespace NadekoBot.Modules.Games.Common;
|
||||
public sealed partial class GamesConfig : ICloneable<GamesConfig>
|
||||
{
|
||||
[Comment("DO NOT CHANGE")]
|
||||
public int Version { get; set; } = 3;
|
||||
public int Version { get; set; } = 4;
|
||||
|
||||
[Comment("Hangman related settings (.hangman command)")]
|
||||
public HangmanConfig Hangman { get; set; } = new()
|
||||
@@ -105,8 +105,8 @@ public sealed partial class GamesConfig : ICloneable<GamesConfig>
|
||||
|
||||
[Comment(@"Which chatbot API should bot use.
|
||||
'cleverbot' - bot will use Cleverbot API.
|
||||
'gpt3' - bot will use GPT-3 API")]
|
||||
public ChatBotImplementation ChatBot { get; set; } = ChatBotImplementation.Gpt3;
|
||||
'gpt' - bot will use GPT API")]
|
||||
public ChatBotImplementation ChatBot { get; set; } = ChatBotImplementation.Gpt;
|
||||
|
||||
public ChatGptConfig ChatGpt { get; set; } = new();
|
||||
}
|
||||
@@ -114,10 +114,10 @@ public sealed partial class GamesConfig : ICloneable<GamesConfig>
|
||||
[Cloneable]
|
||||
public sealed partial class ChatGptConfig
|
||||
{
|
||||
[Comment(@"Which GPT-3 Model should bot use.
|
||||
[Comment(@"Which GPT Model should bot use.
|
||||
gpt35turbo - cheapest
|
||||
gpt4 - 30x more expensive, higher quality
|
||||
gp432k - same model as above, but with a 32k token limit")]
|
||||
gpt4o - more expensive, higher quality
|
||||
")]
|
||||
public ChatGptModel ModelName { get; set; } = ChatGptModel.Gpt35Turbo;
|
||||
|
||||
[Comment(@"How should the chat bot behave, what's its personality? (Usage of this counts towards the max tokens)")]
|
||||
@@ -126,10 +126,10 @@ public sealed partial class ChatGptConfig
|
||||
[Comment(@"The maximum number of messages in a conversation that can be remembered. (This will increase the number of tokens used)")]
|
||||
public int ChatHistory { get; set; } = 5;
|
||||
|
||||
[Comment(@"The maximum number of tokens to use per GPT-3 API call")]
|
||||
[Comment(@"The maximum number of tokens to use per GPT API call")]
|
||||
public int MaxTokens { get; set; } = 100;
|
||||
|
||||
[Comment(@"The minimum number of tokens to use per GPT-3 API call, such that chat history is removed to make room.")]
|
||||
[Comment(@"The minimum number of tokens to use per GPT API call, such that chat history is removed to make room.")]
|
||||
public int MinTokens { get; set; } = 30;
|
||||
}
|
||||
|
||||
@@ -163,12 +163,18 @@ public sealed partial class RaceAnimal
|
||||
public enum ChatBotImplementation
|
||||
{
|
||||
Cleverbot,
|
||||
Gpt3
|
||||
Gpt = 1,
|
||||
[Obsolete]
|
||||
Gpt3 = 1,
|
||||
}
|
||||
|
||||
public enum ChatGptModel
|
||||
{
|
||||
Gpt35Turbo,
|
||||
[Obsolete]
|
||||
Gpt4,
|
||||
Gpt432k
|
||||
[Obsolete]
|
||||
Gpt432k,
|
||||
|
||||
Gpt35Turbo,
|
||||
Gpt4o,
|
||||
}
|
@@ -73,15 +73,6 @@ public sealed class GamesConfigService : ConfigServiceBase<GamesConfig>
|
||||
});
|
||||
}
|
||||
|
||||
if (data.Version < 2)
|
||||
{
|
||||
ModifyConfig(c =>
|
||||
{
|
||||
c.Version = 2;
|
||||
c.ChatBot = ChatBotImplementation.Cleverbot;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.Version < 3)
|
||||
{
|
||||
ModifyConfig(c =>
|
||||
@@ -90,5 +81,19 @@ public sealed class GamesConfigService : ConfigServiceBase<GamesConfig>
|
||||
c.ChatGpt.ModelName = ChatGptModel.Gpt35Turbo;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.Version < 4)
|
||||
{
|
||||
ModifyConfig(c =>
|
||||
{
|
||||
c.Version = 4;
|
||||
#pragma warning disable CS0612 // Type or member is obsolete
|
||||
c.ChatGpt.ModelName =
|
||||
c.ChatGpt.ModelName == ChatGptModel.Gpt4 || c.ChatGpt.ModelName == ChatGptModel.Gpt432k
|
||||
? ChatGptModel.Gpt4o
|
||||
: c.ChatGpt.ModelName;
|
||||
#pragma warning restore CS0612 // Type or member is obsolete
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Music.Services;
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Utility;
|
||||
|
||||
namespace NadekoBot.Modules.Music;
|
||||
|
||||
|
@@ -212,7 +212,7 @@ public sealed class MusicService : IMusicService, IPlaceholderProvider
|
||||
if (settings.AutoDisconnect)
|
||||
return LeaveVoiceChannelAsync(guildId);
|
||||
}
|
||||
|
||||
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
|
@@ -32,5 +32,14 @@ public class PatronageConfig : ConfigServiceBase<PatronConfigData>
|
||||
c.IsEnabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
ModifyConfig(c =>
|
||||
{
|
||||
if (c.Version == 2)
|
||||
{
|
||||
c.Version = 3;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
using NadekoBot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Patronage;
|
||||
|
||||
public readonly struct InsufficientTier
|
||||
{
|
||||
public FeatureType FeatureType { get; init; }
|
||||
public string Feature { get; init; }
|
||||
public PatronTier RequiredTier { get; init; }
|
||||
public PatronTier UserTier { get; init; }
|
||||
}
|
@@ -25,9 +25,4 @@ public sealed class PatreonMemberData : ISubscriberData
|
||||
"Declined" or "Pending" => SubscriptionChargeStatus.Unpaid,
|
||||
_ => SubscriptionChargeStatus.Other,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class PatreonPledgeData
|
||||
{
|
||||
|
||||
}
|
@@ -71,17 +71,16 @@ public partial class Help
|
||||
return;
|
||||
}
|
||||
|
||||
var patron = await _service.GetPatronAsync(user.Id);
|
||||
var quotaStats = await _service.GetUserQuotaStatistic(user.Id);
|
||||
var maybePatron = await _service.GetPatronAsync(user.Id);
|
||||
|
||||
var quotaStats = await _service.LimitStats(user.Id);
|
||||
|
||||
var eb = _sender.CreateEmbed()
|
||||
.WithAuthor(user)
|
||||
.WithTitle(GetText(strs.patron_info))
|
||||
.WithOkColor();
|
||||
|
||||
if (quotaStats.Commands.Count == 0
|
||||
&& quotaStats.Groups.Count == 0
|
||||
&& quotaStats.Modules.Count == 0)
|
||||
if (quotaStats.Count == 0 || maybePatron is not { } patron)
|
||||
{
|
||||
eb.WithDescription(GetText(strs.no_quota_found));
|
||||
}
|
||||
@@ -97,26 +96,9 @@ public partial class Help
|
||||
|
||||
eb.AddField(GetText(strs.quotas), "", false);
|
||||
|
||||
if (quotaStats.Commands.Count > 0)
|
||||
{
|
||||
var text = GetQuotaList(quotaStats.Commands);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
eb.AddField(GetText(strs.commands), text, true);
|
||||
}
|
||||
|
||||
if (quotaStats.Groups.Count > 0)
|
||||
{
|
||||
var text = GetQuotaList(quotaStats.Groups);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
eb.AddField(GetText(strs.groups), text, true);
|
||||
}
|
||||
|
||||
if (quotaStats.Modules.Count > 0)
|
||||
{
|
||||
var text = GetQuotaList(quotaStats.Modules);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
eb.AddField(GetText(strs.modules), text, true);
|
||||
}
|
||||
var text = GetQuotaList(quotaStats);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
eb.AddField(GetText(strs.modules), text, true);
|
||||
}
|
||||
|
||||
|
||||
@@ -131,26 +113,28 @@ public partial class Help
|
||||
}
|
||||
}
|
||||
|
||||
private string GetQuotaList(IReadOnlyDictionary<string, FeatureQuotaStats> featureQuotaStats)
|
||||
private string GetQuotaList(
|
||||
IReadOnlyDictionary<LimitedFeatureName, (int Cur, QuotaLimit Quota)> featureQuotaStats)
|
||||
{
|
||||
var text = string.Empty;
|
||||
foreach (var (key, q) in featureQuotaStats)
|
||||
foreach (var (key, (cur, quota)) in featureQuotaStats)
|
||||
{
|
||||
text += $"\n\t`{key}`\n";
|
||||
if (q.Hourly != default)
|
||||
text += $" {GetEmoji(q.Hourly)} {q.Hourly.Cur}/{q.Hourly.Max} per hour\n";
|
||||
if (q.Daily != default)
|
||||
text += $" {GetEmoji(q.Daily)} {q.Daily.Cur}/{q.Daily.Max} per day\n";
|
||||
if (q.Monthly != default)
|
||||
text += $" {GetEmoji(q.Monthly)} {q.Monthly.Cur}/{q.Monthly.Max} per month\n";
|
||||
if (quota.QuotaPeriod == QuotaPer.PerHour)
|
||||
text += $" {cur}/{(quota.Quota == -1 ? "∞" : quota.Quota)} {QuotaPeriodToString(quota.QuotaPeriod)}\n";
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private string GetEmoji((uint Cur, uint Max) limit)
|
||||
=> limit.Cur < limit.Max
|
||||
? "✅"
|
||||
: "⚠️";
|
||||
public string QuotaPeriodToString(QuotaPer per)
|
||||
=> per switch
|
||||
{
|
||||
QuotaPer.PerHour => "per hour",
|
||||
QuotaPer.PerDay => "per day",
|
||||
QuotaPer.PerMonth => "per month",
|
||||
QuotaPer.Total => "total",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(per), per, null)
|
||||
};
|
||||
}
|
||||
}
|
@@ -2,9 +2,8 @@
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db.Models;
|
||||
using OneOf;
|
||||
using OneOf.Types;
|
||||
using CommandInfo = Discord.Commands.CommandInfo;
|
||||
using StackExchange.Redis;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace NadekoBot.Modules.Patronage;
|
||||
|
||||
@@ -12,7 +11,6 @@ namespace NadekoBot.Modules.Patronage;
|
||||
public sealed class PatronageService
|
||||
: IPatronageService,
|
||||
IReadyExecutor,
|
||||
IExecPreCommand,
|
||||
INService
|
||||
{
|
||||
public event Func<Patron, Task> OnNewPatronPayment = static delegate { return Task.CompletedTask; };
|
||||
@@ -60,7 +58,7 @@ public sealed class PatronageService
|
||||
if (_client.ShardId != 0)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return Task.WhenAll(ResetLoopAsync(), LoadSubscribersLoopAsync());
|
||||
return Task.WhenAll(LoadSubscribersLoopAsync());
|
||||
}
|
||||
|
||||
private async Task LoadSubscribersLoopAsync()
|
||||
@@ -85,71 +83,6 @@ public sealed class PatronageService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ResetLoopAsync()
|
||||
{
|
||||
await Task.Delay(1.Minutes());
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_pConf.Data.IsEnabled)
|
||||
{
|
||||
await Task.Delay(1.Minutes());
|
||||
continue;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastRun = DateTime.MinValue;
|
||||
|
||||
var result = await _cache.GetAsync(_quotaKey);
|
||||
if (result.TryGetValue(out var lastVal) && lastVal != default)
|
||||
{
|
||||
lastRun = DateTime.FromBinary(lastVal);
|
||||
}
|
||||
|
||||
var nowDate = now.ToDateOnly();
|
||||
var lastDate = lastRun.ToDateOnly();
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
if ((lastDate.Day == 1 || (lastDate.Month != nowDate.Month)) && nowDate.Day > 1)
|
||||
{
|
||||
// assumes bot won't be offline for a year
|
||||
await ctx.GetTable<PatronQuota>()
|
||||
.TruncateAsync();
|
||||
}
|
||||
else if (nowDate.DayNumber != lastDate.DayNumber)
|
||||
{
|
||||
// day is different, means hour is different.
|
||||
// reset both hourly and daily quota counts.
|
||||
await ctx.GetTable<PatronQuota>()
|
||||
.UpdateAsync((old) => new()
|
||||
{
|
||||
HourlyCount = 0,
|
||||
DailyCount = 0,
|
||||
});
|
||||
}
|
||||
else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas
|
||||
{
|
||||
await ctx.GetTable<PatronQuota>()
|
||||
.UpdateAsync((old) => new()
|
||||
{
|
||||
HourlyCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
// assumes that the code above runs in less than an hour
|
||||
await _cache.AddAsync(_quotaKey, now.ToBinary());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error in quota reset loop. Message: {ErrorMessage}", ex.Message);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromHours(1).Add(TimeSpan.FromMinutes(1)));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcesssPatronsAsync(IReadOnlyCollection<ISubscriberData> subscribersEnum)
|
||||
{
|
||||
// process only users who have discord accounts connected
|
||||
@@ -284,313 +217,7 @@ public sealed class PatronageService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ExecPreCommandAsync(
|
||||
ICommandContext ctx,
|
||||
string moduleName,
|
||||
CommandInfo command)
|
||||
{
|
||||
var ownerId = ctx.Guild?.OwnerId ?? 0;
|
||||
|
||||
var result = await AttemptRunCommand(
|
||||
ctx.User.Id,
|
||||
ownerId: ownerId,
|
||||
command.Aliases.First().ToLowerInvariant(),
|
||||
command.Module.Parent == null ? string.Empty : command.Module.GetGroupName().ToLowerInvariant(),
|
||||
moduleName.ToLowerInvariant()
|
||||
);
|
||||
|
||||
return result.Match(
|
||||
_ => false,
|
||||
ins =>
|
||||
{
|
||||
var eb = _sender.CreateEmbed()
|
||||
.WithPendingColor()
|
||||
.WithTitle("Insufficient Patron Tier")
|
||||
.AddField("For", $"{ins.FeatureType}: `{ins.Feature}`", true)
|
||||
.AddField("Required Tier",
|
||||
$"[{ins.RequiredTier.ToFullName()}](https://patreon.com/join/nadekobot)",
|
||||
true);
|
||||
|
||||
if (ctx.Guild is null || ctx.Guild?.OwnerId == ctx.User.Id)
|
||||
eb.WithDescription("You don't have the sufficent Patron Tier to run this command.")
|
||||
.WithFooter("You can use '.patron' and '.donate' commands for more info");
|
||||
else
|
||||
eb.WithDescription(
|
||||
"Neither you nor the server owner have the sufficent Patron Tier to run this command.")
|
||||
.WithFooter("You can use '.patron' and '.donate' commands for more info");
|
||||
|
||||
_ = ctx.WarningAsync();
|
||||
|
||||
if (ctx.Guild?.OwnerId == ctx.User.Id)
|
||||
_ = _sender.Response(ctx)
|
||||
.Context(ctx)
|
||||
.Embed(eb)
|
||||
.SendAsync();
|
||||
else
|
||||
_ = _sender.Response(ctx).User(ctx.User).Embed(eb).SendAsync();
|
||||
|
||||
return true;
|
||||
},
|
||||
quota =>
|
||||
{
|
||||
var eb = _sender.CreateEmbed()
|
||||
.WithPendingColor()
|
||||
.WithTitle("Quota Limit Reached");
|
||||
|
||||
if (quota.IsOwnQuota || ctx.User.Id == ownerId)
|
||||
{
|
||||
eb.WithDescription($"You've reached your quota of `{quota.Quota} {quota.QuotaPeriod.ToFullName()}`")
|
||||
.WithFooter("You may want to check your quota by using the '.patron' command.");
|
||||
}
|
||||
else
|
||||
{
|
||||
eb.WithDescription(
|
||||
$"This server reached the quota of {quota.Quota} `{quota.QuotaPeriod.ToFullName()}`")
|
||||
.WithFooter("You may contact the server owner about this issue.\n"
|
||||
+ "Alternatively, you can become patron yourself by using the '.donate' command.\n"
|
||||
+ "If you're already a patron, it means you've reached your quota.\n"
|
||||
+ "You can use '.patron' command to check your quota status.");
|
||||
}
|
||||
|
||||
eb.AddField("For", $"{quota.FeatureType}: `{quota.Feature}`", true)
|
||||
.AddField("Resets At", quota.ResetsAt.ToShortAndRelativeTimestampTag(), true);
|
||||
|
||||
_ = ctx.WarningAsync();
|
||||
|
||||
// send the message in the server in case it's the owner
|
||||
if (ctx.Guild?.OwnerId == ctx.User.Id)
|
||||
_ = _sender.Response(ctx)
|
||||
.Embed(eb)
|
||||
.SendAsync();
|
||||
else
|
||||
_ = _sender.Response(ctx).User(ctx.User).Embed(eb).SendAsync();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async ValueTask<OneOf<OneOf.Types.Success, InsufficientTier, QuotaLimit>> AttemptRunCommand(
|
||||
ulong userId,
|
||||
ulong ownerId,
|
||||
string commandName,
|
||||
string groupName,
|
||||
string moduleName)
|
||||
{
|
||||
// try to run as a user
|
||||
var res = await AttemptRunCommand(userId, commandName, groupName, moduleName, true);
|
||||
|
||||
// if it fails, try to run as an owner
|
||||
// but only if the command is ran in a server
|
||||
// and if the owner is not the user
|
||||
if (!res.IsT0 && ownerId != 0 && ownerId != userId)
|
||||
res = await AttemptRunCommand(ownerId, commandName, groupName, moduleName, false);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns either the current usage counter if limit wasn't reached, or QuotaLimit if it is.
|
||||
/// </summary>
|
||||
public async ValueTask<OneOf<(uint Hourly, uint Daily, uint Monthly), QuotaLimit>> TryIncrementQuotaCounterAsync(
|
||||
ulong userId,
|
||||
bool isSelf,
|
||||
FeatureType featureType,
|
||||
string featureName,
|
||||
uint? maybeHourly,
|
||||
uint? maybeDaily,
|
||||
uint? maybeMonthly)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
await using var tran = await ctx.Database.BeginTransactionAsync();
|
||||
|
||||
var userQuotaData = await ctx.GetTable<PatronQuota>()
|
||||
.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId
|
||||
&& x.Feature == featureName)
|
||||
?? new PatronQuota();
|
||||
|
||||
// if hourly exists, if daily exists, etc...
|
||||
if (maybeHourly is uint hourly && userQuotaData.HourlyCount >= hourly)
|
||||
{
|
||||
return new QuotaLimit()
|
||||
{
|
||||
QuotaPeriod = QuotaPer.PerHour,
|
||||
Quota = hourly,
|
||||
// quite a neat trick. https://stackoverflow.com/a/5733560
|
||||
ResetsAt = now.Date.AddHours(now.Hour + 1),
|
||||
Feature = featureName,
|
||||
FeatureType = featureType,
|
||||
IsOwnQuota = isSelf
|
||||
};
|
||||
}
|
||||
|
||||
if (maybeDaily is uint daily
|
||||
&& userQuotaData.DailyCount >= daily)
|
||||
{
|
||||
return new QuotaLimit()
|
||||
{
|
||||
QuotaPeriod = QuotaPer.PerDay,
|
||||
Quota = daily,
|
||||
ResetsAt = now.Date.AddDays(1),
|
||||
Feature = featureName,
|
||||
FeatureType = featureType,
|
||||
IsOwnQuota = isSelf
|
||||
};
|
||||
}
|
||||
|
||||
if (maybeMonthly is uint monthly && userQuotaData.MonthlyCount >= monthly)
|
||||
{
|
||||
return new QuotaLimit()
|
||||
{
|
||||
QuotaPeriod = QuotaPer.PerMonth,
|
||||
Quota = monthly,
|
||||
ResetsAt = now.Date.SecondOfNextMonth(),
|
||||
Feature = featureName,
|
||||
FeatureType = featureType,
|
||||
IsOwnQuota = isSelf
|
||||
};
|
||||
}
|
||||
|
||||
await ctx.GetTable<PatronQuota>()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
{
|
||||
UserId = userId,
|
||||
FeatureType = featureType,
|
||||
Feature = featureName,
|
||||
DailyCount = 1,
|
||||
MonthlyCount = 1,
|
||||
HourlyCount = 1,
|
||||
},
|
||||
(old) => new()
|
||||
{
|
||||
HourlyCount = old.HourlyCount + 1,
|
||||
DailyCount = old.DailyCount + 1,
|
||||
MonthlyCount = old.MonthlyCount + 1,
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
UserId = userId,
|
||||
FeatureType = featureType,
|
||||
Feature = featureName,
|
||||
});
|
||||
|
||||
await tran.CommitAsync();
|
||||
|
||||
return (userQuotaData.HourlyCount + 1, userQuotaData.DailyCount + 1, userQuotaData.MonthlyCount + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to add 1 to user's quota for the command, group and module.
|
||||
/// Input MUST BE lowercase
|
||||
/// </summary>
|
||||
/// <param name="userId">Id of the user who is attempting to run the command</param>
|
||||
/// <param name="commandName">Name of the command the user is trying to run</param>
|
||||
/// <param name="groupName">Name of the command's group</param>
|
||||
/// <param name="moduleName">Name of the command's top level module</param>
|
||||
/// <param name="isSelf">Whether this is check is for the user himself. False if it's someone else's id (owner)</param>
|
||||
/// <returns>Either a succcess (user can run the command) or one of the error values.</returns>
|
||||
private async ValueTask<OneOf<OneOf.Types.Success, InsufficientTier, QuotaLimit>> AttemptRunCommand(
|
||||
ulong userId,
|
||||
string commandName,
|
||||
string groupName,
|
||||
string moduleName,
|
||||
bool isSelf)
|
||||
{
|
||||
var confData = _pConf.Data;
|
||||
|
||||
if (!confData.IsEnabled)
|
||||
return default;
|
||||
|
||||
if (_creds.GetCreds().IsOwner(userId))
|
||||
return default;
|
||||
|
||||
// get user tier
|
||||
var patron = await GetPatronAsync(userId);
|
||||
FeatureType quotaForFeatureType;
|
||||
|
||||
if (confData.Quotas.Commands.TryGetValue(commandName, out var quotaData))
|
||||
{
|
||||
quotaForFeatureType = FeatureType.Command;
|
||||
}
|
||||
else if (confData.Quotas.Groups.TryGetValue(groupName, out quotaData))
|
||||
{
|
||||
quotaForFeatureType = FeatureType.Group;
|
||||
}
|
||||
else if (confData.Quotas.Modules.TryGetValue(moduleName, out quotaData))
|
||||
{
|
||||
quotaForFeatureType = FeatureType.Module;
|
||||
}
|
||||
else
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var featureName = quotaForFeatureType switch
|
||||
{
|
||||
FeatureType.Command => commandName,
|
||||
FeatureType.Group => groupName,
|
||||
FeatureType.Module => moduleName,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(quotaForFeatureType))
|
||||
};
|
||||
|
||||
if (!TryGetTierDataOrLower(quotaData, patron.Tier, out var data))
|
||||
{
|
||||
return new InsufficientTier()
|
||||
{
|
||||
Feature = featureName,
|
||||
FeatureType = quotaForFeatureType,
|
||||
RequiredTier = quotaData.Count == 0
|
||||
? PatronTier.ComingSoon
|
||||
: quotaData.Keys.First(),
|
||||
UserTier = patron.Tier,
|
||||
};
|
||||
}
|
||||
|
||||
// no quota limits for this tier
|
||||
if (data is null)
|
||||
return default;
|
||||
|
||||
var quotaCheckResult = await TryIncrementQuotaCounterAsync(userId,
|
||||
isSelf,
|
||||
quotaForFeatureType,
|
||||
featureName,
|
||||
data.TryGetValue(QuotaPer.PerHour, out var hourly) ? hourly : null,
|
||||
data.TryGetValue(QuotaPer.PerDay, out var daily) ? daily : null,
|
||||
data.TryGetValue(QuotaPer.PerMonth, out var monthly) ? monthly : null
|
||||
);
|
||||
|
||||
return quotaCheckResult.Match<OneOf<Success, InsufficientTier, QuotaLimit>>(
|
||||
_ => new Success(),
|
||||
x => x);
|
||||
}
|
||||
|
||||
private bool TryGetTierDataOrLower<T>(
|
||||
IReadOnlyDictionary<PatronTier, T?> data,
|
||||
PatronTier tier,
|
||||
out T? o)
|
||||
{
|
||||
// check for quotas on this tier
|
||||
if (data.TryGetValue(tier, out o))
|
||||
return true;
|
||||
|
||||
// if there are none, get the quota first tier below this one
|
||||
// which has quotas specified
|
||||
for (var i = _tiers.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var lowerTier = _tiers[i];
|
||||
if (lowerTier < tier && data.TryGetValue(lowerTier, out o))
|
||||
return true;
|
||||
}
|
||||
|
||||
// if there are none, that means the feature is intended
|
||||
// to be patron-only but the quotas haven't been specified yet
|
||||
// so it will be marked as "Coming Soon"
|
||||
o = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<Patron> GetPatronAsync(ulong userId)
|
||||
public async Task<Patron?> GetPatronAsync(ulong userId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
@@ -616,128 +243,126 @@ public sealed class PatronageService
|
||||
return PatronUserToPatron(max);
|
||||
}
|
||||
|
||||
public async Task<UserQuotaStats> GetUserQuotaStatistic(ulong userId)
|
||||
public async Task<bool> LimitHitAsync(LimitedFeatureName key, ulong userId, int amount = 1)
|
||||
{
|
||||
var pConfData = _pConf.Data;
|
||||
if (_creds.GetCreds().IsOwner(userId))
|
||||
return true;
|
||||
|
||||
if (!_pConf.Data.IsEnabled)
|
||||
return true;
|
||||
|
||||
if (!pConfData.IsEnabled)
|
||||
return new();
|
||||
var userLimit = await GetUserLimit(key, userId);
|
||||
|
||||
var patron = await GetPatronAsync(userId);
|
||||
if (userLimit.Quota == 0)
|
||||
return false;
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var allPatronQuotas = await ctx.GetTable<PatronQuota>()
|
||||
.Where(x => x.UserId == userId)
|
||||
.ToListAsync();
|
||||
if (userLimit.Quota == -1)
|
||||
return true;
|
||||
|
||||
var allQuotasDict = allPatronQuotas
|
||||
.GroupBy(static x => x.FeatureType)
|
||||
.ToDictionary(static x => x.Key, static x => x.ToDictionary(static y => y.Feature));
|
||||
|
||||
allQuotasDict.TryGetValue(FeatureType.Command, out var data);
|
||||
var userCommandQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Commands);
|
||||
|
||||
allQuotasDict.TryGetValue(FeatureType.Group, out data);
|
||||
var userGroupQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Groups);
|
||||
|
||||
allQuotasDict.TryGetValue(FeatureType.Module, out data);
|
||||
var userModuleQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Modules);
|
||||
|
||||
return new UserQuotaStats()
|
||||
{
|
||||
Tier = patron.Tier,
|
||||
Commands = userCommandQuotaStats,
|
||||
Groups = userGroupQuotaStats,
|
||||
Modules = userModuleQuotaStats,
|
||||
};
|
||||
return await TryAddLimit(key, userLimit, userId, amount);
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, FeatureQuotaStats> GetFeatureQuotaStats(
|
||||
PatronTier patronTier,
|
||||
IReadOnlyDictionary<string, PatronQuota>? allQuotasDict,
|
||||
Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> commands)
|
||||
public async Task<bool> LimitForceHit(LimitedFeatureName key, ulong userId, int amount)
|
||||
{
|
||||
var userCommandQuotaStats = new Dictionary<string, FeatureQuotaStats>();
|
||||
foreach (var (key, quotaData) in commands)
|
||||
if (_creds.GetCreds().IsOwner(userId))
|
||||
return true;
|
||||
|
||||
if (!_pConf.Data.IsEnabled)
|
||||
return true;
|
||||
|
||||
var userLimit = await GetUserLimit(key, userId);
|
||||
|
||||
var cacheKey = CreateKey(key, userId);
|
||||
await _cache.GetOrAddAsync(cacheKey, () => Task.FromResult(0), GetExpiry(userLimit));
|
||||
|
||||
return await TryAddLimit(key, userLimit, userId, amount);
|
||||
}
|
||||
|
||||
private async Task<bool> TryAddLimit(
|
||||
LimitedFeatureName key,
|
||||
QuotaLimit userLimit,
|
||||
ulong userId,
|
||||
int amount)
|
||||
{
|
||||
var cacheKey = CreateKey(key, userId);
|
||||
var cur = await _cache.GetOrAddAsync(cacheKey, () => Task.FromResult(0), GetExpiry(userLimit));
|
||||
|
||||
if (cur + amount < userLimit.Quota)
|
||||
{
|
||||
if (TryGetTierDataOrLower(quotaData, patronTier, out var data))
|
||||
await _cache.AddAsync(cacheKey, cur + amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private TimeSpan? GetExpiry(QuotaLimit userLimit)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
switch (userLimit.QuotaPeriod)
|
||||
{
|
||||
case QuotaPer.PerHour:
|
||||
return TimeSpan.FromMinutes(60 - now.Minute);
|
||||
case QuotaPer.PerDay:
|
||||
return TimeSpan.FromMinutes((24 * 60) - ((now.Hour * 60) + now.Minute));
|
||||
case QuotaPer.PerMonth:
|
||||
var firstOfNextMonth = now.FirstOfNextMonth();
|
||||
return firstOfNextMonth - now;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private TypedKey<int> CreateKey(LimitedFeatureName key, ulong userId)
|
||||
=> new($"limited_feature:{key}:{userId}");
|
||||
|
||||
private QuotaLimit _emptyQuota = new QuotaLimit()
|
||||
{
|
||||
Quota = 0,
|
||||
QuotaPeriod = QuotaPer.PerDay,
|
||||
};
|
||||
|
||||
public async Task<QuotaLimit> GetUserLimit(LimitedFeatureName name, ulong userId)
|
||||
{
|
||||
var maybePatron = await GetPatronAsync(userId);
|
||||
|
||||
if (maybePatron is not { } patron)
|
||||
return _emptyQuota;
|
||||
|
||||
if (patron.ValidThru < DateTime.UtcNow)
|
||||
return _emptyQuota;
|
||||
|
||||
foreach (var (key, value) in _pConf.Data.Limits)
|
||||
{
|
||||
if (patron.Amount >= key)
|
||||
{
|
||||
// if data is null that means the quota for the user's tier is unlimited
|
||||
// no point in returning it?
|
||||
|
||||
if (data is null)
|
||||
continue;
|
||||
|
||||
var (daily, hourly, monthly) = default((uint, uint, uint));
|
||||
// try to get users stats for this feature
|
||||
// if it fails just leave them at 0
|
||||
if (allQuotasDict?.TryGetValue(key, out var quota) ?? false)
|
||||
(daily, hourly, monthly) = (quota.DailyCount, quota.HourlyCount, quota.MonthlyCount);
|
||||
|
||||
userCommandQuotaStats[key] = new FeatureQuotaStats()
|
||||
if (value.TryGetValue(name, out var quotaLimit))
|
||||
{
|
||||
Hourly = data.TryGetValue(QuotaPer.PerHour, out var hourD)
|
||||
? (hourly, hourD)
|
||||
: default,
|
||||
Daily = data.TryGetValue(QuotaPer.PerDay, out var maxD)
|
||||
? (daily, maxD)
|
||||
: default,
|
||||
Monthly = data.TryGetValue(QuotaPer.PerMonth, out var maxM)
|
||||
? (monthly, maxM)
|
||||
: default,
|
||||
};
|
||||
return quotaLimit;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return userCommandQuotaStats;
|
||||
return _emptyQuota;
|
||||
}
|
||||
|
||||
public async Task<FeatureLimit> TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue)
|
||||
public async Task<Dictionary<LimitedFeatureName, (int, QuotaLimit)>> LimitStats(ulong userId)
|
||||
{
|
||||
var conf = _pConf.Data;
|
||||
|
||||
// if patron system is disabled, the quota is just default
|
||||
if (!conf.IsEnabled)
|
||||
return new()
|
||||
{
|
||||
Name = key.PrettyName,
|
||||
Quota = defaultValue,
|
||||
IsPatronLimit = false
|
||||
};
|
||||
|
||||
|
||||
if (!conf.Quotas.Features.TryGetValue(key.Key, out var data))
|
||||
return new()
|
||||
{
|
||||
Name = key.PrettyName,
|
||||
Quota = defaultValue,
|
||||
IsPatronLimit = false,
|
||||
};
|
||||
|
||||
var patron = await GetPatronAsync(userId);
|
||||
if (!TryGetTierDataOrLower(data, patron.Tier, out var limit))
|
||||
return new()
|
||||
{
|
||||
Name = key.PrettyName,
|
||||
Quota = 0,
|
||||
IsPatronLimit = true,
|
||||
};
|
||||
|
||||
return new()
|
||||
var dict = new Dictionary<LimitedFeatureName, (int, QuotaLimit)>();
|
||||
foreach (var featureName in Enum.GetValues<LimitedFeatureName>())
|
||||
{
|
||||
Name = key.PrettyName,
|
||||
Quota = limit,
|
||||
IsPatronLimit = true
|
||||
};
|
||||
var cacheKey = CreateKey(featureName, userId);
|
||||
var userLimit = await GetUserLimit(featureName, userId);
|
||||
var cur = await _cache.GetOrAddAsync(cacheKey, () => Task.FromResult(0), GetExpiry(userLimit));
|
||||
|
||||
dict[featureName] = (cur, userLimit);
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
// public async Task<Patron> GiftPatronAsync(IUser user, int amount)
|
||||
// {
|
||||
// if (amount < 1)
|
||||
// throw new ArgumentOutOfRangeException(nameof(amount));
|
||||
//
|
||||
//
|
||||
// }
|
||||
|
||||
private Patron PatronUserToPatron(PatronUser user)
|
||||
=> new Patron()
|
||||
@@ -767,6 +392,22 @@ public sealed class PatronageService
|
||||
};
|
||||
}
|
||||
|
||||
public int PercentBonus(Patron? maybePatron)
|
||||
=> maybePatron is { } user && user.ValidThru > DateTime.UtcNow
|
||||
? PercentBonus(user.Amount)
|
||||
: 0;
|
||||
|
||||
public int PercentBonus(long amount)
|
||||
=> amount switch
|
||||
{
|
||||
>= 10_000 => 100,
|
||||
>= 5000 => 50,
|
||||
>= 2000 => 20,
|
||||
>= 1000 => 10,
|
||||
>= 500 => 5,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
private async Task SendWelcomeMessage(Patron patron)
|
||||
{
|
||||
try
|
||||
@@ -776,28 +417,28 @@ public sealed class PatronageService
|
||||
return;
|
||||
|
||||
var eb = _sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithTitle("❤️ Thank you for supporting NadekoBot! ❤️")
|
||||
.WithDescription(
|
||||
"Your donation has been processed and you will receive the rewards shortly.\n"
|
||||
+ "You can visit <https://www.patreon.com/join/nadekobot> to see rewards for your tier. 🎉")
|
||||
.AddField("Tier", Format.Bold(patron.Tier.ToString()), true)
|
||||
.AddField("Pledge", $"**{patron.Amount / 100.0f:N1}$**", true)
|
||||
.AddField("Expires",
|
||||
patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(),
|
||||
true)
|
||||
.AddField("Instructions",
|
||||
"""
|
||||
*- Within the next **1-2 minutes** you will have all of the benefits of the Tier you've subscribed to.*
|
||||
*- You can check your benefits on <https://www.patreon.com/join/nadekobot>*
|
||||
*- You can use the `.patron` command in this chat to check your current quota usage for the Patron-only commands*
|
||||
*- **ALL** of the servers that you **own** will enjoy your Patron benefits.*
|
||||
*- You can use any of the commands available in your tier on any server (assuming you have sufficient permissions to run those commands)*
|
||||
*- Any user in any of your servers can use Patron-only commands, but they will spend **your quota**, which is why it's recommended to use Nadeko's command cooldown system (.h .cmdcd) or permission system to limit the command usage for your server members.*
|
||||
*- Permission guide can be found here if you're not familiar with it: <https://nadekobot.readthedocs.io/en/latest/permissions-system/>*
|
||||
""",
|
||||
inline: false)
|
||||
.WithFooter($"platform id: {patron.UniquePlatformUserId}");
|
||||
.WithOkColor()
|
||||
.WithTitle("❤️ Thank you for supporting NadekoBot! ❤️")
|
||||
.WithDescription(
|
||||
"Your donation has been processed and you will receive the rewards shortly.\n"
|
||||
+ "You can visit <https://www.patreon.com/join/nadekobot> to see rewards for your tier. 🎉")
|
||||
.AddField("Tier", Format.Bold(patron.Tier.ToString()), true)
|
||||
.AddField("Pledge", $"**{patron.Amount / 100.0f:N1}$**", true)
|
||||
.AddField("Expires",
|
||||
patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(),
|
||||
true)
|
||||
.AddField("Instructions",
|
||||
"""
|
||||
*- Within the next **1-2 minutes** you will have all of the benefits of the Tier you've subscribed to.*
|
||||
*- You can check your benefits on <https://www.patreon.com/join/nadekobot>*
|
||||
*- You can use the `.patron` command in this chat to check your current quota usage for the Patron-only commands*
|
||||
*- **ALL** of the servers that you **own** will enjoy your Patron benefits.*
|
||||
*- You can use any of the commands available in your tier on any server (assuming you have sufficient permissions to run those commands)*
|
||||
*- Any user in any of your servers can use Patron-only commands, but they will spend **your quota**, which is why it's recommended to use Nadeko's command cooldown system (.h .cmdcd) or permission system to limit the command usage for your server members.*
|
||||
*- Permission guide can be found here if you're not familiar with it: <https://nadekobot.readthedocs.io/en/latest/permissions-system/>*
|
||||
""",
|
||||
inline: false)
|
||||
.WithFooter($"platform id: {patron.UniquePlatformUserId}");
|
||||
|
||||
await _sender.Response(user).Embed(eb).SendAsync();
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
using AngleSharp;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -22,46 +23,32 @@ public sealed class DefaultStockDataService : IStockDataService, INService
|
||||
|
||||
using var http = _httpClientFactory.CreateClient();
|
||||
|
||||
|
||||
|
||||
var quoteHtmlPage = $"https://finance.yahoo.com/quote/{query.ToUpperInvariant()}";
|
||||
|
||||
var config = Configuration.Default.WithDefaultLoader();
|
||||
using var document = await BrowsingContext.New(config).OpenAsync(quoteHtmlPage);
|
||||
var divElem =
|
||||
document.QuerySelector(
|
||||
"#quote-header-info > div:nth-child(2) > div > div > h1");
|
||||
var tickerName = (divElem)?.TextContent;
|
||||
|
||||
var tickerName = document.QuerySelector("div.top > .left > .container > h1")
|
||||
?.TextContent;
|
||||
|
||||
if (tickerName is null)
|
||||
return default;
|
||||
|
||||
var marketcap = document
|
||||
.QuerySelectorAll("table")
|
||||
.Skip(1)
|
||||
.First()
|
||||
.QuerySelector("tbody > tr > td:nth-child(2)")
|
||||
.QuerySelector("li > span > fin-streamer[data-field='marketCap']")
|
||||
?.TextContent;
|
||||
|
||||
|
||||
var volume = document.QuerySelector("td[data-test='AVERAGE_VOLUME_3MONTH-value']")
|
||||
var volume = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketVolume']")
|
||||
?.TextContent;
|
||||
|
||||
var close= document.QuerySelector("td[data-test='PREV_CLOSE-value']")
|
||||
?.TextContent ?? "0";
|
||||
|
||||
var price = document
|
||||
.QuerySelector("#quote-header-info")
|
||||
?.QuerySelector("fin-streamer[data-field='regularMarketPrice']")
|
||||
?.TextContent ?? close;
|
||||
|
||||
// var data = await http.GetFromJsonAsync<YahooQueryModel>(
|
||||
// $"https://query1.finance.yahoo.com/v7/finance/quote?symbols={query}");
|
||||
//
|
||||
// if (data is null)
|
||||
// return default;
|
||||
|
||||
// var symbol = data.QuoteResponse.Result.FirstOrDefault();
|
||||
var close = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketPreviousClose']")
|
||||
?.TextContent
|
||||
?? "0";
|
||||
|
||||
// if (symbol is null)
|
||||
// return default;
|
||||
var price = document.QuerySelector("fin-streamer.livePrice > span")
|
||||
?.TextContent
|
||||
?? "0";
|
||||
|
||||
return new()
|
||||
{
|
||||
|
@@ -1,312 +0,0 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Searches.Common;
|
||||
using NadekoBot.Modules.Searches.Services;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public partial class Searches
|
||||
{
|
||||
[Group]
|
||||
public partial class PathOfExileCommands : NadekoModule<SearchesService>
|
||||
{
|
||||
private const string POE_URL = "https://www.pathofexile.com/character-window/get-characters?accountName=";
|
||||
private const string PON_URL = "http://poe.ninja/api/Data/GetCurrencyOverview?league=";
|
||||
private const string POGS_URL = "http://pathofexile.gamepedia.com/api.php?action=opensearch&search=";
|
||||
|
||||
private const string POG_URL =
|
||||
"https://pathofexile.gamepedia.com/api.php?action=browsebysubject&format=json&subject=";
|
||||
|
||||
private const string POGI_URL =
|
||||
"https://pathofexile.gamepedia.com/api.php?action=query&prop=imageinfo&iiprop=url&format=json&titles=File:";
|
||||
|
||||
private const string PROFILE_URL = "https://www.pathofexile.com/account/view-profile/";
|
||||
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
private Dictionary<string, string> currencyDictionary = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "Chaos Orb", "Chaos Orb" },
|
||||
{ "Orb of Alchemy", "Orb of Alchemy" },
|
||||
{ "Jeweller's Orb", "Jeweller's Orb" },
|
||||
{ "Exalted Orb", "Exalted Orb" },
|
||||
{ "Mirror of Kalandra", "Mirror of Kalandra" },
|
||||
{ "Vaal Orb", "Vaal Orb" },
|
||||
{ "Orb of Alteration", "Orb of Alteration" },
|
||||
{ "Orb of Scouring", "Orb of Scouring" },
|
||||
{ "Divine Orb", "Divine Orb" },
|
||||
{ "Orb of Annulment", "Orb of Annulment" },
|
||||
{ "Master Cartographer's Sextant", "Master Cartographer's Sextant" },
|
||||
{ "Journeyman Cartographer's Sextant", "Journeyman Cartographer's Sextant" },
|
||||
{ "Apprentice Cartographer's Sextant", "Apprentice Cartographer's Sextant" },
|
||||
{ "Blessed Orb", "Blessed Orb" },
|
||||
{ "Orb of Regret", "Orb of Regret" },
|
||||
{ "Gemcutter's Prism", "Gemcutter's Prism" },
|
||||
{ "Glassblower's Bauble", "Glassblower's Bauble" },
|
||||
{ "Orb of Fusing", "Orb of Fusing" },
|
||||
{ "Cartographer's Chisel", "Cartographer's Chisel" },
|
||||
{ "Chromatic Orb", "Chromatic Orb" },
|
||||
{ "Orb of Augmentation", "Orb of Augmentation" },
|
||||
{ "Blacksmith's Whetstone", "Blacksmith's Whetstone" },
|
||||
{ "Orb of Transmutation", "Orb of Transmutation" },
|
||||
{ "Armourer's Scrap", "Armourer's Scrap" },
|
||||
{ "Scroll of Wisdom", "Scroll of Wisdom" },
|
||||
{ "Regal Orb", "Regal Orb" },
|
||||
{ "Chaos", "Chaos Orb" },
|
||||
{ "Alch", "Orb of Alchemy" },
|
||||
{ "Alchs", "Orb of Alchemy" },
|
||||
{ "Jews", "Jeweller's Orb" },
|
||||
{ "Jeweller", "Jeweller's Orb" },
|
||||
{ "Jewellers", "Jeweller's Orb" },
|
||||
{ "Jeweller's", "Jeweller's Orb" },
|
||||
{ "X", "Exalted Orb" },
|
||||
{ "Ex", "Exalted Orb" },
|
||||
{ "Exalt", "Exalted Orb" },
|
||||
{ "Exalts", "Exalted Orb" },
|
||||
{ "Mirror", "Mirror of Kalandra" },
|
||||
{ "Mirrors", "Mirror of Kalandra" },
|
||||
{ "Vaal", "Vaal Orb" },
|
||||
{ "Alt", "Orb of Alteration" },
|
||||
{ "Alts", "Orb of Alteration" },
|
||||
{ "Scour", "Orb of Scouring" },
|
||||
{ "Scours", "Orb of Scouring" },
|
||||
{ "Divine", "Divine Orb" },
|
||||
{ "Annul", "Orb of Annulment" },
|
||||
{ "Annulment", "Orb of Annulment" },
|
||||
{ "Master Sextant", "Master Cartographer's Sextant" },
|
||||
{ "Journeyman Sextant", "Journeyman Cartographer's Sextant" },
|
||||
{ "Apprentice Sextant", "Apprentice Cartographer's Sextant" },
|
||||
{ "Blessed", "Blessed Orb" },
|
||||
{ "Regret", "Orb of Regret" },
|
||||
{ "Regrets", "Orb of Regret" },
|
||||
{ "Gcp", "Gemcutter's Prism" },
|
||||
{ "Glassblowers", "Glassblower's Bauble" },
|
||||
{ "Glassblower's", "Glassblower's Bauble" },
|
||||
{ "Fusing", "Orb of Fusing" },
|
||||
{ "Fuses", "Orb of Fusing" },
|
||||
{ "Fuse", "Orb of Fusing" },
|
||||
{ "Chisel", "Cartographer's Chisel" },
|
||||
{ "Chisels", "Cartographer's Chisel" },
|
||||
{ "Chance", "Orb of Chance" },
|
||||
{ "Chances", "Orb of Chance" },
|
||||
{ "Chrome", "Chromatic Orb" },
|
||||
{ "Chromes", "Chromatic Orb" },
|
||||
{ "Aug", "Orb of Augmentation" },
|
||||
{ "Augmentation", "Orb of Augmentation" },
|
||||
{ "Augment", "Orb of Augmentation" },
|
||||
{ "Augments", "Orb of Augmentation" },
|
||||
{ "Whetstone", "Blacksmith's Whetstone" },
|
||||
{ "Whetstones", "Blacksmith's Whetstone" },
|
||||
{ "Transmute", "Orb of Transmutation" },
|
||||
{ "Transmutes", "Orb of Transmutation" },
|
||||
{ "Armourers", "Armourer's Scrap" },
|
||||
{ "Armourer's", "Armourer's Scrap" },
|
||||
{ "Wisdom Scroll", "Scroll of Wisdom" },
|
||||
{ "Wisdom Scrolls", "Scroll of Wisdom" },
|
||||
{ "Regal", "Regal Orb" },
|
||||
{ "Regals", "Regal Orb" }
|
||||
};
|
||||
|
||||
public PathOfExileCommands(IHttpClientFactory httpFactory)
|
||||
=> _httpFactory = httpFactory;
|
||||
|
||||
[Cmd]
|
||||
public async Task PathOfExile(string usr, string league = "", int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(usr))
|
||||
{
|
||||
await Response().Error("Please provide an account name.").SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var characters = new List<Account>();
|
||||
|
||||
try
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var res = await http.GetStringAsync($"{POE_URL}{usr}");
|
||||
characters = JsonConvert.DeserializeObject<List<Account>>(res);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var embed = _sender.CreateEmbed().WithDescription(GetText(strs.account_not_found)).WithErrorColor();
|
||||
|
||||
await Response().Embed(embed).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(league))
|
||||
characters.RemoveAll(c => c.League != league);
|
||||
|
||||
await Response()
|
||||
.Paginated()
|
||||
.Items(characters)
|
||||
.PageSize(9)
|
||||
.CurrentPage(page)
|
||||
.Page((items, curPage) =>
|
||||
{
|
||||
var embed = _sender.CreateEmbed()
|
||||
.WithAuthor($"Characters on {usr}'s account",
|
||||
"https://web.poecdn.com/image/favicon/ogimage.png",
|
||||
$"{PROFILE_URL}{usr}")
|
||||
.WithOkColor();
|
||||
|
||||
if (characters.Count == 0)
|
||||
return embed.WithDescription("This account has no characters.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"```{"#",-5}{"Character Name",-23}{"League",-10}{"Class",-13}{"Level",-3}");
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var character = items[i];
|
||||
|
||||
sb.AppendLine(
|
||||
$"#{i + 1 + (curPage * 9),-4}{character.Name,-23}{ShortLeagueName(character.League),-10}{character.Class,-13}{character.Level,-3}");
|
||||
}
|
||||
|
||||
sb.AppendLine("```");
|
||||
embed.WithDescription(sb.ToString());
|
||||
|
||||
return embed;
|
||||
})
|
||||
.SendAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task PathOfExileLeagues()
|
||||
{
|
||||
var leagues = new List<Leagues>();
|
||||
|
||||
try
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var res = await http.GetStringAsync("http://api.pathofexile.com/leagues?type=main&compact=1");
|
||||
leagues = JsonConvert.DeserializeObject<List<Leagues>>(res);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var eembed = _sender.CreateEmbed().WithDescription(GetText(strs.leagues_not_found)).WithErrorColor();
|
||||
|
||||
await Response().Embed(eembed).SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = _sender.CreateEmbed()
|
||||
.WithAuthor("Path of Exile Leagues",
|
||||
"https://web.poecdn.com/image/favicon/ogimage.png",
|
||||
"https://www.pathofexile.com")
|
||||
.WithOkColor();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"```{"#",-5}{"League Name",-23}");
|
||||
for (var i = 0; i < leagues.Count; i++)
|
||||
{
|
||||
var league = leagues[i];
|
||||
|
||||
sb.AppendLine($"#{i + 1,-4}{league.Id,-23}");
|
||||
}
|
||||
|
||||
sb.AppendLine("```");
|
||||
|
||||
embed.WithDescription(sb.ToString());
|
||||
|
||||
await Response().Embed(embed).SendAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task PathOfExileCurrency(
|
||||
string leagueName,
|
||||
string currencyName,
|
||||
string convertName = "Chaos Orb")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(leagueName))
|
||||
{
|
||||
await Response().Error("Please provide league name.").SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(currencyName))
|
||||
{
|
||||
await Response().Error("Please provide currency name.").SendAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var cleanCurrency = ShortCurrencyName(currencyName);
|
||||
var cleanConvert = ShortCurrencyName(convertName);
|
||||
|
||||
try
|
||||
{
|
||||
var res = $"{PON_URL}{leagueName}";
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var obj = JObject.Parse(await http.GetStringAsync(res));
|
||||
|
||||
var chaosEquivalent = 0.0F;
|
||||
var conversionEquivalent = 0.0F;
|
||||
|
||||
// poe.ninja API does not include a "chaosEquivalent" property for Chaos Orbs.
|
||||
if (cleanCurrency == "Chaos Orb")
|
||||
chaosEquivalent = 1.0F;
|
||||
else
|
||||
{
|
||||
var currencyInput = obj["lines"]
|
||||
.Values<JObject>()
|
||||
.Where(i => i["currencyTypeName"].Value<string>() == cleanCurrency)
|
||||
.FirstOrDefault();
|
||||
chaosEquivalent = float.Parse(currencyInput["chaosEquivalent"].ToString(),
|
||||
CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (cleanConvert == "Chaos Orb")
|
||||
conversionEquivalent = 1.0F;
|
||||
else
|
||||
{
|
||||
var currencyOutput = obj["lines"]
|
||||
.Values<JObject>()
|
||||
.Where(i => i["currencyTypeName"].Value<string>() == cleanConvert)
|
||||
.FirstOrDefault();
|
||||
conversionEquivalent = float.Parse(currencyOutput["chaosEquivalent"].ToString(),
|
||||
CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var embed = _sender.CreateEmbed()
|
||||
.WithAuthor($"{leagueName} Currency Exchange",
|
||||
"https://web.poecdn.com/image/favicon/ogimage.png",
|
||||
"http://poe.ninja")
|
||||
.AddField("Currency Type", cleanCurrency, true)
|
||||
.AddField($"{cleanConvert} Equivalent", chaosEquivalent / conversionEquivalent, true)
|
||||
.WithOkColor();
|
||||
|
||||
await Response().Embed(embed).SendAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
var embed = _sender.CreateEmbed().WithDescription(GetText(strs.ninja_not_found)).WithErrorColor();
|
||||
|
||||
await Response().Embed(embed).SendAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private string ShortCurrencyName(string str)
|
||||
{
|
||||
if (currencyDictionary.ContainsValue(str))
|
||||
return str;
|
||||
|
||||
var currency = currencyDictionary[str];
|
||||
|
||||
return currency;
|
||||
}
|
||||
|
||||
private static string ShortLeagueName(string str)
|
||||
{
|
||||
var league = str.Replace("Hardcore", "HC", StringComparison.InvariantCultureIgnoreCase);
|
||||
|
||||
return league;
|
||||
}
|
||||
}
|
||||
}
|
@@ -2,6 +2,7 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using NadekoBot.Modules.Searches.Common;
|
||||
using NadekoBot.Modules.Searches.Services;
|
||||
using NadekoBot.Modules.Utility;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SixLabors.ImageSharp;
|
||||
@@ -168,7 +169,7 @@ public partial class Searches : NadekoModule<SearchesService>
|
||||
.AddField("Rating", movie.ImdbRating, true)
|
||||
.AddField("Genre", movie.Genre, true)
|
||||
.AddField("Year", movie.Year, true)
|
||||
.WithImageUrl(movie.Poster))
|
||||
.WithImageUrl(Uri.IsWellFormedUriString(movie.Poster, UriKind.Absolute) ? movie.Poster : null))
|
||||
.SendAsync();
|
||||
}
|
||||
|
||||
|
314
src/NadekoBot/Modules/Utility/Ai/AiAssistantService.cs
Normal file
314
src/NadekoBot/Modules/Utility/Ai/AiAssistantService.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Modules.Administration;
|
||||
using NadekoBot.Modules.Games.Services;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public enum GetCommandErrorResult
|
||||
{
|
||||
RateLimitHit,
|
||||
NotAuthorized,
|
||||
Disregard,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public sealed class AiAssistantService
|
||||
: IAiAssistantService, IReadyExecutor,
|
||||
IExecOnMessage,
|
||||
INService
|
||||
{
|
||||
private IReadOnlyCollection<AiCommandModel> _commands = [];
|
||||
|
||||
private readonly IBotStrings _strings;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly CommandService _cmds;
|
||||
private readonly IBotCredsProvider _credsProvider;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly ICommandHandler _cmdHandler;
|
||||
private readonly BotConfigService _bcs;
|
||||
private readonly IMessageSenderService _sender;
|
||||
|
||||
private readonly JsonSerializerOptions _serializerOptions = new();
|
||||
private readonly IPermissionChecker _permChecker;
|
||||
private readonly IBotCache _botCache;
|
||||
private readonly ChatterBotService _cbs;
|
||||
|
||||
public AiAssistantService(
|
||||
DiscordSocketClient client,
|
||||
IBotStrings strings,
|
||||
IHttpClientFactory httpFactory,
|
||||
CommandService cmds,
|
||||
IBotCredsProvider credsProvider,
|
||||
ICommandHandler cmdHandler,
|
||||
BotConfigService bcs,
|
||||
IPermissionChecker permChecker,
|
||||
IBotCache botCache,
|
||||
ChatterBotService cbs,
|
||||
IMessageSenderService sender)
|
||||
{
|
||||
_client = client;
|
||||
_strings = strings;
|
||||
_httpFactory = httpFactory;
|
||||
_cmds = cmds;
|
||||
_credsProvider = credsProvider;
|
||||
_cmdHandler = cmdHandler;
|
||||
_bcs = bcs;
|
||||
_sender = sender;
|
||||
_permChecker = permChecker;
|
||||
_botCache = botCache;
|
||||
_cbs = cbs;
|
||||
}
|
||||
|
||||
public async Task<OneOf.OneOf<NadekoCommandCallModel, GetCommandErrorResult>> TryGetCommandAsync(
|
||||
ulong userId,
|
||||
string prompt,
|
||||
IReadOnlyCollection<AiCommandModel> commands,
|
||||
string prefix)
|
||||
{
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
query = prompt,
|
||||
commands = commands.ToDictionary(x => x.Name,
|
||||
x => new AiCommandModel()
|
||||
{
|
||||
Desc = string.Format(x.Desc ?? "", prefix),
|
||||
Params = x.Params,
|
||||
Name = x.Name
|
||||
}),
|
||||
}),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
|
||||
using var request = new HttpRequestMessage();
|
||||
request.Method = HttpMethod.Post;
|
||||
// request.RequestUri = new("https://nai.nadeko.bot/get-command");
|
||||
request.RequestUri = new("https://nai.nadeko.bot/get-command");
|
||||
request.Content = content;
|
||||
|
||||
var creds = _credsProvider.GetCreds();
|
||||
|
||||
request.Headers.TryAddWithoutValidation("x-auth-token", creds.NadekoAiToken);
|
||||
request.Headers.TryAddWithoutValidation("x-auth-userid", userId.ToString());
|
||||
|
||||
|
||||
using var client = _httpFactory.CreateClient();
|
||||
|
||||
// todo customize according to the bot's config
|
||||
// - CurrencyName
|
||||
// -
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
return GetCommandErrorResult.RateLimitHit;
|
||||
}
|
||||
else if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return GetCommandErrorResult.NotAuthorized;
|
||||
}
|
||||
|
||||
var funcModel = await response.Content.ReadFromJsonAsync<CommandPromptResultModel>();
|
||||
|
||||
|
||||
if (funcModel?.Name == "disregard")
|
||||
{
|
||||
Log.Warning("Disregarding the prompt: {Prompt}", prompt);
|
||||
return GetCommandErrorResult.Disregard;
|
||||
}
|
||||
|
||||
if (funcModel is null)
|
||||
return GetCommandErrorResult.Unknown;
|
||||
|
||||
var comModel = new NadekoCommandCallModel()
|
||||
{
|
||||
Name = funcModel.Name,
|
||||
Arguments = funcModel.Arguments
|
||||
.OrderBy(param => _commands.FirstOrDefault(x => x.Name == funcModel.Name)
|
||||
?.Params
|
||||
.Select((x, i) => (x, i))
|
||||
.Where(x => x.x.Name == param.Key)
|
||||
.Select(x => x.i)
|
||||
.FirstOrDefault())
|
||||
.Select(x => x.Value)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.ToArray(),
|
||||
Remaining = funcModel.Remaining
|
||||
};
|
||||
|
||||
return comModel;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<AiCommandModel> GetCommands()
|
||||
=> _commands;
|
||||
|
||||
public Task OnReadyAsync()
|
||||
{
|
||||
var cmds = _cmds.Commands
|
||||
.Select(x => (MethodName: x.Summary, CommandName: x.Aliases[0]))
|
||||
.Where(x => !x.MethodName.Contains("///"))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var funcs = new List<AiCommandModel>();
|
||||
foreach (var (method, cmd) in cmds)
|
||||
{
|
||||
var commandStrings = _strings.GetCommandStrings(method);
|
||||
|
||||
if (commandStrings is null)
|
||||
continue;
|
||||
|
||||
funcs.Add(new()
|
||||
{
|
||||
Name = cmd,
|
||||
Desc = commandStrings?.Desc?.Replace("currency", "flowers") ?? string.Empty,
|
||||
Params = commandStrings?.Params.FirstOrDefault()
|
||||
?.Select(x => new AiCommandParamModel()
|
||||
{
|
||||
Desc = x.Value.Desc,
|
||||
Name = x.Key,
|
||||
})
|
||||
.ToArray()
|
||||
?? []
|
||||
});
|
||||
}
|
||||
|
||||
_commands = funcs;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public int Priority
|
||||
=> 2;
|
||||
|
||||
public async Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage msg)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_credsProvider.GetCreds().NadekoAiToken))
|
||||
return false;
|
||||
|
||||
if (guild is not SocketGuild sg)
|
||||
return false;
|
||||
|
||||
var nadekoId = _client.CurrentUser.Id;
|
||||
|
||||
var channel = msg.Channel as ITextChannel;
|
||||
if (channel is null)
|
||||
return false;
|
||||
|
||||
var normalMention = $"<@{nadekoId}> ";
|
||||
var nickMention = $"<@!{nadekoId}> ";
|
||||
string query;
|
||||
if (msg.Content.StartsWith(normalMention, StringComparison.InvariantCulture))
|
||||
query = msg.Content[normalMention.Length..].Trim();
|
||||
else if (msg.Content.StartsWith(nickMention, StringComparison.InvariantCulture))
|
||||
query = msg.Content[nickMention.Length..].Trim();
|
||||
else
|
||||
return false;
|
||||
|
||||
var success = await TryExecuteAiCommand(guild, msg, channel, query);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
public async Task<bool> TryExecuteAiCommand(
|
||||
IGuild guild,
|
||||
IUserMessage msg,
|
||||
ITextChannel channel,
|
||||
string query)
|
||||
{
|
||||
// check permissions
|
||||
var pcResult = await _permChecker.CheckPermsAsync(
|
||||
guild,
|
||||
msg.Channel,
|
||||
msg.Author,
|
||||
"Utility",
|
||||
"prompt"
|
||||
);
|
||||
|
||||
if (!pcResult.IsAllowed)
|
||||
return false;
|
||||
|
||||
using var _ = channel.EnterTypingState();
|
||||
|
||||
var result = await TryGetCommandAsync(msg.Author.Id, query, _commands, _cmdHandler.GetPrefix(guild.Id));
|
||||
|
||||
if (result.TryPickT0(out var model, out var error))
|
||||
{
|
||||
if (model.Name == ".ai_chat")
|
||||
{
|
||||
if (guild is not SocketGuild sg)
|
||||
return false;
|
||||
|
||||
var sess = _cbs.GetOrCreateSession(guild.Id);
|
||||
if (sess is null)
|
||||
return false;
|
||||
|
||||
await _cbs.RunChatterBot(sg, msg, channel, sess, query);
|
||||
return false;
|
||||
}
|
||||
|
||||
var commandString = GetCommandString(model);
|
||||
|
||||
var msgTask = _sender.Response(channel)
|
||||
.Embed(_sender.CreateEmbed()
|
||||
.WithOkColor()
|
||||
.WithAuthor(msg.Author.GlobalName,
|
||||
msg.Author.RealAvatarUrl().ToString())
|
||||
.WithDescription(commandString))
|
||||
.SendAsync();
|
||||
|
||||
|
||||
await _cmdHandler.TryRunCommand(
|
||||
(SocketGuild)guild,
|
||||
(ISocketMessageChannel)channel,
|
||||
new DoAsUserMessage((SocketUserMessage)msg, msg.Author, commandString));
|
||||
|
||||
var cmdMsg = await msgTask;
|
||||
|
||||
cmdMsg.DeleteAfter(5);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error == GetCommandErrorResult.Disregard)
|
||||
{
|
||||
// await msg.ErrorAsync();
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = new TypedKey<bool>($"sub_error:{msg.Author.Id}:{error}");
|
||||
|
||||
if (!await _botCache.AddAsync(key, true, TimeSpan.FromDays(1), overwrite: false))
|
||||
return false;
|
||||
|
||||
var errorMsg = error switch
|
||||
{
|
||||
GetCommandErrorResult.RateLimitHit
|
||||
=> "You've spent your daily requests quota.",
|
||||
GetCommandErrorResult.NotAuthorized
|
||||
=> "In order to use this command you have to have a 5$ or higher subscription at <https://patreon.com/nadekobot>",
|
||||
GetCommandErrorResult.Unknown
|
||||
=> "The service is temporarily unavailable.",
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
|
||||
await _sender.Response(channel)
|
||||
.Error(errorMsg)
|
||||
.SendAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private string GetCommandString(NadekoCommandCallModel res)
|
||||
=> $"{_bcs.Data.Prefix}{res.Name} {res.Arguments.Select((x, i) => GetParamString(x, i + 1 == res.Arguments.Count)).Join(" ")}";
|
||||
|
||||
private static string GetParamString(string val, bool isLast)
|
||||
=> isLast ? val : "\"" + val + "\"";
|
||||
}
|
15
src/NadekoBot/Modules/Utility/Ai/AiCommandModel.cs
Normal file
15
src/NadekoBot/Modules/Utility/Ai/AiCommandModel.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public sealed class AiCommandModel
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("desc")]
|
||||
public required string Desc { get; set; }
|
||||
|
||||
[JsonPropertyName("params")]
|
||||
public required IReadOnlyList<AiCommandParamModel> Params { get; set; }
|
||||
}
|
12
src/NadekoBot/Modules/Utility/Ai/AiCommandParamModel.cs
Normal file
12
src/NadekoBot/Modules/Utility/Ai/AiCommandParamModel.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public sealed class AiCommandParamModel
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("desc")]
|
||||
public required string Desc { get; set; }
|
||||
}
|
16
src/NadekoBot/Modules/Utility/Ai/CommandPromptResultModel.cs
Normal file
16
src/NadekoBot/Modules/Utility/Ai/CommandPromptResultModel.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public sealed class CommandPromptResultModel
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("arguments")]
|
||||
public required Dictionary<string, string> Arguments { get; set; }
|
||||
|
||||
[JsonPropertyName("remaining")]
|
||||
[JsonConverter(typeof(NumberToStringConverter))]
|
||||
public required string Remaining { get; set; }
|
||||
}
|
20
src/NadekoBot/Modules/Utility/Ai/IAiAssistantService.cs
Normal file
20
src/NadekoBot/Modules/Utility/Ai/IAiAssistantService.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using OneOf;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public interface IAiAssistantService
|
||||
{
|
||||
Task<OneOf<NadekoCommandCallModel, GetCommandErrorResult>> TryGetCommandAsync(
|
||||
ulong userId,
|
||||
string prompt,
|
||||
IReadOnlyCollection<AiCommandModel> commands,
|
||||
string prefix);
|
||||
|
||||
IReadOnlyCollection<AiCommandModel> GetCommands();
|
||||
|
||||
Task<bool> TryExecuteAiCommand(
|
||||
IGuild guild,
|
||||
IUserMessage msg,
|
||||
ITextChannel channel,
|
||||
string query);
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public sealed class NadekoCommandCallModel
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required IReadOnlyList<string> Arguments { get; set; }
|
||||
public required string Remaining { get; set; }
|
||||
}
|
23
src/NadekoBot/Modules/Utility/Ai/UtilityCommands.cs
Normal file
23
src/NadekoBot/Modules/Utility/Ai/UtilityCommands.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using NadekoBot.Modules.Administration;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public partial class UtilityCommands
|
||||
{
|
||||
public class PromptCommands : NadekoModule<IAiAssistantService>
|
||||
{
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Prompt([Leftover] string query)
|
||||
{
|
||||
await ctx.Channel.TriggerTypingAsync();
|
||||
var res = await _service.TryExecuteAiCommand(ctx.Guild, ctx.Message, (ITextChannel)ctx.Channel, query);
|
||||
}
|
||||
|
||||
private string GetCommandString(NadekoCommandCallModel res)
|
||||
=> $"{_bcs.Data.Prefix}{res.Name} {res.Arguments.Select((x, i) => GetParamString(x, i + 1 == res.Arguments.Count)).Join(" ")}";
|
||||
|
||||
private static string GetParamString(string val, bool isLast)
|
||||
=> isLast ? val : "\"" + val + "\"";
|
||||
}
|
||||
}
|
@@ -144,9 +144,9 @@ public partial class Utility
|
||||
true)
|
||||
.WithOkColor();
|
||||
|
||||
var patron = await _ps.GetPatronAsync(user.Id);
|
||||
var mPatron = await _ps.GetPatronAsync(user.Id);
|
||||
|
||||
if (patron.Tier != PatronTier.None)
|
||||
if (mPatron is {} patron && patron.Tier != PatronTier.None)
|
||||
{
|
||||
embed.WithFooter(patron.Tier switch
|
||||
{
|
||||
|
@@ -113,10 +113,9 @@ public partial class Utility
|
||||
foreach (var rem in rems)
|
||||
{
|
||||
var when = rem.When;
|
||||
var diff = when - DateTime.UtcNow;
|
||||
embed.AddField(
|
||||
$"#{++i + (page * 10)} {rem.When:HH:mm yyyy-MM-dd} UTC "
|
||||
+ $"(in {diff.ToPrettyStringHm()})",
|
||||
+ $"{TimestampTag.FromDateTime(when)}",
|
||||
$@"`Target:` {(rem.IsPrivate ? "DM" : "Channel")}
|
||||
`TargetId:` {rem.ChannelId}
|
||||
`Message:` {rem.Message?.TrimTo(50)}");
|
||||
@@ -203,16 +202,15 @@ public partial class Utility
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var gTime = ctx.Guild is null ? time : TimeZoneInfo.ConvertTime(time, _tz.GetTimeZoneOrUtc(ctx.Guild.Id));
|
||||
// var gTime = ctx.Guild is null ? time : TimeZoneInfo.ConvertTime(time, _tz.GetTimeZoneOrUtc(ctx.Guild.Id));
|
||||
try
|
||||
{
|
||||
await Response()
|
||||
.Confirm($"\u23f0 {GetText(strs.remind(
|
||||
.Confirm($"\u23f0 {GetText(strs.remind2(
|
||||
Format.Bold(!isPrivate ? $"<#{targetId}>" : ctx.User.Username),
|
||||
Format.Bold(message),
|
||||
ts.ToPrettyStringHm(),
|
||||
gTime,
|
||||
gTime))}")
|
||||
TimestampTag.FromDateTime(DateTime.UtcNow.Add(ts), TimestampTagStyles.Relative),
|
||||
TimestampTag.FormatFromDateTime(time, TimestampTagStyles.ShortDateTime)))}")
|
||||
.SendAsync();
|
||||
}
|
||||
catch
|
||||
|
@@ -2,6 +2,7 @@
|
||||
using NadekoBot.Modules.Xp.Services;
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Patronage;
|
||||
using NadekoBot.Modules.Utility;
|
||||
|
||||
namespace NadekoBot.Modules.Xp;
|
||||
|
||||
|
@@ -1286,9 +1286,9 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
Image? frame = null;
|
||||
if (item is null)
|
||||
{
|
||||
if (patron.Tier == PatronTier.V)
|
||||
if (patron?.Tier == PatronTier.V)
|
||||
frame = Image.Load<Rgba32>(File.OpenRead("data/images/frame_silver.png"));
|
||||
else if (patron.Tier >= PatronTier.X || _creds.IsOwner(userId))
|
||||
else if (patron?.Tier >= PatronTier.X || _creds.IsOwner(userId))
|
||||
frame = Image.Load<Rgba32>(File.OpenRead("data/images/frame_gold.png"));
|
||||
}
|
||||
else
|
||||
@@ -1465,7 +1465,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
{
|
||||
var patron = await _ps.GetPatronAsync(userId);
|
||||
|
||||
if ((int)patron.Tier < (int)req)
|
||||
if (patron is null || (int)patron.Value.Tier < (int)req)
|
||||
return BuyResult.InsufficientPatronTier;
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user