simplified quota system

This commit is contained in:
Toastie 2025-03-30 16:16:57 +13:00
parent 7d8f61ecea
commit aba5c4fbfd
Signed by: toastie_t0ast
GPG key ID: 0861BE54AD481DC7
16 changed files with 166 additions and 366 deletions

View file

@ -32,6 +32,7 @@
- .notify will now let you know if you can't set a notify message due to a missing channel - .notify will now let you know if you can't set a notify message due to a missing channel
- `.say` will no longer reply - `.say` will no longer reply
- `.vote` and `.timely` will now show active bonuses - `.vote` and `.timely` will now show active bonuses
- `.lcha` (live channel) limit increased to 5
### Fixed ### Fixed
- Fixed `.antispamignore` restart persistence - Fixed `.antispamignore` restart persistence

View file

@ -145,7 +145,7 @@ public class ChatterBotService : IExecOnMessage, IReadyExecutor
if (!res.IsAllowed) if (!res.IsAllowed)
return false; return false;
if (!await _ps.LimitHitAsync(LimitedFeatureName.ChatBot, usrMsg.Author.Id, 2048 / 2)) if (!await _ps.LimitHitAsync("ai", guild.OwnerId, 1))
{ {
// limit exceeded // limit exceeded
return false; return false;
@ -156,14 +156,6 @@ public class ChatterBotService : IExecOnMessage, IReadyExecutor
if (response.TryPickT0(out var result, out var error)) 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) await _sender.Response(channel)
.Confirm(result.Text) .Confirm(result.Text)
.SendAsync(); .SendAsync();

View file

@ -73,16 +73,14 @@ public partial class Help
var maybePatron = await _service.GetPatronAsync(user.Id); var maybePatron = await _service.GetPatronAsync(user.Id);
var quotaStats = await _service.LimitStats(user.Id);
var eb = CreateEmbed() var eb = CreateEmbed()
.WithAuthor(user) .WithAuthor(user)
.WithTitle(GetText(strs.patron_info)) .WithTitle(GetText(strs.patron_info))
.WithOkColor(); .WithOkColor();
if (quotaStats.Count == 0 || maybePatron is not { } patron) if (maybePatron is not { } patron)
{ {
eb.WithDescription(GetText(strs.no_quota_found)); eb.WithDescription("You don't have an active subscription");
} }
else else
{ {
@ -93,12 +91,6 @@ public partial class Help
eb.AddField(GetText(strs.expires), eb.AddField(GetText(strs.expires),
patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(), patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(),
true); true);
eb.AddField(GetText(strs.quotas), "", false);
var text = GetQuotaList(quotaStats);
if (!string.IsNullOrWhiteSpace(text))
eb.AddField(GetText(strs.modules), text, true);
} }
@ -112,29 +104,5 @@ public partial class Help
await Response().Error(strs.cant_dm).SendAsync(); await Response().Error(strs.cant_dm).SendAsync();
} }
} }
private string GetQuotaList(
IReadOnlyDictionary<LimitedFeatureName, (int Cur, QuotaLimit Quota)> featureQuotaStats)
{
var text = string.Empty;
foreach (var (key, (cur, quota)) in featureQuotaStats)
{
text += $"\n\t`{key}`\n";
if (quota.QuotaPeriod == QuotaPer.PerHour)
text += $" {cur}/{(quota.Quota == -1 ? "" : quota.Quota)} {QuotaPeriodToString(quota.QuotaPeriod)}\n";
}
return text;
}
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)
};
} }
} }

View file

@ -236,135 +236,55 @@ public sealed class PatronageService
return PatronUserToPatron(max); return PatronUserToPatron(max);
} }
public async Task<bool> LimitHitAsync(LimitedFeatureName key, ulong userId, int amount = 1) private Func<string, ulong, TypedKey<int>> Limitkey
=> (name, userId) => new($"patron_limit:{userId}:{name}");
public async Task<bool> LimitHitAsync(string name, ulong userId, int defaultMax)
{ {
if (_creds.GetCreds().IsOwner(userId)) var data = _pConf.Data;
if (!data.IsEnabled)
return true; return true;
if (!_pConf.Data.IsEnabled) var limit = await GetUserLimit(name, userId, defaultMax);
if (limit == -1)
return true; return true;
var userLimit = await GetUserLimit(key, userId); var timeUntilTomorrow = (DateTime.UtcNow.Date.AddDays(1) - DateTime.UtcNow);
var soFar = await _cache.GetOrAddAsync(Limitkey(name, userId),
() => Task.FromResult(0),
expiry: timeUntilTomorrow);
if (userLimit.Quota == 0) if (soFar >= limit)
return false; return false;
if (userLimit.Quota == -1) await _cache.AddAsync(Limitkey(name, userId), soFar + 1, timeUntilTomorrow, overwrite: true);
return true;
return await TryAddLimit(key, userLimit, userId, amount);
}
public async Task<bool> LimitForceHit(LimitedFeatureName key, ulong userId, int amount)
{
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)
{
await _cache.AddAsync(cacheKey, cur + amount);
return true; return true;
} }
return false; public async Task<int> GetUserLimit(string name, ulong userId, int defaultMax)
{
var data = _pConf.Data;
if (!data.IsEnabled || _creds.GetCreds().OwnerIds.Contains(userId))
return defaultMax;
var mPatron = await GetPatronAsync(userId);
if (mPatron is not { } patron || !patron.IsActive)
{
if (data.Quotas.TryGetValue(PatronTier.I, out var limits)
&& limits.TryGetValue(name, out var limit))
return limit;
return 0;
} }
private TimeSpan? GetExpiry(QuotaLimit userLimit) if (data.Quotas.TryGetValue(patron.Tier, out var plimits)
{ && plimits.TryGetValue(name, out var plimit))
var now = DateTime.UtcNow; return plimit;
switch (userLimit.QuotaPeriod)
{ return 0;
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 readonly QuotaLimit _emptyQuota = new QuotaLimit()
{
Quota = 0,
QuotaPeriod = QuotaPer.PerDay,
};
private readonly QuotaLimit _infiniteQuota = new QuotaLimit()
{
Quota = -1,
QuotaPeriod = QuotaPer.PerDay,
};
public async Task<QuotaLimit> GetUserLimit(LimitedFeatureName name, ulong userId)
{
if (!_pConf.Data.IsEnabled)
return _infiniteQuota;
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 (value.TryGetValue(name, out var quotaLimit))
{
return quotaLimit;
}
break;
}
}
return _emptyQuota;
}
public async Task<Dictionary<LimitedFeatureName, (int, QuotaLimit)>> LimitStats(ulong userId)
{
var dict = new Dictionary<LimitedFeatureName, (int, QuotaLimit)>();
foreach (var featureName in Enum.GetValues<LimitedFeatureName>())
{
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;
}
private Patron PatronUserToPatron(PatronUser user) private Patron PatronUserToPatron(PatronUser user)
=> new Patron() => new Patron()
@ -384,10 +304,13 @@ public sealed class PatronageService
return user.AmountCents switch return user.AmountCents switch
{ {
<= 200 => PatronTier.I, >= 10_000 => PatronTier.C,
<= 1_000 => PatronTier.C, >= 5_000 => PatronTier.L,
<= 5_000 => PatronTier.L, >= 2_000 => PatronTier.XX,
_ => PatronTier.None >= 1_000 => PatronTier.X,
>= 500 => PatronTier.V,
>= 100 => PatronTier.I,
_ => 0,
}; };
} }
@ -399,10 +322,12 @@ public sealed class PatronageService
public int PercentBonus(long amount) public int PercentBonus(long amount)
=> amount switch => amount switch
{ {
< 200 => 0, >= 10_000 => 100,
< 1_000 => 10, >= 5_000 => 50,
< 5_000 => 50, >= 2_000 => 25,
_ => 100 >= 1_000 => 10,
>= 500 => 5,
_ => 0,
}; };
private async Task SendWelcomeMessage(Patron patron) private async Task SendWelcomeMessage(Patron patron)
@ -427,12 +352,7 @@ public sealed class PatronageService
.AddField("Instructions", .AddField("Instructions",
""" """
*- Within the next **1-2 minutes** you will have all of the benefits of the Tier you've subscribed to.* *- 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/elliebot>*
*- You can use the `.patron` command in this chat to check your current quota usage for the Patron-only commands* *- 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 Ellie'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://docs.elliebot.net/ellie/features/permissions-system/>*
""", """,
inline: false) inline: false)
.WithFooter($"platform id: {patron.UniquePlatformUserId}"); .WithFooter($"platform id: {patron.UniquePlatformUserId}");

View file

@ -13,10 +13,10 @@ public partial class Utility
[BotPerm(GuildPerm.ManageChannels)] [BotPerm(GuildPerm.ManageChannels)]
public async Task LiveChAdd(IChannel channel, [Leftover] string template) public async Task LiveChAdd(IChannel channel, [Leftover] string template)
{ {
if (!await svc.AddLiveChannelAsync(ctx.Guild.Id, channel.Id, template)) if (!await svc.AddLiveChannelAsync(ctx.Guild.Id, channel.Id, ctx.Guild.OwnerId, template))
{ {
await Response() await Response()
.Error(strs.livechannel_limit(LiveChannelService.MAX_LIVECHANNELS)) .Error(strs.livechannel_limit(await svc.GetMaxLiveChannels(ctx.Guild.OwnerId)))
.SendAsync(); .SendAsync();
return; return;
} }

View file

@ -4,6 +4,7 @@ using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors; using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models; using EllieBot.Db.Models;
using EllieBot.Modules.Patronage;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace EllieBot.Modules.Utility.LiveChannel; namespace EllieBot.Modules.Utility.LiveChannel;
@ -15,9 +16,10 @@ public class LiveChannelService(
DbService db, DbService db,
DiscordSocketClient client, DiscordSocketClient client,
IReplacementService repSvc, IReplacementService repSvc,
IPatronageService patron,
ShardData shardData) : IReadyExecutor, IEService ShardData shardData) : IReadyExecutor, IEService
{ {
public const int MAX_LIVECHANNELS = 1; public const int DEFAULT_MAX_LIVECHANNELS = 5;
private readonly ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, LiveChannelConfig>> _liveChannels = new(); private readonly ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, LiveChannelConfig>> _liveChannels = new();
@ -122,13 +124,13 @@ public class LiveChannelService(
/// <param name="channelId">ID of the channel</param> /// <param name="channelId">ID of the channel</param>
/// <param name="template">Template text to use for the channel</param> /// <param name="template">Template text to use for the channel</param>
/// <returns>True if successfully added, false otherwise</returns> /// <returns>True if successfully added, false otherwise</returns>
public async Task<bool> AddLiveChannelAsync(ulong guildId, ulong channelId, string template) public async Task<bool> AddLiveChannelAsync(ulong guildId, ulong channelId, ulong guildOwnerId, string template)
{ {
var guildDict = _liveChannels.GetOrAdd( var guildDict = _liveChannels.GetOrAdd(
guildId, guildId,
_ => new()); _ => new());
if (!guildDict.ContainsKey(channelId) && guildDict.Count >= MAX_LIVECHANNELS) if (!guildDict.ContainsKey(channelId) && guildDict.Count >= await GetMaxLiveChannels(guildOwnerId))
return false; return false;
await using var uow = db.GetDbContext(); await using var uow = db.GetDbContext();
@ -194,4 +196,11 @@ public class LiveChannelService(
.Where(x => x.GuildId == guildId) .Where(x => x.GuildId == guildId)
.ToListAsyncLinqToDB(); .ToListAsyncLinqToDB();
} }
public async Task<int> GetMaxLiveChannels(ulong guildOwnerId)
{
var limit = await patron.GetUserLimit("livechannels", guildOwnerId, DEFAULT_MAX_LIVECHANNELS);
return limit;
}
} }

View file

@ -236,7 +236,6 @@ public partial class Utility
} }
[Cmd]
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
public async Task QuoteAdd(string keyword, [Leftover] string text) public async Task QuoteAdd(string keyword, [Leftover] string text)
{ {

View file

@ -1,12 +1,5 @@
namespace EllieBot.Modules.Patronage; namespace EllieBot.Modules.Patronage;
public enum LimitedFeatureName
{
ChatBot,
ReactionRole,
Prune,
}
public readonly struct FeatureLimitKey public readonly struct FeatureLimitKey
{ {
public string PrettyName { get; init; } public string PrettyName { get; init; }

View file

@ -30,12 +30,8 @@ public interface IPatronageService
/// <returns>A patron with the specifeid userId</returns> /// <returns>A patron with the specifeid userId</returns>
public Task<Patron?> GetPatronAsync(ulong userId); public Task<Patron?> GetPatronAsync(ulong userId);
Task<bool> LimitHitAsync(LimitedFeatureName key, ulong userId, int amount = 1); Task<bool> LimitHitAsync(string name, ulong userId, int def);
Task<bool> LimitForceHit(LimitedFeatureName key, ulong userId, int amount); Task<int> GetUserLimit(string name, ulong userId, int def);
Task<QuotaLimit> GetUserLimit(LimitedFeatureName name, ulong userId);
Task<Dictionary<LimitedFeatureName, (int, QuotaLimit)>> LimitStats(ulong userId);
PatronConfigData GetConfig(); PatronConfigData GetConfig();
int PercentBonus(Patron? user); int PercentBonus(Patron? user);
int PercentBonus(long amount); int PercentBonus(long amount);

View file

@ -12,6 +12,6 @@ public partial class PatronConfigData : ICloneable<PatronConfigData>
[Comment("Whether the patronage feature is enabled")] [Comment("Whether the patronage feature is enabled")]
public bool IsEnabled { get; set; } public bool IsEnabled { get; set; }
[Comment("Who can do how much of what")] [Comment("Quotas for patron system")]
public Dictionary<int, Dictionary<LimitedFeatureName, QuotaLimit>> Limits { get; set; } = new(); public Dictionary<PatronTier, Dictionary<string, int>> Quotas { get; set; } = new();
} }

View file

@ -5,7 +5,9 @@ public enum PatronTier
{ {
None, None,
I, I,
V,
X, X,
XX,
L, L,
C C
} }

View file

@ -1,23 +0,0 @@
namespace EllieBot.Modules.Patronage;
/// <summary>
/// Represents information about why the user has triggered a quota limit
/// </summary>
public readonly struct QuotaLimit
{
/// <summary>
/// Amount of usages reached, which is the limit
/// </summary>
public int Quota { get; init; }
/// <summary>
/// Which period is this quota limit for (hourly, daily, monthly, etc...)
/// </summary>
public QuotaPer QuotaPeriod { get; init; }
public QuotaLimit(int quota, QuotaPer quotaPeriod)
{
Quota = quota;
QuotaPeriod = quotaPeriod;
}
}

View file

@ -1,9 +0,0 @@
namespace EllieBot.Modules.Patronage;
public enum QuotaPer
{
PerHour,
PerDay,
PerMonth,
Total,
}

View file

@ -14,28 +14,7 @@ public sealed class Rgba32TypeReader : EllieTypeReader<Rgba32>
return ValueTask.FromResult( return ValueTask.FromResult(
TypeReaderResult.FromError<Rgba32>(CommandError.ParseFailed, "Parameter is not a valid color hex.")); TypeReaderResult.FromError<Rgba32>(CommandError.ParseFailed, "Parameter is not a valid color hex."));
} }
Log.Information(color.ToHex());
return ValueTask.FromResult(TypeReaderResult.FromSuccess((Rgba32)color)); return ValueTask.FromResult(TypeReaderResult.FromSuccess((Rgba32)color));
if (Rgba32.TryParseHex(input, out var clr))
{
return ValueTask.FromResult(TypeReaderResult.FromSuccess(clr));
}
if (!Enum.TryParse<Color>(input, true, out var clrName))
return ValueTask.FromResult(
TypeReaderResult.FromError<Rgba32>(CommandError.ParseFailed,
"Parameter is not a valid color hex."));
Log.Information(clrName.ToString());
if (Rgba32.TryParseHex(clrName.ToHex(), out clr))
{
return ValueTask.FromResult(TypeReaderResult.FromSuccess(clr));
}
return ValueTask.FromResult(
TypeReaderResult.FromError<Rgba32>(CommandError.ParseFailed, "Parameter is not a valid color hex."));
} }
} }

View file

@ -7717,22 +7717,6 @@
"Options": null, "Options": null,
"Requirements": [] "Requirements": []
}, },
{
"Aliases": [
".quoteadd",
".qa",
".qadd",
".quadd"
],
"Description": "Adds a new quote with the specified name and message.",
"Usage": [
".quoteadd sayhi Hi"
],
"Submodule": "QuoteCommands",
"Module": "Utility",
"Options": null,
"Requirements": []
},
{ {
"Aliases": [ "Aliases": [
".quoteedit", ".quoteedit",

View file

@ -2,35 +2,24 @@
version: 3 version: 3
# Whether the patronage feature is enabled # Whether the patronage feature is enabled
isEnabled: false isEnabled: false
# Who can do how much of what # Quotas for patron system
limits: quotas:
50: None:
ChatBot: I:
quota: 20000000 livechannels: 1
quotaPeriod: PerMonth ai: 0
ReactionRole: V:
quota: -1 livechannels: 2
quotaPeriod: Total ai: 50
Prune: X:
quota: -1 livechannels: 5
quotaPeriod: PerDay ai: 100
10: XX:
ChatBot: livechannels: 5
quota: 2500000 ai: 200
quotaPeriod: PerMonth L:
ReactionRole: livechannels: 5
quota: 50 ai: 500
quotaPeriod: Total C:
Prune: livechannels: 5
quota: 5 ai: -1
quotaPeriod: PerDay
2:
ChatBot:
quota: 1000000
quotaPeriod: PerMonth
ReactionRole:
quota: 25
quotaPeriod: Total
Prune:
quota: 2
quotaPeriod: PerDay