This repository has been archived on 2024-12-22. You can view files and clone it, but cannot push or open issues or pull requests.
elliebot/src/EllieBot/Modules/Xp/XpService.cs

1650 lines
No EOL
57 KiB
C#

#nullable disable warnings
using LinqToDB;
using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using Newtonsoft.Json;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using System.Threading.Channels;
using LinqToDB.EntityFrameworkCore;
using LinqToDB.Tools;
using EllieBot.Modules.Patronage;
using Color = SixLabors.ImageSharp.Color;
using Exception = System.Exception;
using Image = SixLabors.ImageSharp.Image;
namespace EllieBot.Modules.Xp.Services;
public class XpService : IEService, IReadyExecutor, IExecNoCommand
{
private readonly DbService _db;
private readonly IImageCache _images;
private readonly IBotStrings _strings;
private readonly FontProvider _fonts;
private readonly IBotCreds _creds;
private readonly ICurrencyService _cs;
private readonly IHttpClientFactory _httpFactory;
private readonly XpConfigService _xpConfig;
private readonly IPubSub _pubSub;
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _excludedRoles;
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _excludedChannels;
private readonly ConcurrentHashSet<ulong> _excludedServers;
private XpTemplate template;
private readonly DiscordSocketClient _client;
private readonly TypedKey<bool> _xpTemplateReloadKey;
private readonly IPatronageService _ps;
private readonly IBotCache _c;
private readonly QueueRunner _levelUpQueue = new QueueRunner(0, 50);
private readonly Channel<UserXpGainData> _xpGainQueue = Channel.CreateUnbounded<UserXpGainData>();
private readonly IMessageSenderService _sender;
public XpService(
DiscordSocketClient client,
IBot bot,
DbService db,
IBotStrings strings,
IImageCache images,
IBotCache c,
FontProvider fonts,
IBotCreds creds,
ICurrencyService cs,
IHttpClientFactory http,
XpConfigService xpConfig,
IPubSub pubSub,
IPatronageService ps,
IMessageSenderService sender)
{
_db = db;
_images = images;
_strings = strings;
_fonts = fonts;
_creds = creds;
_cs = cs;
_httpFactory = http;
_xpConfig = xpConfig;
_pubSub = pubSub;
_sender = sender;
_excludedServers = new();
_excludedChannels = new();
_client = client;
_xpTemplateReloadKey = new("xp.template.reload");
_ps = ps;
_c = c;
InternalReloadXpTemplate();
if (client.ShardId == 0)
{
_pubSub.Sub(_xpTemplateReloadKey,
_ =>
{
InternalReloadXpTemplate();
return default;
});
}
//load settings
var allGuildConfigs = bot.AllGuildConfigs.Where(x => x.XpSettings is not null).ToList();
_excludedChannels = allGuildConfigs.ToDictionary(x => x.GuildId,
x => new ConcurrentHashSet<ulong>(x.XpSettings.ExclusionList
.Where(ex => ex.ItemType == ExcludedItemType.Channel)
.Select(ex => ex.ItemId)
.Distinct()))
.ToConcurrent();
_excludedRoles = allGuildConfigs.ToDictionary(x => x.GuildId,
x => new ConcurrentHashSet<ulong>(x.XpSettings.ExclusionList
.Where(ex => ex.ItemType
== ExcludedItemType.Role)
.Select(ex => ex.ItemId)
.Distinct()))
.ToConcurrent();
_excludedServers = new(allGuildConfigs.Where(x => x.XpSettings.ServerExcluded).Select(x => x.GuildId));
#if !GLOBAL_ELLIE
_client.UserVoiceStateUpdated += Client_OnUserVoiceStateUpdated;
// Scan guilds on startup.
_client.GuildAvailable += Client_OnGuildAvailable;
foreach (var guild in _client.Guilds)
Client_OnGuildAvailable(guild);
#endif
}
public async Task OnReadyAsync()
{
_ = Task.Run(() => _levelUpQueue.RunAsync());
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync())
{
await UpdateXp();
}
}
public sealed class MiniGuildXpStats
{
public long Xp { get; set; }
public XpNotificationLocation NotifyOnLevelUp { get; set; }
public ulong GuildId { get; set; }
public ulong UserId { get; set; }
}
private async Task UpdateXp()
{
try
{
var reader = _xpGainQueue.Reader;
// sum up all gains into a single UserCacheItem
var globalToAdd = new Dictionary<ulong, UserXpGainData>();
var guildToAdd = new Dictionary<ulong, Dictionary<ulong, UserXpGainData>>();
while (reader.TryRead(out var item))
{
// add global xp to these users
if (!globalToAdd.TryGetValue(item.User.Id, out var ci))
globalToAdd[item.User.Id] = item.Clone();
else
ci.XpAmount += item.XpAmount;
// ad guild xp in these guilds to these users
if (!guildToAdd.TryGetValue(item.Guild.Id, out var users))
users = guildToAdd[item.Guild.Id] = new();
if (!users.TryGetValue(item.User.Id, out ci))
users[item.User.Id] = item.Clone();
else
ci.XpAmount += item.XpAmount;
}
var dus = new List<DiscordUser>(globalToAdd.Count);
var gxps = new List<UserXpStats>(globalToAdd.Count);
await using (var ctx = _db.GetDbContext())
{
var conf = _xpConfig.Data;
if (conf.CurrencyPerXp > 0)
{
foreach (var user in globalToAdd)
{
var amount = (long)(user.Value.XpAmount * conf.CurrencyPerXp);
if (amount > 0)
await _cs.AddAsync(user.Key, amount, null);
}
}
// update global user xp in batches
// group by xp amount and update the same amounts at the same time
foreach (var group in globalToAdd.GroupBy(x => x.Value.XpAmount, x => x.Key))
{
var items = await ctx.Set<DiscordUser>()
.Where(x => group.Contains(x.UserId))
.UpdateWithOutputAsync(old => new()
{
TotalXp = old.TotalXp + group.Key
},
(_, n) => n);
await ctx.Set<ClubInfo>()
.Where(x => x.Members.Any(m => group.Contains(m.UserId)))
.UpdateAsync(old => new()
{
Xp = old.Xp + (group.Key * old.Members.Count(m => group.Contains(m.UserId)))
});
dus.AddRange(items);
}
// update guild user xp in batches
foreach (var (guildId, toAdd) in guildToAdd)
{
foreach (var group in toAdd.GroupBy(x => x.Value.XpAmount, x => x.Key))
{
var items = await ctx
.Set<UserXpStats>()
.Where(x => x.GuildId == guildId)
.Where(x => group.Contains(x.UserId))
.UpdateWithOutputAsync(old => new()
{
Xp = old.Xp + group.Key
},
(_, n) => n);
gxps.AddRange(items);
var missingUserIds = group.Where(userId => !items.Any(x => x.UserId == userId)).ToArray();
foreach (var userId in missingUserIds)
{
await ctx
.Set<UserXpStats>()
.ToLinqToDBTable()
.InsertOrUpdateAsync(() => new UserXpStats()
{
UserId = userId,
GuildId = guildId,
Xp = group.Key,
DateAdded = DateTime.UtcNow,
AwardedXp = 0,
NotifyOnLevelUp = XpNotificationLocation.None
},
_ => new()
{
},
() => new()
{
UserId = userId
});
}
if (missingUserIds.Length > 0)
{
var missingItems = await ctx.Set<UserXpStats>()
.ToLinqToDBTable()
.Where(x => missingUserIds.Contains(x.UserId))
.ToArrayAsyncLinqToDB();
gxps.AddRange(missingItems);
}
}
}
}
foreach (var du in dus)
{
var oldLevel = new LevelStats(du.TotalXp - globalToAdd[du.UserId].XpAmount);
var newLevel = new LevelStats(du.TotalXp);
if (oldLevel.Level != newLevel.Level)
{
var item = globalToAdd[du.UserId];
await _levelUpQueue.EnqueueAsync(
NotifyUser(item.Guild.Id,
item.Channel.Id,
du.UserId,
false,
oldLevel.Level,
newLevel.Level,
du.NotifyOnLevelUp));
}
}
foreach (var du in gxps)
{
if (guildToAdd.TryGetValue(du.GuildId, out var users)
&& users.TryGetValue(du.UserId, out var xpGainData))
{
var oldLevel = new LevelStats(du.Xp - xpGainData.XpAmount + du.AwardedXp);
var newLevel = new LevelStats(du.Xp + du.AwardedXp);
if (oldLevel.Level < newLevel.Level)
{
await _levelUpQueue.EnqueueAsync(
NotifyUser(xpGainData.Guild.Id,
xpGainData.Channel.Id,
du.UserId,
true,
oldLevel.Level,
newLevel.Level,
du.NotifyOnLevelUp));
}
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Error In the XP update loop");
}
}
private Func<Task> NotifyUser(
ulong guildId,
ulong channelId,
ulong userId,
bool isServer,
long oldLevel,
long newLevel,
XpNotificationLocation notifyLoc)
=> async () =>
{
if (isServer)
{
await HandleRewardsInternalAsync(guildId, userId, oldLevel, newLevel);
}
await HandleNotifyInternalAsync(guildId, channelId, userId, isServer, newLevel, notifyLoc);
};
private async Task HandleRewardsInternalAsync(
ulong guildId,
ulong userId,
long oldLevel,
long newLevel)
{
List<XpRoleReward> rrews;
List<XpCurrencyReward> crews;
await using (var ctx = _db.GetDbContext())
{
rrews = ctx.XpSettingsFor(guildId).RoleRewards.ToList();
crews = ctx.XpSettingsFor(guildId).CurrencyRewards.ToList();
}
//loop through levels since last level up, so if a high amount of xp is gained, reward are still applied.
for (var i = oldLevel + 1; i <= newLevel; i++)
{
var rrew = rrews.FirstOrDefault(x => x.Level == i);
if (rrew is not null)
{
var guild = _client.GetGuild(guildId);
var role = guild?.GetRole(rrew.RoleId);
var user = guild?.GetUser(userId);
if (role is not null && user is not null)
{
if (rrew.Remove)
_ = user.RemoveRoleAsync(role);
else
_ = user.AddRoleAsync(role);
}
}
//get currency reward for this level
var crew = crews.FirstOrDefault(x => x.Level == i);
if (crew is not null)
{
//give the user the reward if it exists
await _cs.AddAsync(userId, crew.Amount, new("xp", "level-up"));
}
}
}
private async Task HandleNotifyInternalAsync(
ulong guildId,
ulong channelId,
ulong userId,
bool isServer,
long newLevel,
XpNotificationLocation notifyLoc)
{
if (notifyLoc == XpNotificationLocation.None)
return;
var guild = _client.GetGuild(guildId);
var user = guild?.GetUser(userId);
var ch = guild?.GetTextChannel(channelId);
if (guild is null || user is null)
return;
if (isServer)
{
if (notifyLoc == XpNotificationLocation.Dm)
{
await _sender.Response(user)
.Confirm(_strings.GetText(strs.level_up_dm(user.Mention,
Format.Bold(newLevel.ToString()),
Format.Bold(guild.ToString() ?? "-")),
guild.Id))
.SendAsync();
}
else // channel
{
if (ch is not null)
{
await _sender.Response(ch)
.Confirm(_strings.GetText(strs.level_up_channel(user.Mention,
Format.Bold(newLevel.ToString())),
guild.Id))
.SendAsync();
}
}
}
else // global level
{
var chan = notifyLoc switch
{
XpNotificationLocation.Dm => (IMessageChannel)await user.CreateDMChannelAsync(),
XpNotificationLocation.Channel => ch,
_ => null
};
if (chan is null)
return;
await _sender.Response(chan)
.Confirm(_strings.GetText(strs.level_up_global(user.Mention,
Format.Bold(newLevel.ToString())),
guild.Id))
.SendAsync();
}
}
private const string XP_TEMPLATE_PATH = "./data/xp_template.json";
private void InternalReloadXpTemplate()
{
try
{
var settings = new JsonSerializerSettings
{
ContractResolver = new RequireObjectPropertiesContractResolver()
};
if (!File.Exists(XP_TEMPLATE_PATH))
{
var newTemp = new XpTemplate();
newTemp.Version = 1;
File.WriteAllText(XP_TEMPLATE_PATH, JsonConvert.SerializeObject(newTemp, Formatting.Indented));
}
template = JsonConvert.DeserializeObject<XpTemplate>(
File.ReadAllText(XP_TEMPLATE_PATH),
settings)!;
if (template.Version < 1)
{
Log.Warning("Loaded default xp_template.json values as the old one was version 0. "
+ "Old one was renamed to xp_template.json.old");
File.WriteAllText("./data/xp_template.json.old",
JsonConvert.SerializeObject(template, Formatting.Indented));
template = new();
template.Version = 1;
File.WriteAllText(XP_TEMPLATE_PATH, JsonConvert.SerializeObject(template, Formatting.Indented));
}
}
catch (Exception ex)
{
Log.Error(ex, "xp_template.json is invalid. Loaded default values");
template = new();
template.Version = 1;
}
}
public void ReloadXpTemplate()
=> _pubSub.Pub(_xpTemplateReloadKey, true);
public void SetCurrencyReward(ulong guildId, int level, int amount)
{
using var uow = _db.GetDbContext();
var settings = uow.XpSettingsFor(guildId);
if (amount <= 0)
{
var toRemove = settings.CurrencyRewards.FirstOrDefault(x => x.Level == level);
if (toRemove is not null)
{
uow.Remove(toRemove);
settings.CurrencyRewards.Remove(toRemove);
}
}
else
{
var rew = settings.CurrencyRewards.FirstOrDefault(x => x.Level == level);
if (rew is not null)
rew.Amount = amount;
else
{
settings.CurrencyRewards.Add(new()
{
Level = level,
Amount = amount
});
}
}
uow.SaveChanges();
}
public IEnumerable<XpCurrencyReward> GetCurrencyRewards(ulong id)
{
using var uow = _db.GetDbContext();
return uow.XpSettingsFor(id).CurrencyRewards.ToArray();
}
public IEnumerable<XpRoleReward> GetRoleRewards(ulong id)
{
using var uow = _db.GetDbContext();
return uow.XpSettingsFor(id).RoleRewards.ToArray();
}
public void ResetRoleReward(ulong guildId, int level)
{
using var uow = _db.GetDbContext();
var settings = uow.XpSettingsFor(guildId);
var toRemove = settings.RoleRewards.FirstOrDefault(x => x.Level == level);
if (toRemove is not null)
{
uow.Remove(toRemove);
settings.RoleRewards.Remove(toRemove);
}
uow.SaveChanges();
}
public void SetRoleReward(
ulong guildId,
int level,
ulong roleId,
bool remove)
{
using var uow = _db.GetDbContext();
var settings = uow.XpSettingsFor(guildId);
var rew = settings.RoleRewards.FirstOrDefault(x => x.Level == level);
if (rew is not null)
{
rew.RoleId = roleId;
rew.Remove = remove;
}
else
{
settings.RoleRewards.Add(new()
{
Level = level,
RoleId = roleId,
Remove = remove,
});
}
uow.SaveChanges();
}
public async Task<IReadOnlyCollection<UserXpStats>> GetGuildUserXps(ulong guildId, int page)
{
await using var uow = _db.GetDbContext();
return await uow
.UserXpStats
.Where(x => x.GuildId == guildId)
.OrderByDescending(x => x.Xp + x.AwardedXp)
.Skip(page * 10)
.Take(10)
.ToArrayAsyncLinqToDB();
}
public async Task<IReadOnlyCollection<UserXpStats>> GetGuildUserXps(ulong guildId, List<ulong> users, int page)
{
await using var uow = _db.GetDbContext();
return await uow.Set<UserXpStats>()
.Where(x => x.GuildId == guildId && x.UserId.In(users))
.OrderByDescending(x => x.Xp + x.AwardedXp)
.Skip(page * 10)
.Take(10)
.ToArrayAsyncLinqToDB();
}
public async Task<IReadOnlyCollection<DiscordUser>> GetGlobalUserXps(int page)
{
await using var uow = _db.GetDbContext();
return await uow.GetTable<DiscordUser>()
.OrderByDescending(x => x.TotalXp)
.Skip(page * 10)
.Take(10)
.ToArrayAsyncLinqToDB();
}
public async Task<IReadOnlyCollection<DiscordUser>> GetGlobalUserXps(int page, List<ulong> users)
{
await using var uow = _db.GetDbContext();
return await uow.GetTable<DiscordUser>()
.Where(x => x.UserId.In(users))
.OrderByDescending(x => x.TotalXp)
.Skip(page * 10)
.Take(10)
.ToArrayAsyncLinqToDB();
}
public async Task ChangeNotificationType(ulong userId, ulong guildId, XpNotificationLocation type)
{
await using var uow = _db.GetDbContext();
var user = uow.GetOrCreateUserXpStats(guildId, userId);
user.NotifyOnLevelUp = type;
await uow.SaveChangesAsync();
}
public XpNotificationLocation GetNotificationType(ulong userId, ulong guildId)
{
using var uow = _db.GetDbContext();
var user = uow.GetOrCreateUserXpStats(guildId, userId);
return user.NotifyOnLevelUp;
}
public XpNotificationLocation GetNotificationType(IUser user)
{
using var uow = _db.GetDbContext();
return uow.GetOrCreateUser(user).NotifyOnLevelUp;
}
public async Task ChangeNotificationType(IUser user, XpNotificationLocation type)
{
await using var uow = _db.GetDbContext();
var du = uow.GetOrCreateUser(user);
du.NotifyOnLevelUp = type;
await uow.SaveChangesAsync();
}
private Task Client_OnGuildAvailable(SocketGuild guild)
{
Task.Run(async () =>
{
foreach (var channel in guild.VoiceChannels)
await ScanChannelForVoiceXp(channel);
});
return Task.CompletedTask;
}
private Task Client_OnUserVoiceStateUpdated(SocketUser socketUser, SocketVoiceState before, SocketVoiceState after)
{
if (socketUser is not SocketGuildUser user || user.IsBot)
return Task.CompletedTask;
_ = Task.Run(async () =>
{
if (before.VoiceChannel is not null)
await ScanChannelForVoiceXp(before.VoiceChannel);
if (after.VoiceChannel is not null && after.VoiceChannel != before.VoiceChannel)
{
await ScanChannelForVoiceXp(after.VoiceChannel);
}
else if (after.VoiceChannel is null && before.VoiceChannel is not null)
{
// In this case, the user left the channel and the previous for loops didn't catch
// it because it wasn't in any new channel. So we need to get rid of it.
await UserLeftVoiceChannel(user, before.VoiceChannel);
}
});
return Task.CompletedTask;
}
private async Task ScanChannelForVoiceXp(SocketVoiceChannel channel)
{
if (ShouldTrackVoiceChannel(channel))
{
foreach (var user in channel.ConnectedUsers)
await ScanUserForVoiceXp(user, channel);
}
else
{
foreach (var user in channel.ConnectedUsers)
await UserLeftVoiceChannel(user, channel);
}
}
/// <summary>
/// Assumes that the channel itself is valid and adding xp.
/// </summary>
/// <param name="user"></param>
/// <param name="channel"></param>
private async Task ScanUserForVoiceXp(SocketGuildUser user, SocketVoiceChannel channel)
{
if (UserParticipatingInVoiceChannel(user) && ShouldTrackXp(user, channel))
await UserJoinedVoiceChannel(user);
else
await UserLeftVoiceChannel(user, channel);
}
private bool ShouldTrackVoiceChannel(SocketVoiceChannel channel)
=> channel.ConnectedUsers.Where(UserParticipatingInVoiceChannel).Take(2).Count() >= 2;
private bool UserParticipatingInVoiceChannel(SocketGuildUser user)
=> !user.IsDeafened && !user.IsMuted && !user.IsSelfDeafened && !user.IsSelfMuted;
private TypedKey<long> GetVoiceXpKey(ulong userId)
=> new($"xp:{_client.CurrentUser.Id}:vc_join:{userId}");
private async Task UserJoinedVoiceChannel(SocketGuildUser user)
{
var value = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
await _c.AddAsync(GetVoiceXpKey(user.Id),
value,
TimeSpan.FromMinutes(_xpConfig.Data.VoiceMaxMinutes),
overwrite: false);
}
// private void UserJoinedVoiceChannel(SocketGuildUser user)
// {
// var key = $"{_creds.RedisKey()}_user_xp_vc_join_{user.Id}";
// var value = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
//
// _cache.Redis.GetDatabase()
// .StringSet(key,
// value,
// TimeSpan.FromMinutes(_xpConfig.Data.VoiceMaxMinutes),
// when: When.NotExists);
// }
private async Task UserLeftVoiceChannel(SocketGuildUser user, SocketVoiceChannel channel)
{
var key = GetVoiceXpKey(user.Id);
var result = await _c.GetAsync(key);
if (!await _c.RemoveAsync(key))
return;
// Allow for if this function gets called multiple times when a user leaves a channel.
if (!result.TryGetValue(out var unixTime))
return;
var dateStart = DateTimeOffset.FromUnixTimeSeconds(unixTime);
var dateEnd = DateTimeOffset.UtcNow;
var minutes = (dateEnd - dateStart).TotalMinutes;
var xp = _xpConfig.Data.VoiceXpPerMinute * minutes;
var actualXp = (int)Math.Floor(xp);
if (actualXp > 0)
{
Log.Information("Adding {Amount} voice xp to {User}", actualXp, user.ToString());
await _xpGainQueue.Writer.WriteAsync(new()
{
Guild = channel.Guild,
User = user,
XpAmount = actualXp,
Channel = channel
});
}
}
/*
* private void UserLeftVoiceChannel(SocketGuildUser user, SocketVoiceChannel channel)
{
var key = $"{_creds.RedisKey()}_user_xp_vc_join_{user.Id}";
var value = _cache.Redis.GetDatabase().StringGet(key);
_cache.Redis.GetDatabase().KeyDelete(key);
// Allow for if this function gets called multiple times when a user leaves a channel.
if (value.IsNull)
return;
if (!value.TryParse(out long startUnixTime))
return;
var dateStart = DateTimeOffset.FromUnixTimeSeconds(startUnixTime);
var dateEnd = DateTimeOffset.UtcNow;
var minutes = (dateEnd - dateStart).TotalMinutes;
var xp = _xpConfig.Data.VoiceXpPerMinute * minutes;
var actualXp = (int)Math.Floor(xp);
if (actualXp > 0)
{
_addMessageXp.Enqueue(new()
{
Guild = channel.Guild,
User = user,
XpAmount = actualXp
});
}
}
*/
private bool ShouldTrackXp(SocketGuildUser user, IMessageChannel channel)
{
var channelId = channel.Id;
if (_excludedChannels.TryGetValue(user.Guild.Id, out var chans)
&& (chans.Contains(channelId)
|| (channel is SocketThreadChannel tc && chans.Contains(tc.ParentChannel.Id))))
return false;
if (_excludedServers.Contains(user.Guild.Id))
return false;
if (_excludedRoles.TryGetValue(user.Guild.Id, out var roles) && user.Roles.Any(x => roles.Contains(x.Id)))
return false;
return true;
}
public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage arg)
{
if (arg.Author is not SocketGuildUser user || user.IsBot)
return Task.CompletedTask;
_ = Task.Run(async () =>
{
if (!ShouldTrackXp(user, arg.Channel))
return;
var xpConf = _xpConfig.Data;
var xp = 0;
if (arg.Attachments.Any(a => a.Height >= 128 && a.Width >= 128))
xp = xpConf.XpFromImage;
if (arg.Content.Contains(' ') || arg.Content.Length >= 5)
xp = Math.Max(xp, xpConf.XpPerMessage);
if (xp <= 0)
return;
if (!await SetUserRewardedAsync(user.Id))
return;
await _xpGainQueue.Writer.WriteAsync(new()
{
Guild = user.Guild,
Channel = arg.Channel,
User = user,
XpAmount = xp
});
});
return Task.CompletedTask;
}
// public void AddXpDirectly(IGuildUser user, IMessageChannel channel, int amount)
// {
// if (amount <= 0)
// throw new ArgumentOutOfRangeException(nameof(amount));
//
// _xpGainQueue.Writer.WriteAsync(new()
// {
// Guild = user.Guild,
// Channel = channel,
// User = user,
// XpAmount = amount
// });
// }
public async Task<int> AddXpToUsersAsync(ulong guildId, long amount, params ulong[] userIds)
{
await using var ctx = _db.GetDbContext();
return await ctx.GetTable<UserXpStats>()
.Where(x => x.GuildId == guildId && userIds.Contains(x.UserId))
.UpdateAsync(old => new()
{
Xp = old.Xp + amount
});
}
public void AddXp(ulong userId, ulong guildId, int amount)
{
using var uow = _db.GetDbContext();
var usr = uow.GetOrCreateUserXpStats(guildId, userId);
usr.AwardedXp += amount;
uow.SaveChanges();
}
public bool IsServerExcluded(ulong id)
=> _excludedServers.Contains(id);
public IEnumerable<ulong> GetExcludedRoles(ulong id)
{
if (_excludedRoles.TryGetValue(id, out var val))
return val.ToArray();
return Enumerable.Empty<ulong>();
}
public IEnumerable<ulong> GetExcludedChannels(ulong id)
{
if (_excludedChannels.TryGetValue(id, out var val))
return val.ToArray();
return Enumerable.Empty<ulong>();
}
private TypedKey<bool> GetUserRewKey(ulong userId)
=> new($"xp:{_client.CurrentUser.Id}:user_gain:{userId}");
private async Task<bool> SetUserRewardedAsync(ulong userId)
=> await _c.AddAsync(GetUserRewKey(userId),
true,
expiry: TimeSpan.FromMinutes(_xpConfig.Data.MessageXpCooldown),
overwrite: false);
public async Task<FullUserStats> GetUserStatsAsync(IGuildUser user)
{
await using var uow = _db.GetDbContext();
var du = uow.GetOrCreateUser(user, set => set.Include(x => x.Club));
var totalXp = du.TotalXp;
var globalRank = uow.Set<DiscordUser>().GetUserGlobalRank(user.Id);
var guildRank = await uow.Set<UserXpStats>().GetUserGuildRanking(user.Id, user.GuildId);
var stats = uow.GetOrCreateUserXpStats(user.GuildId, user.Id);
await uow.SaveChangesAsync();
return new(du,
stats,
new(totalXp),
new(stats.Xp + stats.AwardedXp),
globalRank,
guildRank);
}
public bool ToggleExcludeServer(ulong id)
{
using var uow = _db.GetDbContext();
var xpSetting = uow.XpSettingsFor(id);
if (_excludedServers.Add(id))
{
xpSetting.ServerExcluded = true;
uow.SaveChanges();
return true;
}
_excludedServers.TryRemove(id);
xpSetting.ServerExcluded = false;
uow.SaveChanges();
return false;
}
public bool ToggleExcludeRole(ulong guildId, ulong rId)
{
var roles = _excludedRoles.GetOrAdd(guildId, _ => new());
using var uow = _db.GetDbContext();
var xpSetting = uow.XpSettingsFor(guildId);
var excludeObj = new ExcludedItem
{
ItemId = rId,
ItemType = ExcludedItemType.Role
};
if (roles.Add(rId))
{
if (xpSetting.ExclusionList.Add(excludeObj))
uow.SaveChanges();
return true;
}
roles.TryRemove(rId);
var toDelete = xpSetting.ExclusionList.FirstOrDefault(x => x.Equals(excludeObj));
if (toDelete is not null)
{
uow.Remove(toDelete);
uow.SaveChanges();
}
return false;
}
public bool ToggleExcludeChannel(ulong guildId, ulong chId)
{
var channels = _excludedChannels.GetOrAdd(guildId, _ => new());
using var uow = _db.GetDbContext();
var xpSetting = uow.XpSettingsFor(guildId);
var excludeObj = new ExcludedItem
{
ItemId = chId,
ItemType = ExcludedItemType.Channel
};
if (channels.Add(chId))
{
if (xpSetting.ExclusionList.Add(excludeObj))
uow.SaveChanges();
return true;
}
channels.TryRemove(chId);
if (xpSetting.ExclusionList.Remove(excludeObj))
uow.SaveChanges();
return false;
}
public async Task<(Stream Image, IImageFormat Format)> GenerateXpImageAsync(IGuildUser user)
{
var stats = await GetUserStatsAsync(user);
return await GenerateXpImageAsync(stats);
}
public Task<(Stream Image, IImageFormat Format)> GenerateXpImageAsync(FullUserStats stats)
=> Task.Run(async () =>
{
var bgBytes = await GetXpBackgroundAsync(stats.User.UserId);
if (bgBytes is null)
{
Log.Warning("Xp background image could not be loaded");
throw new ArgumentNullException(nameof(bgBytes));
}
var outlinePen = new SolidPen(Color.Black, 1f);
using var img = Image.Load<Rgba32>(bgBytes);
if (template.User.Name.Show)
{
var fontSize = (int)(template.User.Name.FontSize * 0.9);
var username = stats.User.ToString();
var usernameFont = _fonts.NotoSans.CreateFont(fontSize, FontStyle.Bold);
var size = TextMeasurer.MeasureSize($"@{username}", new(usernameFont));
var scale = 400f / size.Width;
if (scale < 1)
usernameFont = _fonts.NotoSans.CreateFont(template.User.Name.FontSize * scale, FontStyle.Bold);
img.Mutate(x =>
{
x.DrawText(new RichTextOptions(usernameFont)
{
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
FallbackFontFamilies = _fonts.FallBackFonts,
Origin = new(template.User.Name.Pos.X, template.User.Name.Pos.Y + 8)
},
"@" + username,
Brushes.Solid(template.User.Name.Color),
outlinePen);
});
}
//club name
if (template.Club.Name.Show)
{
var clubName = stats.User.Club?.ToString() ?? "-";
var clubFont = _fonts.NotoSans.CreateFont(template.Club.Name.FontSize, FontStyle.Regular);
img.Mutate(x => x.DrawText(new RichTextOptions(clubFont)
{
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
FallbackFontFamilies = _fonts.FallBackFonts,
Origin = new(template.Club.Name.Pos.X + 50, template.Club.Name.Pos.Y - 8)
},
clubName,
Brushes.Solid(template.Club.Name.Color),
outlinePen));
}
Font GetTruncatedFont(
FontFamily fontFamily,
int fontSize,
FontStyle style,
string text,
int maxSize)
{
var font = fontFamily.CreateFont(fontSize, style);
var size = TextMeasurer.MeasureSize(text, new(font));
var scale = maxSize / size.Width;
if (scale < 1)
font = fontFamily.CreateFont(fontSize * scale, style);
return font;
}
if (template.User.GlobalLevel.Show)
{
// up to 83 width
var globalLevelFont = GetTruncatedFont(
_fonts.NotoSans,
template.User.GlobalLevel.FontSize,
FontStyle.Bold,
stats.Global.Level.ToString(),
75);
img.Mutate(x =>
{
x.DrawText(stats.Global.Level.ToString(),
globalLevelFont,
template.User.GlobalLevel.Color,
new(template.User.GlobalLevel.Pos.X, template.User.GlobalLevel.Pos.Y)); //level
});
}
if (template.User.GuildLevel.Show)
{
var guildLevelFont = GetTruncatedFont(
_fonts.NotoSans,
template.User.GuildLevel.FontSize,
FontStyle.Bold,
stats.Guild.Level.ToString(),
75);
img.Mutate(x =>
{
x.DrawText(stats.Guild.Level.ToString(),
guildLevelFont,
template.User.GuildLevel.Color,
new(template.User.GuildLevel.Pos.X, template.User.GuildLevel.Pos.Y));
});
}
var global = stats.Global;
var guild = stats.Guild;
//xp bar
if (template.User.Xp.Bar.Show)
{
var xpPercent = global.LevelXp / (float)global.RequiredXp;
DrawXpBar(xpPercent, template.User.Xp.Bar.Global, img);
xpPercent = guild.LevelXp / (float)guild.RequiredXp;
DrawXpBar(xpPercent, template.User.Xp.Bar.Guild, img);
}
if (template.User.Xp.Global.Show)
{
img.Mutate(x => x.DrawText(
new RichTextOptions(_fonts.NotoSans.CreateFont(template.User.Xp.Global.FontSize, FontStyle.Bold))
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Origin = new(template.User.Xp.Global.Pos.X, template.User.Xp.Global.Pos.Y),
},
$"{global.LevelXp}/{global.RequiredXp}",
Brushes.Solid(template.User.Xp.Global.Color),
outlinePen));
}
if (template.User.Xp.Guild.Show)
{
img.Mutate(x => x.DrawText(
new RichTextOptions(_fonts.NotoSans.CreateFont(template.User.Xp.Guild.FontSize, FontStyle.Bold))
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Origin = new(template.User.Xp.Guild.Pos.X, template.User.Xp.Guild.Pos.Y)
},
$"{guild.LevelXp}/{guild.RequiredXp}",
Brushes.Solid(template.User.Xp.Guild.Color),
outlinePen));
}
if (stats.FullGuildStats.AwardedXp != 0 && template.User.Xp.Awarded.Show)
{
var sign = stats.FullGuildStats.AwardedXp > 0 ? "+ " : "";
var awX = template.User.Xp.Awarded.Pos.X
- (Math.Max(0, stats.FullGuildStats.AwardedXp.ToString().Length - 2) * 5);
var awY = template.User.Xp.Awarded.Pos.Y;
img.Mutate(x => x.DrawText($"({sign}{stats.FullGuildStats.AwardedXp})",
_fonts.NotoSans.CreateFont(template.User.Xp.Awarded.FontSize, FontStyle.Bold),
Brushes.Solid(template.User.Xp.Awarded.Color),
outlinePen,
new(awX, awY)));
}
var rankPen = new SolidPen(Color.White, 1);
//ranking
if (template.User.GlobalRank.Show)
{
var globalRankStr = stats.GlobalRanking.ToString();
var globalRankFont = GetTruncatedFont(
_fonts.UniSans,
template.User.GlobalRank.FontSize,
FontStyle.Bold,
globalRankStr,
68);
img.Mutate(x => x.DrawText(
new RichTextOptions(globalRankFont)
{
Origin = new(template.User.GlobalRank.Pos.X, template.User.GlobalRank.Pos.Y)
},
globalRankStr,
Brushes.Solid(template.User.GlobalRank.Color),
rankPen
));
}
if (template.User.GuildRank.Show)
{
var guildRankStr = stats.GuildRanking.ToString();
var guildRankFont = GetTruncatedFont(
_fonts.UniSans,
template.User.GuildRank.FontSize,
FontStyle.Bold,
guildRankStr,
43);
img.Mutate(x => x.DrawText(
new RichTextOptions(guildRankFont)
{
Origin = new(template.User.GuildRank.Pos.X, template.User.GuildRank.Pos.Y)
},
guildRankStr,
Brushes.Solid(template.User.GuildRank.Color),
rankPen
));
}
//avatar
if (template.User.Icon.Show)
{
try
{
var avatarUrl = stats.User.RealAvatarUrl();
if (avatarUrl is not null)
{
var result = await _c.GetImageDataAsync(avatarUrl);
if (!result.TryPickT0(out var data, out _))
{
using (var http = _httpFactory.CreateClient())
{
var avatarData = await http.GetByteArrayAsync(avatarUrl);
using (var tempDraw = Image.Load<Rgba32>(avatarData))
{
tempDraw.Mutate(x => x
.Resize(template.User.Icon.Size.X, template.User.Icon.Size.Y)
.ApplyRoundedCorners(Math.Max(template.User.Icon.Size.X,
template.User.Icon.Size.Y)
/ 2.0f));
await using (var stream = await tempDraw.ToStreamAsync())
{
data = stream.ToArray();
}
}
}
await _c.SetImageDataAsync(avatarUrl, data);
}
using var toDraw = Image.Load(data);
if (toDraw.Size != new Size(template.User.Icon.Size.X, template.User.Icon.Size.Y))
toDraw.Mutate(x => x.Resize(template.User.Icon.Size.X, template.User.Icon.Size.Y));
img.Mutate(x => x.DrawImage(toDraw,
new Point(template.User.Icon.Pos.X, template.User.Icon.Pos.Y),
1));
}
}
catch (Exception ex)
{
Log.Warning(ex, "Error drawing avatar image");
}
}
//club image
if (template.Club.Icon.Show)
await DrawClubImage(img, stats);
// #if GLOBAL_ELLIE
await DrawFrame(img, stats.User.UserId);
// #endif
var outputSize = template.OutputSize;
if (outputSize.X != img.Width || outputSize.Y != img.Height)
img.Mutate(x => x.Resize(template.OutputSize.X, template.OutputSize.Y));
var imageFormat = img.Metadata.DecodedImageFormat;
var output = ((Stream)await img.ToStreamAsync(imageFormat), imageFormat);
return output;
});
private async Task<byte[]?> GetXpBackgroundAsync(ulong userId)
{
var item = await GetItemInUse(userId, XpShopItemType.Background);
if (item is null)
{
return await _images.GetXpBackgroundImageAsync();
}
var url = _xpConfig.Data.Shop.GetItemUrl(XpShopItemType.Background, item.ItemKey);
if (!string.IsNullOrWhiteSpace(url))
{
var data = await _images.GetImageDataAsync(new Uri(url));
return data;
}
return await _images.GetXpBackgroundImageAsync();
}
// #if GLOBAL_ELLIE
private async Task DrawFrame(Image<Rgba32> img, ulong userId)
{
var patron = await _ps.GetPatronAsync(userId);
var item = await GetItemInUse(userId, XpShopItemType.Frame);
Image? frame = null;
if (item is null)
{
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))
frame = Image.Load<Rgba32>(File.OpenRead("data/images/frame_gold.png"));
}
else
{
var url = _xpConfig.Data.Shop.GetItemUrl(XpShopItemType.Frame, item.ItemKey);
if (!string.IsNullOrWhiteSpace(url))
{
var data = await _images.GetImageDataAsync(new Uri(url));
frame = Image.Load<Rgba32>(data);
}
}
if (frame is not null)
img.Mutate(x => x.DrawImage(frame, new Point(0, 0), new GraphicsOptions()));
}
// #endif
private void DrawXpBar(float percent, XpBar info, Image<Rgba32> img)
{
var x1 = info.PointA.X;
var y1 = info.PointA.Y;
var x2 = info.PointB.X;
var y2 = info.PointB.Y;
var length = info.Length * percent;
float x3, x4, y3, y4;
if (info.Direction == XpTemplateDirection.Down)
{
x3 = x1;
x4 = x2;
y3 = y1 + length;
y4 = y2 + length;
}
else if (info.Direction == XpTemplateDirection.Up)
{
x3 = x1;
x4 = x2;
y3 = y1 - length;
y4 = y2 - length;
}
else if (info.Direction == XpTemplateDirection.Left)
{
x3 = x1 - length;
x4 = x2 - length;
y3 = y1;
y4 = y2;
}
else
{
x3 = x1 + length;
x4 = x2 + length;
y3 = y1;
y4 = y2;
}
img.Mutate(x => x.FillPolygon(info.Color,
new PointF(x1, y1),
new PointF(x3, y3),
new PointF(x4, y4),
new PointF(x2, y2)));
}
private async Task DrawClubImage(Image<Rgba32> img, FullUserStats stats)
{
if (!string.IsNullOrWhiteSpace(stats.User.Club?.ImageUrl))
{
try
{
var imgUrl = new Uri(stats.User.Club.ImageUrl);
var result = await _c.GetImageDataAsync(imgUrl);
if (!result.TryPickT0(out var data, out _))
{
using (var http = _httpFactory.CreateClient())
using (var temp = await http.GetAsync(imgUrl, HttpCompletionOption.ResponseHeadersRead))
{
if (!temp.IsImage() || temp.GetContentLength() > 11 * 1024 * 1024)
return;
var imgData = await temp.Content.ReadAsByteArrayAsync();
using (var tempDraw = Image.Load<Rgba32>(imgData))
{
tempDraw.Mutate(x => x
.Resize(template.Club.Icon.Size.X, template.Club.Icon.Size.Y)
.ApplyRoundedCorners(Math.Max(template.Club.Icon.Size.X,
template.Club.Icon.Size.Y)
/ 2.0f));
await using (var tds = await tempDraw.ToStreamAsync())
{
data = tds.ToArray();
}
}
}
await _c.SetImageDataAsync(imgUrl, data);
}
using var toDraw = Image.Load(data);
if (toDraw.Size != new Size(template.Club.Icon.Size.X, template.Club.Icon.Size.Y))
toDraw.Mutate(x => x.Resize(template.Club.Icon.Size.X, template.Club.Icon.Size.Y));
img.Mutate(x => x.DrawImage(
toDraw,
new Point(template.Club.Icon.Pos.X, template.Club.Icon.Pos.Y),
1));
}
catch (Exception ex)
{
Log.Warning(ex, "Error drawing club image");
}
}
}
public void XpReset(ulong guildId, ulong userId)
{
using var uow = _db.GetDbContext();
uow.Set<UserXpStats>().ResetGuildUserXp(userId, guildId);
uow.SaveChanges();
}
public void XpReset(ulong guildId)
{
using var uow = _db.GetDbContext();
uow.Set<UserXpStats>().ResetGuildXp(guildId);
uow.SaveChanges();
}
public async Task ResetXpRewards(ulong guildId)
{
await using var uow = _db.GetDbContext();
var guildConfig = uow.GuildConfigsForId(guildId,
set => set.Include(x => x.XpSettings)
.ThenInclude(x => x.CurrencyRewards)
.Include(x => x.XpSettings)
.ThenInclude(x => x.RoleRewards));
uow.RemoveRange(guildConfig.XpSettings.RoleRewards);
uow.RemoveRange(guildConfig.XpSettings.CurrencyRewards);
await uow.SaveChangesAsync();
}
public ValueTask<Dictionary<string, XpConfig.ShopItemInfo>?> GetShopBgs()
{
var data = _xpConfig.Data;
if (!data.Shop.IsEnabled)
return new(default(Dictionary<string, XpConfig.ShopItemInfo>));
return new(_xpConfig.Data.Shop.Bgs?.Where(x => x.Value.Price >= 0).ToDictionary(x => x.Key, x => x.Value));
}
public ValueTask<Dictionary<string, XpConfig.ShopItemInfo>?> GetShopFrames()
{
var data = _xpConfig.Data;
if (!data.Shop.IsEnabled)
return new(default(Dictionary<string, XpConfig.ShopItemInfo>));
return new(_xpConfig.Data.Shop.Frames?.Where(x => x.Value.Price >= 0).ToDictionary(x => x.Key, x => x.Value));
}
public async Task<BuyResult> BuyShopItemAsync(ulong userId, XpShopItemType type, string key)
{
var conf = _xpConfig.Data;
if (!conf.Shop.IsEnabled)
return BuyResult.XpShopDisabled;
var req = type == XpShopItemType.Background
? conf.Shop.BgsTierRequirement
: conf.Shop.FramesTierRequirement;
if (req != PatronTier.None && !_creds.IsOwner(userId))
{
var patron = await _ps.GetPatronAsync(userId);
if (patron is null || (int)patron.Value.Tier < (int)req)
return BuyResult.InsufficientPatronTier;
}
await using var ctx = _db.GetDbContext();
try
{
if (await ctx.GetTable<XpShopOwnedItem>()
.AnyAsyncLinqToDB(x => x.UserId == userId && x.ItemKey == key && x.ItemType == type))
return BuyResult.AlreadyOwned;
var item = GetShopItem(type, key);
if (item is null || item.Price < 0)
return BuyResult.UnknownItem;
if (item.Price > 0 && !await _cs.RemoveAsync(userId, item.Price, new("xpshop", "buy", $"Background {key}")))
return BuyResult.InsufficientFunds;
await ctx.GetTable<XpShopOwnedItem>()
.InsertAsync(() => new XpShopOwnedItem()
{
UserId = userId,
IsUsing = false,
ItemKey = key,
ItemType = type,
DateAdded = DateTime.UtcNow,
});
return BuyResult.Success;
}
catch (Exception ex)
{
Log.Error(ex, "Error buying shop item: {ErrorMessage}", ex.Message);
return BuyResult.UnknownItem;
}
}
private XpConfig.ShopItemInfo? GetShopItem(XpShopItemType type, string key)
{
var data = _xpConfig.Data;
if (type == XpShopItemType.Background)
{
if (data.Shop.Bgs is { } bgs && bgs.TryGetValue(key, out var item))
return item;
return null;
}
if (type == XpShopItemType.Frame)
{
if (data.Shop.Frames is { } fs && fs.TryGetValue(key, out var item))
return item;
return null;
}
throw new ArgumentOutOfRangeException(nameof(type));
}
public async Task<bool> OwnsItemAsync(
ulong userId,
XpShopItemType itemType,
string key)
{
await using var ctx = _db.GetDbContext();
return await ctx.GetTable<XpShopOwnedItem>()
.AnyAsyncLinqToDB(x => x.UserId == userId
&& x.ItemType == itemType
&& x.ItemKey == key);
}
public async Task<XpShopOwnedItem?> GetUserItemAsync(
ulong userId,
XpShopItemType itemType,
string key)
{
await using var ctx = _db.GetDbContext();
return await ctx.GetTable<XpShopOwnedItem>()
.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId
&& x.ItemType == itemType
&& x.ItemKey == key);
}
public async Task<XpShopOwnedItem?> GetItemInUse(
ulong userId,
XpShopItemType itemType)
{
await using var ctx = _db.GetDbContext();
return await ctx.GetTable<XpShopOwnedItem>()
.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId
&& x.ItemType == itemType
&& x.IsUsing);
}
public async Task<bool> UseShopItemAsync(ulong userId, XpShopItemType itemType, string key)
{
var data = _xpConfig.Data;
XpConfig.ShopItemInfo? item = null;
if (itemType == XpShopItemType.Background)
{
data.Shop.Bgs?.TryGetValue(key, out item);
}
else
{
data.Shop.Frames?.TryGetValue(key, out item);
}
if (item is null)
return false;
await using var ctx = _db.GetDbContext();
if (await OwnsItemAsync(userId, itemType, key))
{
await ctx.GetTable<XpShopOwnedItem>()
.Where(x => x.UserId == userId && x.ItemType == itemType)
.UpdateAsync(old => new()
{
IsUsing = key == old.ItemKey
});
return true;
}
return false;
}
public PatronTier GetXpShopTierRequirement(Xp.XpShopInputType type)
=> type switch
{
Xp.XpShopInputType.F => _xpConfig.Data.Shop.FramesTierRequirement,
_ => _xpConfig.Data.Shop.BgsTierRequirement,
};
public bool IsShopEnabled()
=> _xpConfig.Data.Shop.IsEnabled;
}
public enum BuyResult
{
Success,
XpShopDisabled,
AlreadyOwned,
InsufficientFunds,
UnknownItem,
InsufficientPatronTier,
}