Compare commits

...

7 Commits
4.2.1 ... 4.2.2

14 changed files with 196 additions and 164 deletions

View File

@@ -3,6 +3,17 @@
Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
## [4.2.2] - 15.06.2022
### Fixed
- Added missing Patron Tiers and fixed Patron pledge update bugs
- Prevented creds_example.yml error in docker containers from crashing it
### Changed
- Rss feeds will now show error counter before deletion
## [4.2.1] - 14.06.2022 ## [4.2.1] - 14.06.2022
### Added ### Added
@@ -130,6 +141,7 @@ Note: Results of each `.youtube` query will be cached for 1 hour to improve perf
- `.rh` no longer needs quotes for multi word roles - `.rh` no longer needs quotes for multi word roles
- `.deletexp` will now properly delete server xp too - `.deletexp` will now properly delete server xp too
- Fixed `.crypto` sparklines
- [dev] added support for configs to properly parse enums without case sensitivity (ConfigParsers.InsensitiveEnum) - [dev] added support for configs to properly parse enums without case sensitivity (ConfigParsers.InsensitiveEnum)
- [dev] Fixed a bug in .gencmdlist - [dev] Fixed a bug in .gencmdlist
- [dev] small fixes to creds provider - [dev] small fixes to creds provider
@@ -139,15 +151,6 @@ Note: Results of each `.youtube` query will be cached for 1 hour to improve perf
- `.ddg` removed. - `.ddg` removed.
- [dev] removed some dead code and comments - [dev] removed some dead code and comments
### Obsolete
### Fixed
- Fixed `.crypto` sparklines
## [4.1.6] - 14.05.2022 ## [4.1.6] - 14.05.2022
### Fixed ### Fixed

View File

@@ -35,4 +35,14 @@ public class PatronUser
// Date Only component // Date Only component
public DateTime ValidThru { get; set; } public DateTime ValidThru { get; set; }
public PatronUser Clone()
=> new PatronUser()
{
UniquePlatformUserId = this.UniquePlatformUserId,
UserId = this.UserId,
AmountCents = this.AmountCents,
LastCharge = this.LastCharge,
ValidThru = this.ValidThru
};
} }

View File

@@ -75,19 +75,4 @@ public sealed class BankService : IBankService, INService
?.Balance ?.Balance
?? 0; ?? 0;
} }
public async Task<long> BurnAllAsync(ulong userId)
{
await using var ctx = _db.GetDbContext();
var output = await ctx.GetTable<BankUser>()
.Where(x => x.UserId == userId)
.UpdateWithOutputAsync(old => new()
{
Balance = 0
});
if (output.Length == 0)
return 0;
return output[0].Deleted.Balance;
}
} }

View File

@@ -5,5 +5,4 @@ public interface IBankService
Task<bool> DepositAsync(ulong userId, long amount); Task<bool> DepositAsync(ulong userId, long amount);
Task<bool> WithdrawAsync(ulong userId, long amount); Task<bool> WithdrawAsync(ulong userId, long amount);
Task<long> GetBalanceAsync(ulong userId); Task<long> GetBalanceAsync(ulong userId);
Task<long> BurnAllAsync(ulong userId);
} }

View File

@@ -136,7 +136,7 @@ public class ChatterBotService : IExecOnMessage
var channel = (ITextChannel)usrMsg.Channel; var channel = (ITextChannel)usrMsg.Channel;
var conf = _ps.GetConfig(); var conf = _ps.GetConfig();
if (conf.IsEnabled) if (!_creds.IsOwner(sg.OwnerId) && conf.IsEnabled)
{ {
var quota = await _ps.TryGetFeatureLimitAsync(_flKey, sg.OwnerId, 0); var quota = await _ps.TryGetFeatureLimitAsync(_flKey, sg.OwnerId, 0);

View File

@@ -49,7 +49,7 @@ public class FeedsService : INService
private void ClearErrors(string url) private void ClearErrors(string url)
=> _errorCounters.Remove(url); => _errorCounters.Remove(url);
private async Task AddError(string url, List<int> ids) private async Task<uint> AddError(string url, List<int> ids)
{ {
try try
{ {
@@ -68,10 +68,13 @@ public class FeedsService : INService
// reset the error counter // reset the error counter
ClearErrors(url); ClearErrors(url);
} }
return newValue;
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, "Error adding rss errors..."); Log.Error(ex, "Error adding rss errors...");
return 0;
} }
} }
@@ -181,12 +184,13 @@ public class FeedsService : INService
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning("An error occured while getting rss stream {RssFeed}" var errorCount = await AddError(rssUrl, kvp.Value.Select(x => x.Id).ToList());
Log.Warning("An error occured while getting rss stream ({ErrorCount} / 100) {RssFeed}"
+ "\n {Message}", + "\n {Message}",
errorCount,
rssUrl, rssUrl,
$"[{ex.GetType().Name}]: {ex.Message}"); $"[{ex.GetType().Name}]: {ex.Message}");
await AddError(rssUrl, kvp.Value.Select(x => x.Id).ToList());
} }
} }

View File

@@ -14,5 +14,9 @@ public class PatronageConfig : ConfigServiceBase<PatronConfigData>
public PatronageConfig(IConfigSeria serializer, IPubSub pubSub) : base(FILE_PATH, serializer, pubSub, _changeKey) public PatronageConfig(IConfigSeria serializer, IPubSub pubSub) : base(FILE_PATH, serializer, pubSub, _changeKey)
{ {
AddParsedProp("enabled",
x => x.IsEnabled,
bool.TryParse,
ConfigPrinters.ToString);
} }
} }

View File

@@ -50,67 +50,91 @@ public class CurrencyRewardService : INService, IDisposable
private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron) private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron)
{ {
if (oldPatron.Amount != newPatron.Amount) // if pledge was increased
if (oldPatron.Amount < newPatron.Amount)
{ {
var conf = _config.Data; var conf = _config.Data;
var newAmount = (long)(newPatron.Amount * conf.PatreonCurrencyPerCent);
var newAmount = (long)(Math.Max(newPatron.Amount, oldPatron.Amount) * conf.PatreonCurrencyPerCent); RewardedUser old;
UpdateOutput<RewardedUser>[] output;
await using (var ctx = _db.GetDbContext()) await using (var ctx = _db.GetDbContext())
{ {
output = await ctx.GetTable<RewardedUser>() old = await ctx.GetTable<RewardedUser>()
.Where(x => x.PlatformUserId == newPatron.UnqiuePlatformUserId) .Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId)
.UpdateWithOutputAsync(old => new() .FirstOrDefaultAsync();
{
PlatformUserId = newPatron.UnqiuePlatformUserId,
UserId = newPatron.UserId,
// amount before bonuses
AmountRewardedThisMonth = newAmount,
LastReward = newPatron.PaidAt
});
}
// if the user wasn't previously in the db for some reason, if (old is null)
// we will treat him as a new patron
if (output.Length == 0)
{ {
await OnNewPayment(newPatron); await OnNewPayment(newPatron);
return; return;
} }
var oldAmount = output[0].Deleted.AmountRewardedThisMonth; // no action as the amount is the same or lower
if (old.AmountRewardedThisMonth >= newAmount)
return;
var diff = newAmount - oldAmount; var count = await ctx.GetTable<RewardedUser>()
.Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId)
.UpdateAsync(_ => new()
{
PlatformUserId = newPatron.UniquePlatformUserId,
UserId = newPatron.UserId,
// amount before bonuses
AmountRewardedThisMonth = newAmount,
LastReward = newPatron.PaidAt
});
// shouldn't ever happen
if (count == 0)
return;
}
var oldAmount = old.AmountRewardedThisMonth;
var realNewAmount = GetRealCurrencyReward(
(int)(newAmount / conf.PatreonCurrencyPerCent),
newAmount,
out var percentBonus);
var realOldAmount = GetRealCurrencyReward(
(int)(oldAmount / conf.PatreonCurrencyPerCent),
oldAmount,
out _);
var diff = realNewAmount - realOldAmount;
if (diff <= 0) if (diff <= 0)
return; // no action if new is lower return; // no action if new is lower
// if the user pledges 5$ or more, they will get X % more flowers where X is amount in dollars, // if the user pledges 5$ or more, they will get X % more flowers where X is amount in dollars,
// up to 100% // up to 100%
var realAmount = GetRealCurrencyReward(newPatron.Amount, diff, out var percentBonus); await _cs.AddAsync(newPatron.UserId, diff, new TxData("patron","update"));
await _cs.AddAsync(newPatron.UserId, realAmount, new TxData("patron","update"));
_ = SendMessageToUser(newPatron.UserId, _ = SendMessageToUser(newPatron.UserId,
$"You've received an additional **{realAmount}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!"); $"You've received an additional **{diff}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!");
} }
} }
private long GetRealCurrencyReward(int fullPledge, long currentAmount, out int percentBonus) private long GetRealCurrencyReward(int pledgeCents, long modifiedAmount, out int percentBonus)
{ {
// needs at least 5$ to be eligible for a bonus // needs at least 5$ to be eligible for a bonus
if (fullPledge < 500) if (pledgeCents < 500)
{ {
percentBonus = 0; percentBonus = 0;
return currentAmount; return modifiedAmount;
} }
var dollarValue = fullPledge / 100; var dollarValue = pledgeCents / 100;
percentBonus = dollarValue switch percentBonus = dollarValue switch
{ {
> 100 => 100, >= 100 => 100,
_ => dollarValue >= 50 => 50,
>= 20 => 20,
>= 10 => 10,
>= 5 => 5,
_ => 0
}; };
return (long)(currentAmount * (1 + (percentBonus / 100.0f))); return (long)(modifiedAmount * (1 + (percentBonus / 100.0f)));
} }
// on a new payment, always give the full amount. // on a new payment, always give the full amount.
@@ -121,7 +145,7 @@ public class CurrencyRewardService : INService, IDisposable
await ctx.GetTable<RewardedUser>() await ctx.GetTable<RewardedUser>()
.InsertOrUpdateAsync(() => new() .InsertOrUpdateAsync(() => new()
{ {
PlatformUserId = patron.UnqiuePlatformUserId, PlatformUserId = patron.UniquePlatformUserId,
UserId = patron.UserId, UserId = patron.UserId,
AmountRewardedThisMonth = amount, AmountRewardedThisMonth = amount,
LastReward = patron.PaidAt, LastReward = patron.PaidAt,
@@ -134,7 +158,7 @@ public class CurrencyRewardService : INService, IDisposable
}, },
() => new() () => new()
{ {
PlatformUserId = patron.UnqiuePlatformUserId PlatformUserId = patron.UniquePlatformUserId
}); });
var realAmount = GetRealCurrencyReward(patron.Amount, amount, out var percentBonus); var realAmount = GetRealCurrencyReward(patron.Amount, amount, out var percentBonus);
@@ -167,24 +191,9 @@ public class CurrencyRewardService : INService, IDisposable
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
_ = await ctx.GetTable<RewardedUser>() _ = await ctx.GetTable<RewardedUser>()
.UpdateWithOutputAsync(old => new() .UpdateAsync(old => new()
{ {
AmountRewardedThisMonth = old.AmountRewardedThisMonth * 2 AmountRewardedThisMonth = old.AmountRewardedThisMonth * 2
}); });
// var toTake = old.Length == 0
// ? patron.Amount
// : old[0].Inserted.AmountRewardedThisMonth;
// if (toTake > 0)
// {
// Log.Warning("Wiping the wallet and bank of the user {UserId} due to a refund/fraud...",
// patron.UserId);
// await _cs.RemoveAsync(patron.UserId, patron.Amount, new("patreon", "refund"));
// await _bs.BurnAllAsync(patron.UserId);
// Log.Warning("Burned {Amount} currency from the bank of the user {UserId} due to a refund/fraud.",
// patron.Amount,
// patron.UserId);
// }
} }
} }

View File

@@ -5,10 +5,10 @@ public readonly struct Patron
/// <summary> /// <summary>
/// Unique id assigned to this patron by the payment platform /// Unique id assigned to this patron by the payment platform
/// </summary> /// </summary>
public string UnqiuePlatformUserId { get; init; } public string UniquePlatformUserId { get; init; }
/// <summary> /// <summary>
/// Discord UserId to which this <see cref="UnqiuePlatformUserId"/> is connected to /// Discord UserId to which this <see cref="UniquePlatformUserId"/> is connected to
/// </summary> /// </summary>
public ulong UserId { get; init; } public ulong UserId { get; init; }

View File

@@ -173,7 +173,7 @@ public sealed class PatronageService
var lastChargeUtc = subscriber.LastCharge.Value.ToUniversalTime(); var lastChargeUtc = subscriber.LastCharge.Value.ToUniversalTime();
var dateInOneMonth = lastChargeUtc.Date.AddMonths(1); var dateInOneMonth = lastChargeUtc.Date.AddMonths(1);
await using var tran = await ctx.Database.BeginTransactionAsync(); // await using var tran = await ctx.Database.BeginTransactionAsync();
try try
{ {
var dbPatron = await ctx.GetTable<PatronUser>() var dbPatron = await ctx.GetTable<PatronUser>()
@@ -193,7 +193,7 @@ public sealed class PatronageService
ValidThru = dateInOneMonth, ValidThru = dateInOneMonth,
}); });
await tran.CommitAsync(); // await tran.CommitAsync();
var newPatron = PatronUserToPatron(dbPatron); var newPatron = PatronUserToPatron(dbPatron);
_ = SendWelcomeMessage(newPatron); _ = SendWelcomeMessage(newPatron);
@@ -222,35 +222,38 @@ public sealed class PatronageService
// this should never happen // this should never happen
if (count == 0) if (count == 0)
{ {
await tran.RollbackAsync(); // await tran.RollbackAsync();
continue; continue;
} }
await tran.CommitAsync(); // await tran.CommitAsync();
await OnNewPatronPayment(PatronUserToPatron(dbPatron)); await OnNewPatronPayment(PatronUserToPatron(dbPatron));
} }
else if (dbPatron.AmountCents != subscriber.Cents // if user changed the amount else if (dbPatron.AmountCents != subscriber.Cents // if user changed the amount
|| dbPatron.UserId != subscriber.UserId) // if user updated user id) || dbPatron.UserId != subscriber.UserId) // if user updated user id)
{ {
var cents = subscriber.Cents;
// the user updated the pledge or changed the connected discord account // the user updated the pledge or changed the connected discord account
var newData = await ctx.GetTable<PatronUser>() await ctx.GetTable<PatronUser>()
.Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId .Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId)
&& x.LastCharge < lastChargeUtc) .UpdateAsync(old => new()
.UpdateWithOutputAsync(old => new()
{ {
UserId = subscriber.UserId, UserId = subscriber.UserId,
AmountCents = subscriber.Cents, AmountCents = cents,
LastCharge = lastChargeUtc, LastCharge = lastChargeUtc,
ValidThru = old.ValidThru, ValidThru = old.ValidThru,
}); });
await tran.CommitAsync();
// this should never happen var newPatron = dbPatron.Clone();
if (newData.Length == 0) newPatron.AmountCents = cents;
continue; newPatron.UserId = subscriber.UserId;
await OnPatronUpdated(PatronUserToPatron(dbPatron), PatronUserToPatron(newData[0].Inserted)); // idk what's going on but UpdateWithOutputAsync doesn't work properly here
// nor does firstordefault after update. I'm not seeing something obvious
await OnPatronUpdated(
PatronUserToPatron(dbPatron),
PatronUserToPatron(newPatron));
} }
} }
} }
@@ -262,22 +265,26 @@ public sealed class PatronageService
} }
} }
var expiredDate = DateTime.MinValue;
foreach (var patron in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Refunded)) foreach (var patron in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Refunded))
{ {
var expiredDate = DateTime.MinValue;
// if the subscription is refunded, Disable user's valid thru // if the subscription is refunded, Disable user's valid thru
var output = await ctx.GetTable<PatronUser>() var changedCount = await ctx.GetTable<PatronUser>()
.Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId .Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId
&& x.ValidThru != expiredDate) && x.ValidThru != expiredDate)
.UpdateWithOutputAsync(old => new() .UpdateAsync(old => new()
{ {
ValidThru = expiredDate ValidThru = expiredDate
}); });
if (output.Length == 0) if (changedCount == 0)
continue; continue;
await OnPatronRefunded(PatronUserToPatron(output[0].Inserted)); var updated = await ctx.GetTable<PatronUser>()
.Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId)
.FirstAsync();
await OnPatronRefunded(PatronUserToPatron(updated));
} }
} }
@@ -641,7 +648,6 @@ public sealed class PatronageService
}; };
} }
// should i allow users to pay extra for more quota?
private IReadOnlyDictionary<string, FeatureQuotaStats> GetFeatureQuotaStats( private IReadOnlyDictionary<string, FeatureQuotaStats> GetFeatureQuotaStats(
PatronTier patronTier, PatronTier patronTier,
IReadOnlyDictionary<string, PatronQuota>? allQuotasDict, IReadOnlyDictionary<string, PatronQuota>? allQuotasDict,
@@ -732,7 +738,7 @@ public sealed class PatronageService
private Patron PatronUserToPatron(PatronUser user) private Patron PatronUserToPatron(PatronUser user)
=> new Patron() => new Patron()
{ {
UnqiuePlatformUserId = user.UniquePlatformUserId, UniquePlatformUserId = user.UniquePlatformUserId,
UserId = user.UserId, UserId = user.UserId,
Amount = user.AmountCents, Amount = user.AmountCents,
Tier = CalculateTier(user), Tier = CalculateTier(user),
@@ -747,6 +753,9 @@ public sealed class PatronageService
return user.AmountCents switch return user.AmountCents switch
{ {
>= 10_000 => PatronTier.C,
>= 5000 => PatronTier.L,
>= 2000 => PatronTier.XX,
>= 1000 => PatronTier.X, >= 1000 => PatronTier.X,
>= 500 => PatronTier.V, >= 500 => PatronTier.V,
>= 100 => PatronTier.I, >= 100 => PatronTier.I,
@@ -782,7 +791,7 @@ public sealed class PatronageService
*- 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.* *- 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/>*", *- Permission guide can be found here if you're not familiar with it: <https://nadekobot.readthedocs.io/en/latest/permissions-system/>*",
isInline: false) isInline: false)
.WithFooter($"platform id: {patron.UnqiuePlatformUserId}"); .WithFooter($"platform id: {patron.UniquePlatformUserId}");
await user.EmbedAsync(eb); await user.EmbedAsync(eb);
} }

View File

@@ -69,15 +69,15 @@
<PackageReference Include="JetBrains.Annotations" Version="2022.1.0" /> <PackageReference Include="JetBrains.Annotations" Version="2022.1.0" />
<!-- Db-related packages --> <!-- Db-related packages -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="linq2db.EntityFrameworkCore" Version="6.7.1" /> <PackageReference Include="linq2db.EntityFrameworkCore" Version="6.8.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.6" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.4" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="6.0.1" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="6.0.1" />

View File

@@ -37,8 +37,15 @@ public sealed class BotCredsProvider : IBotCredsProvider
public BotCredsProvider(int? totalShards = null) public BotCredsProvider(int? totalShards = null)
{ {
_totalShards = totalShards; _totalShards = totalShards;
try
{
if (!File.Exists(CredsExamplePath)) if (!File.Exists(CredsExamplePath))
File.WriteAllText(CredsExamplePath, Yaml.Serializer.Serialize(_creds)); File.WriteAllText(CredsExamplePath, Yaml.Serializer.Serialize(_creds));
}
catch
{
// this can fail in docker containers
}
MigrateCredentials(); MigrateCredentials();

View File

@@ -7,7 +7,7 @@ namespace NadekoBot.Services;
public sealed class StatsService : IStatsService, IReadyExecutor, INService public sealed class StatsService : IStatsService, IReadyExecutor, INService
{ {
public const string BOT_VERSION = "4.2.1"; public const string BOT_VERSION = "4.2.2";
public string Author public string Author
=> "Kwoth#2452"; => "Kwoth#2452";

View File

@@ -1,66 +1,68 @@
# DO NOT CHANGE # DO NOT CHANGE
version: 1 version: 1
# Whether the patronage feature is enabled # Whether the patronage feature is enabled
isEnabled: false isEnabled: true
# List of patron only features and relevant quota data # List of patron only features and relevant quota data
quotas: quotas:
# Dictionary of feature names with their respective limits. Set to null for unlimited # Dictionary of feature names with their respective limits. Set to null for unlimited
features: features:
"timely:extra_percent": timely:extra_percent:
v: 10 V: 10
x: 22 X: 22
xx: 50 XX: 50
l: 150 L: 150
c: 350 C: 350
"rero:max_count": rero:max_count:
v: 25 V: 25
x: 50 X: 50
"cleverbot:response": cleverbot:response:
v: -20 V: -20
x: 5000 X: 5000
xx: 12000 XX: 12000
l: 35000 L: 35000
c: 100000 C: 100000
# Dictionary of commands with their respective quota data # Dictionary of commands with their respective quota data
commands: commands:
cleverbot:
V: null
prune: prune:
x: X:
perHour: 1 PerHour: 1
xx: XX:
perHour: 3 PerHour: 3
google: google:
v: V:
perDay: 15 PerDay: 15
x: X:
perDay: 30 PerDay: 30
xx: XX:
perDay: 60 PerDay: 60
l: L:
perDay: 150 PerDay: 150
c: C:
perDay: 300 PerDay: 300
image: image:
v: V:
perDay: 15 PerDay: 15
x: X:
perDay: 30 PerDay: 30
xx: XX:
perDay: 60 PerDay: 60
l: L:
perDay: 150 PerDay: 150
c: C:
perDay: 300 PerDay: 300
youtube: youtube:
v: V:
perDay: 25 PerDay: 25
x: X:
perDay: 50 PerDay: 50
xx: XX:
perDay: 100 PerDay: 100
l: L:
perDay: 250 PerDay: 250
c: C:
perDay: 500 PerDay: 500
# Dictionary of groups with their respective quota data # Dictionary of groups with their respective quota data
groups: {} groups: {}
# Dictionary of modules with their respective quota data # Dictionary of modules with their respective quota data