Abstract away cache. 2 implementations: redis and memory

This commit is contained in:
Kwoth
2022-06-23 13:07:45 +00:00
parent 1716c69132
commit 210da263ad
75 changed files with 11525 additions and 1547 deletions

View File

@@ -500,14 +500,6 @@ public partial class Administration
await ReplyConfirmLocalizedAsync(strs.message_sent);
}
[Cmd]
[OwnerOnly]
public async partial Task ImagesReload()
{
await _service.ReloadImagesAsync();
await ReplyConfirmLocalizedAsync(strs.images_loading);
}
[Cmd]
[OwnerOnly]
public async partial Task StringsReload()

View File

@@ -20,7 +20,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
private ConcurrentDictionary<ulong?, ConcurrentDictionary<int, Timer>> autoCommands = new();
private readonly IImageCache _imgs;
private readonly IHttpClientFactory _httpFactory;
private readonly BotConfigService _bss;
private readonly IPubSub _pubSub;
@@ -28,7 +27,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
//keys
private readonly TypedKey<ActivityPubData> _activitySetKey;
private readonly TypedKey<bool> _imagesReloadKey;
private readonly TypedKey<string> _guildLeaveKey;
public SelfService(
@@ -37,7 +35,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
DbService db,
IBotStrings strings,
IBotCredentials creds,
IDataCache cache,
IHttpClientFactory factory,
BotConfigService bss,
IPubSub pubSub,
@@ -48,20 +45,15 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
_strings = strings;
_client = client;
_creds = creds;
_imgs = cache.LocalImages;
_httpFactory = factory;
_bss = bss;
_pubSub = pubSub;
_eb = eb;
_activitySetKey = new("activity.set");
_imagesReloadKey = new("images.reload");
_guildLeaveKey = new("guild.leave");
HandleStatusChanges();
if (_client.ShardId == 0)
_pubSub.Sub(_imagesReloadKey, async _ => await _imgs.Reload());
_pubSub.Sub(_guildLeaveKey,
async input =>
{
@@ -325,9 +317,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
uow.SaveChanges();
}
public Task ReloadImagesAsync()
=> _pubSub.Pub(_imagesReloadKey, true);
public bool ForwardMessages()
{
var isForwarding = false;

View File

@@ -19,8 +19,8 @@ public partial class Gambling
private static readonly char[] _fateRolls = { '-', ' ', '+' };
private readonly IImageCache _images;
public DiceRollCommands(IDataCache data)
=> _images = data.LocalImages;
public DiceRollCommands(ImageCache images)
=> _images = images;
[Cmd]
public async partial Task Roll()
@@ -31,10 +31,10 @@ public partial class Gambling
var num1 = gen / 10;
var num2 = gen % 10;
using var img1 = GetDice(num1);
using var img2 = GetDice(num2);
using var img1 = await GetDiceAsync(num1);
using var img2 = await GetDiceAsync(num2);
using var img = new[] { img1, img2 }.Merge(out var format);
await using var ms = img.ToStream(format);
await using var ms = await img.ToStreamAsync(format);
await ctx.Channel.SendFileAsync(ms,
$"dice.{format.FileExtensions.First()}",
Format.Bold(ctx.User.ToString()) + " " + GetText(strs.dice_rolled(Format.Code(gen.ToString()))));
@@ -96,7 +96,7 @@ public partial class Gambling
else
toInsert = dice.Count;
dice.Insert(toInsert, GetDice(randomNumber));
dice.Insert(toInsert, await GetDiceAsync(randomNumber));
values.Insert(toInsert, randomNumber);
}
@@ -195,20 +195,19 @@ public partial class Gambling
await ReplyConfirmLocalizedAsync(strs.dice_rolled(Format.Bold(rolled.ToString())));
}
private Image<Rgba32> GetDice(int num)
private async Task<Image<Rgba32>> GetDiceAsync(int num)
{
if (num is < 0 or > 10)
throw new ArgumentOutOfRangeException(nameof(num));
if (num == 10)
{
var images = _images.Dice;
using var imgOne = Image.Load(images[1]);
using var imgZero = Image.Load(images[0]);
using var imgOne = Image.Load(await _images.GetDiceAsync(1));
using var imgZero = Image.Load(await _images.GetDiceAsync(0));
return new[] { imgOne, imgZero }.Merge();
}
return Image.Load(_images.Dice[num]);
return Image.Load(await _images.GetDiceAsync(num));
}
}
}

View File

@@ -14,8 +14,8 @@ public partial class Gambling
private static readonly ConcurrentDictionary<IGuild, Deck> _allDecks = new();
private readonly IImageCache _images;
public DrawCommands(IDataCache data)
=> _images = data.LocalImages;
public DrawCommands(IImageCache images)
=> _images = images;
private async Task<(Stream ImageStream, string ToSend)> InternalDraw(int num, ulong? guildId = null)
{
@@ -43,7 +43,8 @@ public partial class Gambling
var currentCard = cards.Draw();
cardObjects.Add(currentCard);
images.Add(Image.Load(_images.GetCard(currentCard.ToString().ToLowerInvariant().Replace(' ', '_'))));
var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_');
images.Add(Image.Load(await File.ReadAllBytesAsync($"data/images/cards/{cardName}.png")));
}
using var img = images.Merge();

View File

@@ -25,11 +25,17 @@ public partial class Gambling
private static readonly NadekoRandom _rng = new();
private readonly IImageCache _images;
private readonly ICurrencyService _cs;
private readonly ImagesConfig _ic;
public FlipCoinCommands(IDataCache data, ICurrencyService cs, GamblingConfigService gss)
public FlipCoinCommands(
IImageCache images,
ImagesConfig ic,
ICurrencyService cs,
GamblingConfigService gss)
: base(gss)
{
_images = data.LocalImages;
_ic = ic;
_images = images;
_cs = cs;
}
@@ -47,8 +53,8 @@ public partial class Gambling
var imgs = new Image<Rgba32>[count];
for (var i = 0; i < count; i++)
{
var headsArr = _images.Heads[_rng.Next(0, _images.Heads.Count)];
var tailsArr = _images.Tails[_rng.Next(0, _images.Tails.Count)];
var headsArr = await _images.GetHeadsImageAsync();
var tailsArr = await _images.GetTailsImageAsync();
if (_rng.Next(0, 10) < 5)
{
imgs[i] = Image.Load(headsArr);
@@ -94,7 +100,7 @@ public partial class Gambling
BetFlipGuess result;
Uri imageToSend;
var coins = _images.ImageUrls.Coins;
var coins = _ic.Data.Coins;
if (_rng.Next(0, 1000) <= 499)
{
imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)];

View File

@@ -38,7 +38,6 @@ public partial class Gambling : GamblingModule<GamblingService>
private readonly DbService _db;
private readonly ICurrencyService _cs;
private readonly IDataCache _cache;
private readonly DiscordSocketClient _client;
private readonly NumberFormatInfo _enUsCulture;
private readonly DownloadTracker _tracker;
@@ -51,7 +50,6 @@ public partial class Gambling : GamblingModule<GamblingService>
public Gambling(
DbService db,
ICurrencyService currency,
IDataCache cache,
DiscordSocketClient client,
DownloadTracker tracker,
GamblingConfigService configService,
@@ -61,7 +59,6 @@ public partial class Gambling : GamblingModule<GamblingService>
{
_db = db;
_cs = currency;
_cache = cache;
_client = client;
_bank = bank;
_ps = ps;
@@ -124,7 +121,7 @@ public partial class Gambling : GamblingModule<GamblingService>
return;
}
if (_cache.AddTimelyClaim(ctx.User.Id, period) is { } rem)
if (await _service.ClaimTimelyAsync(ctx.User.Id, period) is { } rem)
{
var now = DateTime.UtcNow;
var relativeTag = TimestampTag.FromDateTime(now.Add(rem), TimestampTagStyles.Relative);
@@ -145,7 +142,7 @@ public partial class Gambling : GamblingModule<GamblingService>
[OwnerOnly]
public async partial Task TimelyReset()
{
_cache.RemoveAllTimelyClaims();
await _service.RemoveAllTimelyClaimsAsync();
await ReplyConfirmLocalizedAsync(strs.timely_reset);
}

View File

@@ -1,16 +1,13 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
using NadekoBot.Db.Models;
using NadekoBot.Migrations;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Common.Connect4;
using NadekoBot.Modules.Gambling.Common.Slot;
using NadekoBot.Modules.Gambling.Common.WheelOfFortune;
using Newtonsoft.Json;
namespace NadekoBot.Modules.Gambling.Services;
@@ -22,7 +19,7 @@ public class GamblingService : INService, IReadyExecutor
private readonly ICurrencyService _cs;
private readonly Bot _bot;
private readonly DiscordSocketClient _client;
private readonly IDataCache _cache;
private readonly IBotCache _cache;
private readonly GamblingConfigService _gss;
public GamblingService(
@@ -30,7 +27,7 @@ public class GamblingService : INService, IReadyExecutor
Bot bot,
ICurrencyService cs,
DiscordSocketClient client,
IDataCache cache,
IBotCache cache,
GamblingConfigService gss)
{
_db = db;
@@ -73,6 +70,7 @@ public class GamblingService : INService, IReadyExecutor
}
}
private static readonly TypedKey<long> _curDecayKey = new("currency:last_decay");
private async Task CurrencyDecayLoopAsync()
{
if (_bot.Client.ShardId != 0)
@@ -88,11 +86,16 @@ public class GamblingService : INService, IReadyExecutor
if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0)
continue;
var now = DateTime.UtcNow;
await using var uow = _db.GetDbContext();
var lastCurrencyDecay = _cache.GetLastCurrencyDecay();
var result = await _cache.GetAsync(_curDecayKey);
if (DateTime.UtcNow - lastCurrencyDecay < TimeSpan.FromHours(config.Decay.HourInterval))
if (result.TryPickT0(out var bin, out _)
&& (now - DateTime.FromBinary(bin) < TimeSpan.FromHours(config.Decay.HourInterval)))
{
continue;
}
Log.Information(@"Decaying users' currency - decay: {ConfigDecayPercent}%
| max: {MaxDecay}
@@ -115,8 +118,9 @@ public class GamblingService : INService, IReadyExecutor
: old.CurrencyAmount - maxDecay
});
_cache.SetLastCurrencyDecay();
await uow.SaveChangesAsync();
await _cache.AddAsync(_curDecayKey, now.ToBinary());
}
catch (Exception ex)
{
@@ -161,60 +165,100 @@ public class GamblingService : INService, IReadyExecutor
return toReturn;
}
private static readonly TypedKey<EconomyResult> _ecoKey = new("nadeko:economy");
public async Task<EconomyResult> GetEconomyAsync()
{
if (_cache.TryGetEconomy(out var data))
{
try
var data = await _cache.GetOrAddAsync(_ecoKey,
async () =>
{
return JsonConvert.DeserializeObject<EconomyResult>(data);
}
catch { }
}
await using var uow = _db.GetDbContext();
var cash = uow.DiscordUser.GetTotalCurrency();
var onePercent = uow.DiscordUser.GetTopOnePercentCurrency(_client.CurrentUser.Id);
decimal planted = uow.PlantedCurrency.AsQueryable().Sum(x => x.Amount);
var waifus = uow.WaifuInfo.GetTotalValue();
var bot = uow.DiscordUser.GetUserCurrency(_client.CurrentUser.Id);
decimal bank = await uow.GetTable<BankUser>()
.SumAsyncLinqToDB(x => x.Balance);
decimal cash;
decimal onePercent;
decimal planted;
decimal waifus;
decimal bank;
long bot;
var result = new EconomyResult
{
Cash = cash,
Planted = planted,
Bot = bot,
Waifus = waifus,
OnePercent = onePercent,
Bank = bank
};
using (var uow = _db.GetDbContext())
{
cash = uow.DiscordUser.GetTotalCurrency();
onePercent = uow.DiscordUser.GetTopOnePercentCurrency(_client.CurrentUser.Id);
planted = uow.PlantedCurrency.AsQueryable().Sum(x => x.Amount);
waifus = uow.WaifuInfo.GetTotalValue();
bot = uow.DiscordUser.GetUserCurrency(_client.CurrentUser.Id);
bank = await uow.GetTable<BankUser>()
.SumAsyncLinqToDB(x => x.Balance);
}
return result;
},
TimeSpan.FromMinutes(3));
var result = new EconomyResult
{
Cash = cash,
Planted = planted,
Bot = bot,
Waifus = waifus,
OnePercent = onePercent,
Bank = bank
};
_cache.SetEconomy(JsonConvert.SerializeObject(result));
return result;
return data;
}
public Task<WheelOfFortuneGame.Result> WheelOfFortuneSpinAsync(ulong userId, long bet)
=> new WheelOfFortuneGame(userId, bet, _gss.Data, _cs).SpinAsync();
public struct EconomyResult
private static readonly SemaphoreSlim _timelyLock = new (1, 1);
private static TypedKey<Dictionary<ulong, long>> _timelyKey
= new("timely:claims");
public async Task<TimeSpan?> ClaimTimelyAsync(ulong userId, int period)
{
public decimal Cash { get; set; }
public decimal Planted { get; set; }
public decimal Waifus { get; set; }
public decimal OnePercent { get; set; }
public decimal Bank { get; set; }
public long Bot { get; set; }
if (period == 0)
return null;
await _timelyLock.WaitAsync();
try
{
// get the dictionary from the cache or get a new one
var dict = (await _cache.GetOrAddAsync(_timelyKey,
() => Task.FromResult(new Dictionary<ulong, long>())))!;
var now = DateTime.UtcNow;
var nowB = now.ToBinary();
// try to get users last claim
if (!dict.TryGetValue(userId, out var lastB))
lastB = dict[userId] = now.ToBinary();
var diff = now - DateTime.FromBinary(lastB);
// if its now, or too long ago => success
if (lastB == nowB || diff > period.Hours())
{
// update the cache
dict[userId] = nowB;
await _cache.AddAsync(_timelyKey, dict);
return null;
}
else
{
// otherwise return the remaining time
return period.Hours() - diff;
}
}
finally
{
await _timelyLock.WaitAsync();
}
}
public async Task RemoveAllTimelyClaimsAsync()
=> await _cache.RemoveAsync(_timelyKey);
public readonly struct EconomyResult
{
public decimal Cash { get; init; }
public decimal Planted { get; init; }
public decimal Waifus { get; init; }
public decimal OnePercent { get; init; }
public decimal Bank { get; init; }
public long Bot { get; init; }
}
}

View File

@@ -34,7 +34,7 @@ public class PlantPickService : INService, IExecNoCommand
DbService db,
CommandHandler cmd,
IBotStrings strings,
IDataCache cache,
IImageCache images,
FontProvider fonts,
ICurrencyService cs,
CommandHandler cmdHandler,
@@ -43,7 +43,7 @@ public class PlantPickService : INService, IExecNoCommand
{
_db = db;
_strings = strings;
_images = cache.LocalImages;
_images = images;
_fonts = fonts;
_cs = cs;
_cmdHandler = cmdHandler;
@@ -110,30 +110,21 @@ public class PlantPickService : INService, IExecNoCommand
/// <param name="pass">Optional password to add to top left corner.</param>
/// <param name="extension">Extension of the file, defaults to png</param>
/// <returns>Stream of the currency image</returns>
public Stream GetRandomCurrencyImage(string pass, out string extension)
public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass)
{
// get a random currency image bytes
var rng = new NadekoRandom();
var curImg = _images.Currency[rng.Next(0, _images.Currency.Count)];
var curImg = await _images.GetCurrencyImageAsync();
if (string.IsNullOrWhiteSpace(pass))
{
// determine the extension
using (_ = Image.Load(curImg, out var format))
{
extension = format.FileExtensions.FirstOrDefault() ?? "png";
}
using var load = _ = Image.Load(curImg, out var format);
// return the image
return curImg.ToStream();
return (curImg.ToStream(), format.FileExtensions.FirstOrDefault() ?? "png");
}
// get the image stream and extension
var (s, ext) = AddPassword(curImg, pass);
// set the out extension parameter to the extension we've got
extension = ext;
// return the image
return s;
return AddPassword(curImg, pass);
}
/// <summary>
@@ -214,10 +205,10 @@ public class PlantPickService : INService, IExecNoCommand
var pw = config.Generation.HasPassword ? GenerateCurrencyPassword().ToUpperInvariant() : null;
IUserMessage sent;
await using (var stream = GetRandomCurrencyImage(pw, out var ext))
{
var (stream, ext) = await GetRandomCurrencyImageAsync(pw);
await using (stream)
sent = await channel.SendFileAsync(stream, $"currency_image.{ext}", toSend);
}
await AddPlantToDatabase(channel.GuildId,
channel.Id,
@@ -278,7 +269,7 @@ public class PlantPickService : INService, IExecNoCommand
if (amount > 0)
// give the picked currency to the user
await _cs.AddAsync(uid, amount, new("currency", "collect"));
uow.SaveChanges();
await uow.SaveChangesAsync();
}
try
@@ -316,11 +307,14 @@ public class PlantPickService : INService, IExecNoCommand
msgToSend += " " + GetText(gid, strs.pick_sn(prefix));
//get the image
await using var stream = GetRandomCurrencyImage(pass, out var ext);
var (stream, ext) = await GetRandomCurrencyImageAsync(pass);
// send it
var msg = await ch.SendFileAsync(stream, $"img.{ext}", msgToSend);
// return sent message's id (in order to be able to delete it when it's picked)
return msg.Id;
await using (stream)
{
var msg = await ch.SendFileAsync(stream, $"img.{ext}", msgToSend);
// return sent message's id (in order to be able to delete it when it's picked)
return msg.Id;
}
}
catch
{

View File

@@ -32,13 +32,13 @@ public partial class Gambling
private readonly DbService _db;
public SlotCommands(
IDataCache data,
ImageCache images,
FontProvider fonts,
DbService db,
GamblingConfigService gamb)
: base(gamb)
{
_images = data.LocalImages;
_images = images;
_fonts = fonts;
_db = db;
}
@@ -130,7 +130,8 @@ public partial class Gambling
?? 0;
}
using (var bgImage = Image.Load<Rgba32>(_images.SlotBackground, out _))
var slotBg = await _images.GetSlotBgAsync();
using (var bgImage = Image.Load<Rgba32>(slotBg, out _))
{
var numbers = new int[3];
result.Rolls.CopyTo(numbers, 0);
@@ -184,7 +185,7 @@ public partial class Gambling
for (var i = 0; i < 3; i++)
{
using var img = Image.Load(_images.SlotEmojis[numbers[i]]);
using var img = Image.Load(await _images.GetSlotEmojiAsync(numbers[i]));
bgImage.Mutate(x => x.DrawImage(img, new Point(148 + (105 * i), 217), 1f));
}
@@ -201,7 +202,7 @@ public partial class Gambling
msg = GetText(strs.slot_jackpot(30));
}
await using (var imgStream = bgImage.ToStream())
await using (var imgStream = await bgImage.ToStreamAsync())
{
await ctx.Channel.SendFileAsync(imgStream,
"result.png",

View File

@@ -1,5 +1,6 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
@@ -14,7 +15,7 @@ public class WaifuService : INService, IReadyExecutor
{
private readonly DbService _db;
private readonly ICurrencyService _cs;
private readonly IDataCache _cache;
private readonly IBotCache _cache;
private readonly GamblingConfigService _gss;
private readonly IBotCredentials _creds;
private readonly DiscordSocketClient _client;
@@ -22,7 +23,7 @@ public class WaifuService : INService, IReadyExecutor
public WaifuService(
DbService db,
ICurrencyService cs,
IDataCache cache,
IBotCache cache,
GamblingConfigService gss,
IBotCredentials creds,
DiscordSocketClient client)
@@ -236,8 +237,13 @@ public class WaifuService : INService, IReadyExecutor
var newAff = target is null ? null : uow.GetOrCreateUser(target);
if (w?.Affinity?.UserId == target?.Id)
{
return (null, false, null);
}
else if (!_cache.TryAddAffinityCooldown(user.Id, out remaining))
remaining = await _cache.GetRatelimitAsync(GetAffinityKey(user.Id),
30.Minutes());
if (remaining is not null)
{
}
else if (w is null)
@@ -294,6 +300,12 @@ public class WaifuService : INService, IReadyExecutor
return uow.WaifuInfo.GetWaifuUserId(ownerId, name);
}
private static TypedKey<long> GetDivorceKey(ulong userId)
=> new($"waifu:divorce_cd:{userId}");
private static TypedKey<long> GetAffinityKey(ulong userId)
=> new($"waifu:affinity:{userId}");
public async Task<(WaifuInfo, DivorceResult, long, TimeSpan?)> DivorceWaifuAsync(IUser user, ulong targetId)
{
DivorceResult result;
@@ -305,10 +317,15 @@ public class WaifuService : INService, IReadyExecutor
w = uow.WaifuInfo.ByWaifuUserId(targetId);
if (w?.Claimer is null || w.Claimer.UserId != user.Id)
result = DivorceResult.NotYourWife;
else if (!_cache.TryAddDivorceCooldown(user.Id, out remaining))
result = DivorceResult.Cooldown;
else
{
remaining = await _cache.GetRatelimitAsync(GetDivorceKey(user.Id), 6.Hours());
if (remaining is TimeSpan rem)
{
result = DivorceResult.Cooldown;
return (w, result, amount, rem);
}
amount = w.Price / 2;
if (w.Affinity?.UserId == user.Id)
@@ -486,13 +503,13 @@ public class WaifuService : INService, IReadyExecutor
.ToList();
}
private static readonly TypedKey<long> _waifuDecayKey = $"waifu:last_decay";
public async Task OnReadyAsync()
{
// only decay waifu values from shard 0
if (_client.ShardId != 0)
return;
var redisKey = $"{_creds.RedisKey()}_last_waifu_decay";
while (true)
{
try
@@ -504,28 +521,31 @@ public class WaifuService : INService, IReadyExecutor
if (multi is < 0f or > 1f || decayInterval < 0)
continue;
var val = await _cache.Redis.GetDatabase().StringGetAsync(redisKey);
if (val != default)
var now = DateTime.UtcNow;
var nowB = now.ToBinary();
var result = await _cache.GetAsync(_waifuDecayKey);
if (result.TryGetValue(out var val))
{
var lastDecay = DateTime.FromBinary((long)val);
var lastDecay = DateTime.FromBinary(val);
var toWait = decayInterval.Hours() - (DateTime.UtcNow - lastDecay);
if (toWait > 0.Hours())
continue;
}
await _cache.Redis.GetDatabase().StringSetAsync(redisKey, DateTime.UtcNow.ToBinary());
await _cache.AddAsync(_waifuDecayKey, nowB);
await using var uow = _db.GetDbContext();
await uow.WaifuInfo
await uow.GetTable<WaifuInfo>()
.Where(x => x.Price > minPrice && x.ClaimerId == null)
.UpdateAsync(old => new()
{
Price = (long)(old.Price * multi)
});
await uow.SaveChangesAsync();
}
catch (Exception ex)
{

View File

@@ -14,9 +14,9 @@ public partial class Games : NadekoModule<GamesService>
private readonly IHttpClientFactory _httpFactory;
private readonly Random _rng = new();
public Games(IDataCache data, IHttpClientFactory factory)
public Games(IImageCache images, IHttpClientFactory factory)
{
_images = data.LocalImages;
_images = images;
_httpFactory = factory;
}

View File

@@ -28,11 +28,12 @@ public class GirlRating
Roll = roll;
Advice = advice; // convenient to have it here, even though atm there are only few different ones.
Stream = new(() =>
Stream = new(async () =>
{
try
{
using var img = Image.Load(_images.RategirlMatrix);
var bgBytes = await _images.GetRategirlBgAsync();
using var img = Image.Load(bgBytes);
const int minx = 35;
const int miny = 385;
const int length = 345;
@@ -40,7 +41,8 @@ public class GirlRating
var pointx = (int)(minx + (length * (Hot / 10)));
var pointy = (int)(miny - (length * ((Crazy - 4) / 6)));
using (var pointImg = Image.Load(_images.RategirlDot))
var dotBytes = await _images.GetRategirlDotAsync();
using (var pointImg = Image.Load(dotBytes))
{
img.Mutate(x => x.DrawImage(pointImg, new(pointx - 10, pointy - 10), new GraphicsOptions()));
}

View File

@@ -9,14 +9,14 @@ public partial class Games
[Group]
public partial class TriviaCommands : NadekoModule<GamesService>
{
private readonly IDataCache _cache;
private readonly ILocalDataCache _cache;
private readonly ICurrencyService _cs;
private readonly GamesConfigService _gamesConfig;
private readonly DiscordSocketClient _client;
public TriviaCommands(
DiscordSocketClient client,
IDataCache cache,
ILocalDataCache cache,
ICurrencyService cs,
GamesConfigService gamesConfig)
{

View File

@@ -17,7 +17,7 @@ public class TriviaGame
public bool GameActive { get; private set; }
public bool ShouldStopGame { get; private set; }
private readonly SemaphoreSlim _guessLock = new(1, 1);
private readonly IDataCache _cache;
private readonly ILocalDataCache _cache;
private readonly IBotStrings _strings;
private readonly DiscordSocketClient _client;
private readonly GamesConfig _config;
@@ -35,7 +35,7 @@ public class TriviaGame
IBotStrings strings,
DiscordSocketClient client,
GamesConfig config,
IDataCache cache,
ILocalDataCache cache,
ICurrencyService cs,
IGuild guild,
ITextChannel channel,
@@ -70,7 +70,7 @@ public class TriviaGame
showHowToQuit = !showHowToQuit;
// load question
CurrentQuestion = _questionPool.GetRandomQuestion(OldQuestions, _options.IsPokemon);
CurrentQuestion = await _questionPool.GetRandomQuestionAsync(OldQuestions, _options.IsPokemon);
if (string.IsNullOrWhiteSpace(CurrentQuestion?.Answer)
|| string.IsNullOrWhiteSpace(CurrentQuestion.Question))
{

View File

@@ -4,6 +4,15 @@ using System.Text.RegularExpressions;
// THANKS @ShoMinamimoto for suggestions and coding help
namespace NadekoBot.Modules.Games.Common.Trivia;
public sealed class TriviaQuestionModel
{
public string Category { get; init; }
public string Question { get; init; }
public string ImageUrl { get; init; }
public string AnswerImageUrl { get; init; }
public string Answer { get; init; }
}
public class TriviaQuestion
{
public const int MAX_STRING_LENGTH = 22;
@@ -17,29 +26,30 @@ public class TriviaQuestion
new(22, 3)
};
public string Category { get; set; }
public string Question { get; set; }
public string ImageUrl { get; set; }
public string AnswerImageUrl { get; set; }
public string Answer { get; set; }
public string Category
=> _qModel.Category;
public string Question
=> _qModel.Question;
public string ImageUrl
=> _qModel.ImageUrl;
public string AnswerImageUrl
=> _qModel.AnswerImageUrl ?? ImageUrl;
public string Answer
=> _qModel.Answer;
public string CleanAnswer
=> cleanAnswer ?? (cleanAnswer = Clean(Answer));
private string cleanAnswer;
private readonly TriviaQuestionModel _qModel;
public TriviaQuestion(
string q,
string a,
string c,
string img = null,
string answerImage = null)
public TriviaQuestion(TriviaQuestionModel qModel)
{
Question = q;
Answer = a;
Category = c;
ImageUrl = img;
AnswerImageUrl = answerImage ?? img;
_qModel = qModel;
}
public string GetHint()

View File

@@ -1,45 +1,48 @@
#nullable disable
namespace NadekoBot.Modules.Games.Common.Trivia;
public class TriviaQuestionPool
{
private TriviaQuestion[] Pool
=> _cache.LocalData.TriviaQuestions;
private IReadOnlyDictionary<int, string> Map
=> _cache.LocalData.PokemonMap;
private readonly IDataCache _cache;
private readonly ILocalDataCache _cache;
private readonly int _maxPokemonId;
private readonly NadekoRandom _rng = new();
public TriviaQuestionPool(IDataCache cache)
public TriviaQuestionPool(ILocalDataCache cache)
{
_cache = cache;
_maxPokemonId = 721; //xd
}
public TriviaQuestion GetRandomQuestion(HashSet<TriviaQuestion> exclude, bool isPokemon)
public async Task<TriviaQuestion?> GetRandomQuestionAsync(HashSet<TriviaQuestion> exclude, bool isPokemon)
{
if (Pool.Length == 0)
return null;
if (isPokemon)
{
var pokes = await _cache.GetPokemonMapAsync();
if (pokes is null or { Length: 0 })
return default;
var num = _rng.Next(1, _maxPokemonId + 1);
return new("Who's That Pokémon?",
Map[num].ToTitleCase(),
"Pokemon",
$@"https://nadeko.bot/images/pokemon/shadows/{num}.png",
$@"https://nadeko.bot/images/pokemon/real/{num}.png");
return new(new()
{
Question = "Who's That Pokémon?",
Answer = pokes[num].Name.ToTitleCase(),
Category = "Pokemon",
ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png",
AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png"
});
}
TriviaQuestion randomQuestion;
while (exclude.Contains(randomQuestion = Pool[_rng.Next(0, Pool.Length)]))
var pool = await _cache.GetTriviaQuestionsAsync();
if(pool is null)
return default;
while (exclude.Contains(randomQuestion = new(pool[_rng.Next(0, pool.Length)])))
{
// if too many questions are excluded, clear the exclusion list and start over
if (exclude.Count > Pool.Length / 10 * 9)
if (exclude.Count > pool.Length / 10 * 9)
{
exclude.Clear();
break;

View File

@@ -1,209 +0,0 @@
using StackExchange.Redis;
using System.Runtime.CompilerServices;
using System.Text.Json;
namespace NadekoBot.Modules.Music;
public sealed class RedisTrackCacher : ITrackCacher
{
private readonly ConnectionMultiplexer _multiplexer;
public RedisTrackCacher(ConnectionMultiplexer multiplexer)
=> _multiplexer = multiplexer;
public async Task<string?> GetOrCreateStreamLink(
string id,
MusicPlatform platform,
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory)
{
var trackStreamKey = CreateStreamKey(id, platform);
var value = await GetStreamFromCacheInternalAsync(trackStreamKey);
// if there is no cached value
if (value == default)
{
// otherwise retrieve and cache a new value, and run this method again
var success = await CreateAndCacheStreamUrlAsync(trackStreamKey, streamUrlFactory);
if (!success)
return null;
return await GetOrCreateStreamLink(id, platform, streamUrlFactory);
}
// cache new one for future use
_ = Task.Run(() => CreateAndCacheStreamUrlAsync(trackStreamKey, streamUrlFactory));
return value;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string CreateStreamKey(string id, MusicPlatform platform)
=> $"track:stream:{platform}:{id}";
private async Task<bool> CreateAndCacheStreamUrlAsync(
string trackStreamKey,
Func<Task<(string StreamUrl, TimeSpan Expiry)>> factory)
{
try
{
var data = await factory();
if (data == default)
return false;
await CacheStreamUrlInternalAsync(trackStreamKey, data.StreamUrl, data.Expiry);
return true;
}
catch (Exception ex)
{
Log.Error(ex, "Error resolving stream link for {TrackCacheKey}", trackStreamKey);
return false;
}
}
public Task CacheStreamUrlAsync(
string id,
MusicPlatform platform,
string url,
TimeSpan expiry)
=> CacheStreamUrlInternalAsync(CreateStreamKey(id, platform), url, expiry);
private async Task CacheStreamUrlInternalAsync(string trackStreamKey, string url, TimeSpan expiry)
{
// keys need to be expired after an hour
// to make sure client doesn't get an expired stream url
// to achieve this, track keys will be just pointers to real data
// but that data will expire
var db = _multiplexer.GetDatabase();
var dataKey = $"entry:{Guid.NewGuid()}:{trackStreamKey}";
await db.StringSetAsync(dataKey, url, expiry);
await db.ListRightPushAsync(trackStreamKey, dataKey);
}
private async Task<string?> GetStreamFromCacheInternalAsync(string trackStreamKey)
{
// Job of the method which retrieves keys is to pop the elements
// from the list of cached trackurls until it finds a non-expired key
var db = _multiplexer.GetDatabase();
while (true)
{
string? dataKey = await db.ListLeftPopAsync(trackStreamKey);
if (dataKey == default)
return null;
var streamUrl = await db.StringGetAsync(dataKey);
if (streamUrl == default)
continue;
return streamUrl;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string CreateCachedDataKey(string id, MusicPlatform platform)
=> $"track:data:{platform}:{id}";
public Task CacheTrackDataAsync(ICachableTrackData data)
{
var db = _multiplexer.GetDatabase();
var trackDataKey = CreateCachedDataKey(data.Id, data.Platform);
var dataString = JsonSerializer.Serialize((object)data);
// cache for 1 day
return db.StringSetAsync(trackDataKey, dataString, TimeSpan.FromDays(1));
}
public async Task<ICachableTrackData?> GetCachedDataByIdAsync(string id, MusicPlatform platform)
{
var db = _multiplexer.GetDatabase();
var trackDataKey = CreateCachedDataKey(id, platform);
var data = await db.StringGetAsync(trackDataKey);
if (data == default)
return null;
return JsonSerializer.Deserialize<CachableTrackData>(data);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string CreateCachedQueryDataKey(string query, MusicPlatform platform)
=> $"track:query_to_id:{platform}:{query}";
public async Task<ICachableTrackData?> GetCachedDataByQueryAsync(string query, MusicPlatform platform)
{
query = Uri.EscapeDataString(query.Trim());
var db = _multiplexer.GetDatabase();
var queryDataKey = CreateCachedQueryDataKey(query, platform);
var trackId = await db.StringGetAsync(queryDataKey);
if (trackId == default)
return null;
return await GetCachedDataByIdAsync(trackId, platform);
}
public async Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data)
{
query = Uri.EscapeDataString(query.Trim());
// first cache the data
await CacheTrackDataAsync(data);
// then map the query to cached data's id
var db = _multiplexer.GetDatabase();
var queryDataKey = CreateCachedQueryDataKey(query, data.Platform);
await db.StringSetAsync(queryDataKey, data.Id, TimeSpan.FromDays(7));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string CreateCachedPlaylistKey(string playlistId, MusicPlatform platform)
=> $"playlist:{platform}:{playlistId}";
public async Task<IReadOnlyCollection<string>> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform)
{
var db = _multiplexer.GetDatabase();
var key = CreateCachedPlaylistKey(playlistId, platform);
var vals = await db.ListRangeAsync(key);
if (vals == default || vals.Length == 0)
return Array.Empty<string>();
return vals.Select(x => x.ToString()).ToList();
}
public async Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable<string> ids)
{
var db = _multiplexer.GetDatabase();
var key = CreateCachedPlaylistKey(playlistId, platform);
await db.ListRightPushAsync(key, ids.Select(x => (RedisValue)x).ToArray());
await db.KeyExpireAsync(key, TimeSpan.FromDays(7));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string CreateCachedPlaylistQueryKey(string query, MusicPlatform platform)
=> $"playlist:query:{platform}:{query}";
public Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId)
{
query = Uri.EscapeDataString(query.Trim());
var key = CreateCachedPlaylistQueryKey(query, platform);
var db = _multiplexer.GetDatabase();
return db.StringSetAsync(key, playlistId, TimeSpan.FromDays(7));
}
public async Task<string?> GetPlaylistIdByQueryAsync(string query, MusicPlatform platform)
{
query = Uri.EscapeDataString(query.Trim());
var key = CreateCachedPlaylistQueryKey(query, platform);
var val = await _multiplexer.GetDatabase().StringGetAsync(key);
if (val == default)
return null;
return val;
}
}

View File

@@ -0,0 +1,95 @@
namespace NadekoBot.Modules.Music;
public sealed class TrackCacher : ITrackCacher
{
private readonly IBotCache _cache;
public TrackCacher(IBotCache cache)
=> _cache = cache;
private TypedKey<string> GetStreamLinkKey(MusicPlatform platform, string id)
=> new($"music:stream:{platform}:{id}");
public async Task<string?> GetOrCreateStreamLink(
string id,
MusicPlatform platform,
Func<Task<(string StreamUrl, TimeSpan Expiry)>> streamUrlFactory)
{
var key = GetStreamLinkKey(platform, id);
var streamUrl = await _cache.GetOrDefaultAsync(key);
await _cache.RemoveAsync(key);
if (streamUrl == default)
{
(streamUrl, _) = await streamUrlFactory();
}
// make a new one for later use
_ = Task.Run(async () =>
{
(streamUrl, var expiry) = await streamUrlFactory();
await CacheStreamUrlAsync(id, platform, streamUrl, expiry);
});
return streamUrl;
}
public async Task CacheStreamUrlAsync(
string id,
MusicPlatform platform,
string url,
TimeSpan expiry)
=> await _cache.AddAsync(GetStreamLinkKey(platform, id), url, expiry);
// track data by id
private TypedKey<ICachableTrackData> GetTrackDataKey(MusicPlatform platform, string id)
=> new($"music:track:{platform}:{id}");
public async Task CacheTrackDataAsync(ICachableTrackData data)
=> await _cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), data);
public async Task<ICachableTrackData?> GetCachedDataByIdAsync(string id, MusicPlatform platform)
=> await _cache.GetOrDefaultAsync(GetTrackDataKey(platform, id));
// track data by query
private TypedKey<ICachableTrackData> GetTrackDataQueryKey(MusicPlatform platform, string query)
=> new($"music:track:{platform}:q:{query}");
public async Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data)
=> await Task.WhenAll(
_cache.AddAsync(GetTrackDataQueryKey(data.Platform, query), data).AsTask(),
_cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), data).AsTask());
public async Task<ICachableTrackData?> GetCachedDataByQueryAsync(string query, MusicPlatform platform)
=> await _cache.GetOrDefaultAsync(GetTrackDataQueryKey(platform, query));
// playlist track ids by playlist id
private TypedKey<IReadOnlyCollection<string>> GetPlaylistTracksCacheKey(string playlist, MusicPlatform platform)
=> new($"music:playlist_tracks:{platform}:{playlist}");
public async Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable<string> ids)
=> await _cache.AddAsync(GetPlaylistTracksCacheKey(playlistId, platform), ids.ToList());
public async Task<IReadOnlyCollection<string>> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform)
{
var result = await _cache.GetAsync(GetPlaylistTracksCacheKey(playlistId, platform));
if (result.TryGetValue(out var val))
return val;
return Array.Empty<string>();
}
// playlist id by query
private TypedKey<string> GetPlaylistCacheKey(string query, MusicPlatform platform)
=> new($"music:playlist_id:{platform}:{query}");
public async Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId)
=> await _cache.AddAsync(GetPlaylistCacheKey(query, platform), playlistId);
public async Task<string?> GetPlaylistIdByQueryAsync(string query, MusicPlatform platform)
=> await _cache.GetOrDefaultAsync(GetPlaylistCacheKey(query, platform));
}

View File

@@ -1,34 +1,38 @@
#nullable disable
using Newtonsoft.Json;
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Searches.Common;
public class AnimeResult
{
[JsonPropertyName("id")]
public int Id { get; set; }
public string AiringStatus
=> AiringStatusParsed.ToTitleCase();
[JsonProperty("airing_status")]
[JsonPropertyName("airing_status")]
public string AiringStatusParsed { get; set; }
[JsonProperty("title_english")]
[JsonPropertyName("title_english")]
public string TitleEnglish { get; set; }
[JsonProperty("total_episodes")]
[JsonPropertyName("total_episodes")]
public int TotalEpisodes { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonProperty("image_url_lge")]
[JsonPropertyName("image_url_lge")]
public string ImageUrlLarge { get; set; }
[JsonPropertyName("genres")]
public string[] Genres { get; set; }
[JsonProperty("average_score")]
public string AverageScore { get; set; }
[JsonPropertyName("average_score")]
public float AverageScore { get; set; }
public string AiringStatus
=> AiringStatusParsed.ToTitleCase();
public string Link
=> "http://anilist.co/anime/" + Id;

View File

@@ -1,17 +1,15 @@
#nullable disable
using AngleSharp;
using AngleSharp.Html.Dom;
using NadekoBot.Modules.Searches.Common;
using Newtonsoft.Json;
using System.Net.Http.Json;
namespace NadekoBot.Modules.Searches.Services;
public class AnimeSearchService : INService
{
private readonly IDataCache _cache;
private readonly IBotCache _cache;
private readonly IHttpClientFactory _httpFactory;
public AnimeSearchService(IDataCache cache, IHttpClientFactory httpFactory)
public AnimeSearchService(IBotCache cache, IHttpClientFactory httpFactory)
{
_cache = cache;
_httpFactory = httpFactory;
@@ -21,24 +19,25 @@ public class AnimeSearchService : INService
{
if (string.IsNullOrWhiteSpace(query))
throw new ArgumentNullException(nameof(query));
TypedKey<AnimeResult> GetKey(string link)
=> new TypedKey<AnimeResult>($"anime2:{link}");
try
{
var link = "https://aniapi.nadeko.bot/anime/"
+ Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
var suffix = Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
var link = $"https://aniapi.nadeko.bot/anime/{suffix}";
link = link.ToLowerInvariant();
var (ok, data) = await _cache.TryGetAnimeDataAsync(link);
if (!ok)
var result = await _cache.GetAsync(GetKey(link));
if (!result.TryPickT0(out var data, out _))
{
using (var http = _httpFactory.CreateClient())
{
data = await http.GetStringAsync(link);
}
using var http = _httpFactory.CreateClient();
data = await http.GetFromJsonAsync<AnimeResult>(link);
await _cache.SetAnimeDataAsync(link, data);
await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(12));
}
return JsonConvert.DeserializeObject<AnimeResult>(data);
return data;
}
catch
{
@@ -46,95 +45,31 @@ public class AnimeSearchService : INService
}
}
public async Task<NovelResult> GetNovelData(string query)
{
if (string.IsNullOrWhiteSpace(query))
throw new ArgumentNullException(nameof(query));
query = query.Replace(" ", "-", StringComparison.InvariantCulture);
try
{
var link = "https://www.novelupdates.com/series/"
+ Uri.EscapeDataString(query.Replace(" ", "-").Replace("/", " "));
link = link.ToLowerInvariant();
var (ok, data) = await _cache.TryGetNovelDataAsync(link);
if (!ok)
{
var config = Configuration.Default.WithDefaultLoader();
using var document = await BrowsingContext.New(config).OpenAsync(link);
var imageElem = document.QuerySelector("div.seriesimg > img");
if (imageElem is null)
return null;
var imageUrl = ((IHtmlImageElement)imageElem).Source;
var descElem = document.QuerySelector("div#editdescription > p");
var desc = descElem.InnerHtml;
var genres = document.QuerySelector("div#seriesgenre")
.Children.Select(x => x as IHtmlAnchorElement)
.Where(x => x is not null)
.Select(x => $"[{x.InnerHtml}]({x.Href})")
.ToArray();
var authors = document.QuerySelector("div#showauthors")
.Children.Select(x => x as IHtmlAnchorElement)
.Where(x => x is not null)
.Select(x => $"[{x.InnerHtml}]({x.Href})")
.ToArray();
var score = ((IHtmlSpanElement)document.QuerySelector("h5.seriesother > span.uvotes")).InnerHtml;
var status = document.QuerySelector("div#editstatus").InnerHtml;
var title = document.QuerySelector("div.w-blog-content > div.seriestitlenu").InnerHtml;
var obj = new NovelResult
{
Description = desc,
Authors = authors,
Genres = genres,
ImageUrl = imageUrl,
Link = link,
Score = score,
Status = status,
Title = title
};
await _cache.SetNovelDataAsync(link, JsonConvert.SerializeObject(obj));
return obj;
}
return JsonConvert.DeserializeObject<NovelResult>(data);
}
catch (Exception ex)
{
Log.Error(ex, "Error getting novel data");
return null;
}
}
public async Task<MangaResult> GetMangaData(string query)
{
if (string.IsNullOrWhiteSpace(query))
throw new ArgumentNullException(nameof(query));
TypedKey<MangaResult> GetKey(string link)
=> new TypedKey<MangaResult>($"manga2:{link}");
try
{
var link = "https://aniapi.nadeko.bot/manga/"
+ Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
link = link.ToLowerInvariant();
var (ok, data) = await _cache.TryGetAnimeDataAsync(link);
if (!ok)
var result = await _cache.GetAsync(GetKey(link));
if (!result.TryPickT0(out var data, out _))
{
using (var http = _httpFactory.CreateClient())
{
data = await http.GetStringAsync(link);
}
using var http = _httpFactory.CreateClient();
data = await http.GetFromJsonAsync<MangaResult>(link);
await _cache.SetAnimeDataAsync(link, data);
await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(3));
}
return JsonConvert.DeserializeObject<MangaResult>(data);
return data;
}
catch
{

View File

@@ -1,32 +1,36 @@
#nullable disable
using Newtonsoft.Json;
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Searches.Common;
public class MangaResult
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonProperty("publishing_status")]
[JsonPropertyName("publishing_status")]
public string PublishingStatus { get; set; }
[JsonProperty("image_url_lge")]
[JsonPropertyName("image_url_lge")]
public string ImageUrlLge { get; set; }
[JsonProperty("title_english")]
[JsonPropertyName("title_english")]
public string TitleEnglish { get; set; }
[JsonProperty("total_chapters")]
[JsonPropertyName("total_chapters")]
public int TotalChapters { get; set; }
[JsonProperty("total_volumes")]
[JsonPropertyName("total_volumes")]
public int TotalVolumes { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("genres")]
public string[] Genres { get; set; }
[JsonProperty("average_score")]
public string AverageScore { get; set; }
[JsonPropertyName("average_score")]
public float AverageScore { get; set; }
public string Link
=> "http://anilist.co/manga/" + Id;

View File

@@ -15,13 +15,13 @@ namespace NadekoBot.Modules.Searches.Services;
public class CryptoService : INService
{
private readonly IDataCache _cache;
private readonly IBotCache _cache;
private readonly IHttpClientFactory _httpFactory;
private readonly IBotCredentials _creds;
private readonly SemaphoreSlim _getCryptoLock = new(1, 1);
public CryptoService(IDataCache cache, IHttpClientFactory httpFactory, IBotCredentials creds)
public CryptoService(IBotCache cache, IHttpClientFactory httpFactory, IBotCredentials creds)
{
_cache = cache;
_httpFactory = httpFactory;
@@ -40,7 +40,8 @@ public class CryptoService : INService
Span<PointF> points = new PointF[gElement.ChildNodes.Count];
var cnt = 0;
bool GetValuesFromAttributes(XmlAttributeCollection attrs,
bool GetValuesFromAttributes(
XmlAttributeCollection attrs,
out float x1,
out float y1,
out float x2,
@@ -56,7 +57,7 @@ public class CryptoService : INService
&& attrs["y2"]?.Value is string y2Str
&& float.TryParse(y2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y2);
}
foreach (XmlElement x in gElement.ChildNodes)
{
if (x.Name != "line")
@@ -67,22 +68,22 @@ public class CryptoService : INService
points[cnt++] = new(x1, y1);
// this point will be set twice to the same value
// on all points except the last one
if(cnt + 1 < points.Length)
if (cnt + 1 < points.Length)
points[cnt + 1] = new(x2, y2);
}
}
if (cnt == 0)
return Array.Empty<PointF>();
return points.Slice(0, cnt).ToArray();
}
private SixLabors.ImageSharp.Image<Rgba32> GenerateSparklineChart(PointF[] points, bool up)
{
const int width = 164;
const int height = 48;
var img = new Image<Rgba32>(width, height, Color.Transparent);
var color = up
? Color.Green
@@ -92,10 +93,10 @@ public class CryptoService : INService
{
x.DrawLines(color, 2, points);
});
return img;
}
public async Task<(CmcResponseData? Data, CmcResponseData? Nearest)> GetCryptoData(string name)
{
if (string.IsNullOrWhiteSpace(name))
@@ -130,20 +131,20 @@ public class CryptoService : INService
await _getCryptoLock.WaitAsync();
try
{
var fullStrData = await _cache.GetOrAddCachedDataAsync("nadeko:crypto_data",
async _ =>
var data = await _cache.GetOrAddAsync(new("nadeko:crypto_data"),
async () =>
{
try
{
using var http = _httpFactory.CreateClient();
var strData = await http.GetFromJsonAsync<CryptoResponse>(
var data = await http.GetFromJsonAsync<CryptoResponse>(
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?"
+ $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}"
+ "&start=1"
+ "&limit=5000"
+ "&convert=USD");
return JsonSerializer.Serialize(strData);
return data;
}
catch (Exception ex)
{
@@ -151,13 +152,12 @@ public class CryptoService : INService
return default;
}
},
"",
TimeSpan.FromHours(2));
if (fullStrData is null)
if (data is null)
return default;
return JsonSerializer.Deserialize<CryptoResponse>(fullStrData)?.Data ?? new();
return data.Data;
}
catch (Exception ex)
{
@@ -170,44 +170,33 @@ public class CryptoService : INService
}
}
private TypedKey<byte[]> GetSparklineKey(int id)
=> new($"crypto:sparkline:{id}");
public async Task<Stream?> GetSparklineAsync(int id, bool up)
{
var key = $"crypto:sparkline:{id}";
// attempt to get from cache
var db = _cache.Redis.GetDatabase();
byte[] bytes = await db.StringGetAsync(key);
// if it succeeds, return it
if (bytes is { Length: > 0 })
{
return bytes.ToStream();
}
// if it fails, generate a new one
var points = await DownloadSparklinePointsAsync(id);
if (points is null)
return default;
var sparkline = GenerateSparklineChart(points, up);
// add to cache for 1h and return it
var stream = sparkline.ToStream();
await db.StringSetAsync(key, stream.ToArray(), expiry: TimeSpan.FromHours(1));
return stream;
}
private async Task<PointF[]?> DownloadSparklinePointsAsync(int id)
{
try
{
using var http = _httpFactory.CreateClient();
var str = await http.GetStringAsync(
$"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg");
var points = GetSparklinePointsFromSvgText(str);
return points;
var bytes = await _cache.GetOrAddAsync(GetSparklineKey(id),
async () =>
{
// if it fails, generate a new one
var points = await DownloadSparklinePointsAsync(id);
var sparkline = GenerateSparklineChart(points, up);
using var stream = await sparkline.ToStreamAsync();
return stream.ToArray();
},
TimeSpan.FromHours(1));
if (bytes is { Length: > 0 })
{
return bytes.ToStream();
}
return default;
}
catch(Exception ex)
catch (Exception ex)
{
Log.Warning(ex,
"Exception occurred while downloading sparkline points: {ErrorMessage}",
@@ -215,4 +204,13 @@ public class CryptoService : INService
return default;
}
}
private async Task<PointF[]> DownloadSparklinePointsAsync(int id)
{
using var http = _httpFactory.CreateClient();
var str = await http.GetStringAsync(
$"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg");
var points = GetSparklinePointsFromSvgText(str);
return points;
}
}

View File

@@ -9,15 +9,9 @@ public partial class Searches
[Group]
public partial class PokemonSearchCommands : NadekoModule<SearchesService>
{
public IReadOnlyDictionary<string, SearchPokemon> Pokemons
=> _cache.LocalData.Pokemons;
private readonly ILocalDataCache _cache;
public IReadOnlyDictionary<string, SearchPokemonAbility> PokemonAbilities
=> _cache.LocalData.PokemonAbilities;
private readonly IDataCache _cache;
public PokemonSearchCommands(IDataCache cache)
public PokemonSearchCommands(ILocalDataCache cache)
=> _cache = cache;
[Cmd]
@@ -27,7 +21,7 @@ public partial class Searches
if (string.IsNullOrWhiteSpace(pokemon))
return;
foreach (var kvp in Pokemons)
foreach (var kvp in await _cache.GetPokemonsAsync())
{
if (kvp.Key.ToUpperInvariant() == pokemon.ToUpperInvariant())
{
@@ -58,7 +52,7 @@ public partial class Searches
ability = ability?.Trim().ToUpperInvariant().Replace(" ", "", StringComparison.InvariantCulture);
if (string.IsNullOrWhiteSpace(ability))
return;
foreach (var kvp in PokemonAbilities)
foreach (var kvp in await _cache.GetPokemonAbilitiesAsync())
{
if (kvp.Key.ToUpperInvariant() == ability)
{

View File

@@ -10,14 +10,14 @@ public partial class Searches
public partial class SearchCommands : NadekoModule
{
private readonly ISearchServiceFactory _searchFactory;
private readonly ConnectionMultiplexer _redis;
private readonly IBotCache _cache;
public SearchCommands(
ISearchServiceFactory searchFactory,
ConnectionMultiplexer redis)
IBotCache cache)
{
_searchFactory = searchFactory;
_redis = redis;
_cache = cache;
}
[Cmd]
@@ -127,18 +127,17 @@ public partial class Searches
await ctx.Channel.EmbedAsync(null, embeds: embeds);
}
private TypedKey<string> GetYtCacheKey(string query)
=> new($"search:youtube:{query}");
private async Task AddYoutubeUrlToCacheAsync(string query, string url)
{
var db = _redis.GetDatabase();
await db.StringSetAsync($"search:youtube:{query}", url, expiry: 1.Hours());
}
=> await _cache.AddAsync(GetYtCacheKey(query), url, expiry: 1.Hours());
private async Task<VideoInfo?> GetYoutubeUrlFromCacheAsync(string query)
{
var db = _redis.GetDatabase();
var url = await db.StringGetAsync($"search:youtube:{query}");
var result = await _cache.GetAsync(GetYtCacheKey(query));
if (string.IsNullOrWhiteSpace(url))
if (!result.TryGetValue(out var url) || string.IsNullOrWhiteSpace(url))
return null;
return new VideoInfo()

View File

@@ -1,6 +1,4 @@
#nullable disable
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using Html2Markdown;
using NadekoBot.Modules.Searches.Common;
using Newtonsoft.Json;
@@ -10,7 +8,6 @@ using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using System.Net;
using Color = SixLabors.ImageSharp.Color;
using Image = SixLabors.ImageSharp.Image;
@@ -31,9 +28,9 @@ public class SearchesService : INService
private readonly IHttpClientFactory _httpFactory;
private readonly IGoogleApiService _google;
private readonly IImageCache _imgs;
private readonly IDataCache _cache;
private readonly IBotCache _c;
private readonly FontProvider _fonts;
private readonly IBotCredentials _creds;
private readonly IBotCredsProvider _creds;
private readonly NadekoRandom _rng;
private readonly List<string> _yomamaJokes;
@@ -42,15 +39,16 @@ public class SearchesService : INService
public SearchesService(
IGoogleApiService google,
IDataCache cache,
IImageCache images,
IBotCache c,
IHttpClientFactory factory,
FontProvider fonts,
IBotCredentials creds)
IBotCredsProvider creds)
{
_httpFactory = factory;
_google = google;
_imgs = cache.LocalImages;
_cache = cache;
_imgs = images;
_c = c;
_fonts = fonts;
_creds = creds;
_rng = new();
@@ -76,36 +74,28 @@ public class SearchesService : INService
}
public async Task<Stream> GetRipPictureAsync(string text, Uri imgUrl)
{
var data = await _cache.GetOrAddCachedDataAsync($"nadeko_rip_{text}_{imgUrl}",
GetRipPictureFactory,
(text, imgUrl),
TimeSpan.FromDays(1));
return data.ToStream();
}
=> (await GetRipPictureFactory(text, imgUrl)).ToStream();
private void DrawAvatar(Image bg, Image avatarImage)
=> bg.Mutate(x => x.Grayscale().DrawImage(avatarImage, new(83, 139), new GraphicsOptions()));
public async Task<byte[]> GetRipPictureFactory((string text, Uri avatarUrl) arg)
public async Task<byte[]> GetRipPictureFactory(string text, Uri avatarUrl)
{
var (text, avatarUrl) = arg;
using var bg = Image.Load<Rgba32>(_imgs.Rip.ToArray());
var (succ, data) = (false, (byte[])null); //await _cache.TryGetImageDataAsync(avatarUrl);
if (!succ)
using var bg = Image.Load<Rgba32>(await _imgs.GetRipBgAsync());
var result = await _c.GetImageDataAsync(avatarUrl);
if (!result.TryPickT0(out var data, out _))
{
using var http = _httpFactory.CreateClient();
data = await http.GetByteArrayAsync(avatarUrl);
using (var avatarImg = Image.Load<Rgba32>(data))
{
avatarImg.Mutate(x => x.Resize(85, 85).ApplyRoundedCorners(42));
await using var avStream = avatarImg.ToStream();
await using var avStream = await avatarImg.ToStreamAsync();
data = avStream.ToArray();
DrawAvatar(bg, avatarImg);
}
await _cache.SetImageDataAsync(avatarUrl, data);
await _c.SetImageDataAsync(avatarUrl, data);
}
else
{
@@ -128,7 +118,7 @@ public class SearchesService : INService
new(25, 225)));
//flowa
using (var flowers = Image.Load(_imgs.RipOverlay.ToArray()))
using (var flowers = Image.Load(await _imgs.GetRipOverlayAsync()))
{
bg.Mutate(x => x.DrawImage(flowers, new(0, 0), new GraphicsOptions()));
}
@@ -137,13 +127,12 @@ public class SearchesService : INService
return stream.ToArray();
}
public Task<WeatherData> GetWeatherDataAsync(string query)
public async Task<WeatherData> GetWeatherDataAsync(string query)
{
query = query.Trim().ToLowerInvariant();
return _cache.GetOrAddCachedDataAsync($"nadeko_weather_{query}",
GetWeatherDataFactory,
query,
return await _c.GetOrAddAsync(new($"nadeko_weather_{query}"),
async () => await GetWeatherDataFactory(query),
TimeSpan.FromHours(3));
}
@@ -184,26 +173,28 @@ public class SearchesService : INService
if (string.IsNullOrEmpty(query))
return (default, TimeErrors.InvalidInput);
if (string.IsNullOrWhiteSpace(_creds.LocationIqApiKey) || string.IsNullOrWhiteSpace(_creds.TimezoneDbApiKey))
var locIqKey = _creds.GetCreds().LocationIqApiKey;
var tzDbKey = _creds.GetCreds().TimezoneDbApiKey;
if (string.IsNullOrWhiteSpace(locIqKey) || string.IsNullOrWhiteSpace(tzDbKey))
return (default, TimeErrors.ApiKeyMissing);
try
{
using var http = _httpFactory.CreateClient();
var res = await _cache.GetOrAddCachedDataAsync($"geo_{query}",
_ =>
var res = await _c.GetOrAddAsync(new($"searches:geo:{query}"),
async () =>
{
var url = "https://eu1.locationiq.com/v1/search.php?"
+ (string.IsNullOrWhiteSpace(_creds.LocationIqApiKey)
+ (string.IsNullOrWhiteSpace(locIqKey)
? "key="
: $"key={_creds.LocationIqApiKey}&")
: $"key={locIqKey}&")
+ $"q={Uri.EscapeDataString(query)}&"
+ "format=json";
var res = http.GetStringAsync(url);
var res = await http.GetStringAsync(url);
return res;
},
"",
TimeSpan.FromHours(1));
var responses = JsonConvert.DeserializeObject<LocationIqResponse[]>(res);
@@ -217,7 +208,7 @@ public class SearchesService : INService
using var req = new HttpRequestMessage(HttpMethod.Get,
"http://api.timezonedb.com/v2.1/get-time-zone?"
+ $"key={_creds.TimezoneDbApiKey}"
+ $"key={tzDbKey}"
+ $"&format=json"
+ $"&by=position"
+ $"&lat={geoData.Lat}"
@@ -315,9 +306,8 @@ public class SearchesService : INService
public async Task<MtgData> GetMtgCardAsync(string search)
{
search = search.Trim().ToLowerInvariant();
var data = await _cache.GetOrAddCachedDataAsync($"nadeko_mtg_{search}",
GetMtgCardFactory,
search,
var data = await _c.GetOrAddAsync(new($"mtg:{search}"),
async () => await GetMtgCardFactory(search),
TimeSpan.FromDays(1));
if (data is null || data.Length == 0)
@@ -368,12 +358,11 @@ public class SearchesService : INService
return await cards.Select(GetMtgDataAsync).WhenAll();
}
public Task<HearthstoneCardData> GetHearthstoneCardDataAsync(string name)
public async Task<HearthstoneCardData> GetHearthstoneCardDataAsync(string name)
{
name = name.ToLowerInvariant();
return _cache.GetOrAddCachedDataAsync($"nadeko_hearthstone_{name}",
HearthstoneCardDataFactory,
name,
return await _c.GetOrAddAsync($"hearthstone:{name}",
() => HearthstoneCardDataFactory(name),
TimeSpan.FromDays(1));
}
@@ -381,7 +370,7 @@ public class SearchesService : INService
{
using var http = _httpFactory.CreateClient();
http.DefaultRequestHeaders.Clear();
http.DefaultRequestHeaders.Add("x-rapidapi-key", _creds.RapidApiKey);
http.DefaultRequestHeaders.Add("x-rapidapi-key", _creds.GetCreds().RapidApiKey);
try
{
var response = await http.GetStringAsync("https://omgvamp-hearthstone-v1.p.rapidapi.com/"
@@ -410,16 +399,22 @@ public class SearchesService : INService
}
}
public Task<OmdbMovie> GetMovieDataAsync(string name)
public async Task<OmdbMovie> GetMovieDataAsync(string name)
{
name = name.Trim().ToLowerInvariant();
return _cache.GetOrAddCachedDataAsync($"nadeko_movie_{name}", GetMovieDataFactory, name, TimeSpan.FromDays(1));
return await _c.GetOrAddAsync(new($"movie:{name}"),
() => GetMovieDataFactory(name),
TimeSpan.FromDays(1));
}
private async Task<OmdbMovie> GetMovieDataFactory(string name)
{
using var http = _httpFactory.CreateClient();
var res = await http.GetStringAsync(string.Format("https://omdbapi.nadeko.bot/?t={0}&y=&plot=full&r=json",
var res = await http.GetStringAsync(string.Format("https://omdbapi.nadeko.bot/"
+ "?t={0}"
+ "&y="
+ "&plot=full"
+ "&r=json",
name.Trim().Replace(' ', '+')));
var movie = JsonConvert.DeserializeObject<OmdbMovie>(res);
if (movie?.Title is null)
@@ -432,10 +427,11 @@ public class SearchesService : INService
{
const string steamGameIdsKey = "steam_names_to_appid";
var gamesMap = await _cache.GetOrAddCachedDataAsync(steamGameIdsKey,
async _ =>
var gamesMap = await _c.GetOrAddAsync(new(steamGameIdsKey),
async () =>
{
using var http = _httpFactory.CreateClient();
// https://api.steampowered.com/ISteamApps/GetAppList/v2/
var gamesStr = await http.GetStringAsync("https://api.steampowered.com/ISteamApps/GetAppList/v2/");
var apps = JsonConvert
@@ -446,23 +442,18 @@ public class SearchesService : INService
{
apps = new List<SteamGameId>()
}
})
})!
.applist.apps;
return apps.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.GroupBy(x => x.Name)
.ToDictionary(x => x.Key, x => x.First().AppId);
//await db.HashSetAsync("steam_game_ids", apps.Select(app => new HashEntry(app.Name.Trim().ToLowerInvariant(), app.AppId)).ToArray());
//await db.StringSetAsync("steam_game_ids", gamesStr, TimeSpan.FromHours(24));
//await db.KeyExpireAsync("steam_game_ids", TimeSpan.FromHours(24), CommandFlags.FireAndForget);
},
default(string),
TimeSpan.FromHours(24));
if (gamesMap is null)
return -1;
query = query.Trim();
var keyList = gamesMap.Keys.ToList();

View File

@@ -1,4 +1,6 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
@@ -6,10 +8,98 @@ using NadekoBot.Db.Models;
using NadekoBot.Modules.Searches.Common;
using NadekoBot.Modules.Searches.Common.StreamNotifications;
using NadekoBot.Services.Database.Models;
using StackExchange.Redis;
namespace NadekoBot.Modules.Searches.Services;
public sealed class StreamOnlineMessageDeleterService : INService, IReadyExecutor
{
private readonly StreamNotificationService _notifService;
private readonly DbService _db;
private readonly DiscordSocketClient _client;
private readonly IPubSub _pubSub;
public StreamOnlineMessageDeleterService(
StreamNotificationService notifService,
DbService db,
IPubSub pubSub,
DiscordSocketClient client)
{
_notifService = notifService;
_db = db;
_client = client;
_pubSub = pubSub;
}
public async Task OnReadyAsync()
{
_notifService.OnlineMessagesSent += OnOnlineMessagesSent;
if(_client.ShardId == 0)
await _pubSub.Sub(_notifService.StreamsOfflineKey, OnStreamsOffline);
}
private async Task OnOnlineMessagesSent(FollowedStream.FType type, string name, IReadOnlyCollection<(ulong, ulong)> pairs)
{
await using var ctx = _db.GetDbContext();
foreach (var (channelId, messageId) in pairs)
{
await ctx.GetTable<StreamOnlineMessage>()
.InsertAsync(() => new()
{
Name = name,
Type = type,
MessageId = messageId,
ChannelId = channelId,
DateAdded = DateTime.UtcNow,
});
}
}
private async ValueTask OnStreamsOffline(List<StreamData> streamDatas)
{
if (_client.ShardId != 0)
return;
var pairs = await GetMessagesToDelete(streamDatas);
foreach (var (channelId, messageId) in pairs)
{
try
{
var textChannel = await _client.GetChannelAsync(channelId) as ITextChannel;
if (textChannel is null)
continue;
await textChannel.DeleteMessageAsync(messageId);
}
catch
{
continue;
}
}
}
private async Task<IEnumerable<(ulong, ulong)>> GetMessagesToDelete(List<StreamData> streamDatas)
{
await using var ctx = _db.GetDbContext();
var toReturn = new List<(ulong, ulong)>();
foreach (var sd in streamDatas)
{
var key = sd.CreateKey();
var toDelete = await ctx.GetTable<StreamOnlineMessage>()
.Where(x => (x.Type == key.Type && x.Name == key.Name)
|| Sql.DateDiff(Sql.DateParts.Day, x.DateAdded, DateTime.UtcNow) > 1)
.DeleteWithOutputAsync();
toReturn.AddRange(toDelete.Select(x => (x.ChannelId, x.MessageId)));
}
return toReturn;
}
}
public sealed class StreamNotificationService : INService, IReadyExecutor
{
private readonly DbService _db;
@@ -29,18 +119,22 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
private readonly IPubSub _pubSub;
private readonly IEmbedBuilderService _eb;
private readonly TypedKey<List<StreamData>> _streamsOnlineKey;
private readonly TypedKey<List<StreamData>> _streamsOfflineKey;
public TypedKey<List<StreamData>> StreamsOnlineKey { get; }
public TypedKey<List<StreamData>> StreamsOfflineKey { get; }
private readonly TypedKey<FollowStreamPubData> _streamFollowKey;
private readonly TypedKey<FollowStreamPubData> _streamUnfollowKey;
private readonly ConnectionMultiplexer _redis;
public event Func<
FollowedStream.FType,
string,
IReadOnlyCollection<(ulong, ulong)>,
Task> OnlineMessagesSent = static delegate { return Task.CompletedTask; };
public StreamNotificationService(
DbService db,
DiscordSocketClient client,
IBotStrings strings,
ConnectionMultiplexer redis,
IBotCredsProvider creds,
IHttpClientFactory httpFactory,
Bot bot,
@@ -52,11 +146,11 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
_strings = strings;
_pubSub = pubSub;
_eb = eb;
_redis = redis;
_streamTracker = new(httpFactory, creds, redis, creds.GetCreds().RedisKey(), client.ShardId == 0);
_streamTracker = new(httpFactory, creds);
_streamsOnlineKey = new("streams.online");
_streamsOfflineKey = new("streams.offline");
StreamsOnlineKey = new("streams.online");
StreamsOfflineKey = new("streams.offline");
_streamFollowKey = new("stream.follow");
_streamUnfollowKey = new("stream.unfollow");
@@ -100,7 +194,7 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
var allFollowedStreams = uow.Set<FollowedStream>().AsQueryable().ToList();
foreach (var fs in allFollowedStreams)
_streamTracker.CacheAddData(fs.CreateKey(), null, false);
_streamTracker.AddLastData(fs.CreateKey(), null, false);
_trackCounter = allFollowedStreams.GroupBy(x => new
{
@@ -112,8 +206,8 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
}
}
_pubSub.Sub(_streamsOfflineKey, HandleStreamsOffline);
_pubSub.Sub(_streamsOnlineKey, HandleStreamsOnline);
_pubSub.Sub(StreamsOfflineKey, HandleStreamsOffline);
_pubSub.Sub(StreamsOnlineKey, HandleStreamsOnline);
if (client.ShardId == 0)
{
@@ -186,7 +280,7 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
/// </summary>
private ValueTask HandleFollowStream(FollowStreamPubData info)
{
_streamTracker.CacheAddData(info.Key, null, false);
_streamTracker.AddLastData(info.Key, null, false);
lock (_shardLock)
{
var key = info.Key;
@@ -251,45 +345,8 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
.WhenAll();
}
}
if (_client.ShardId == 0)
{
foreach (var stream in offlineStreams)
{
await DeleteOnlineMessages(stream);
}
}
}
private async Task DeleteOnlineMessages(StreamData stream)
{
var db = _redis.GetDatabase();
var data = await db.ListRangeAsync($"streams_online_del:{stream.CreateKey()}");
await db.KeyDeleteAsync($"streams_online_del:{stream.CreateKey()}");
foreach (string pair in data)
{
var pairArr = pair.Split(',');
if (pairArr.Length != 2)
continue;
if (!ulong.TryParse(pairArr[0], out var chId) || !ulong.TryParse(pairArr[1], out var msgId))
continue;
try
{
var textChannel = await _client.GetChannelAsync(chId) as ITextChannel;
if (textChannel is null)
continue;
await textChannel.DeleteMessageAsync(msgId);
}
catch
{
continue;
}
}
}
private async ValueTask HandleStreamsOnline(List<StreamData> onlineStreams)
{
@@ -331,14 +388,11 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
{
var pairs = messages
.Where(x => x != default)
.Select(x => (RedisValue)$"{x.Item1},{x.Item2}")
.ToArray();
.Select(x => (x.Item1, x.Item2))
.ToList();
if (pairs.Length > 0)
{
var db = _redis.GetDatabase();
await db.ListRightPushAsync($"streams_online_del:{key}", pairs);
}
if (pairs.Count > 0)
await OnlineMessagesSent(key.Type, key.Name, pairs);
}
catch
{
@@ -349,10 +403,10 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
}
private Task OnStreamsOnline(List<StreamData> data)
=> _pubSub.Pub(_streamsOnlineKey, data);
=> _pubSub.Pub(StreamsOnlineKey, data);
private Task OnStreamsOffline(List<StreamData> data)
=> _pubSub.Pub(_streamsOfflineKey, data);
=> _pubSub.Pub(StreamsOfflineKey, data);
private Task ClientOnJoinedGuild(GuildConfig guildConfig)
{

View File

@@ -6,10 +6,9 @@ namespace NadekoBot.Modules.Searches.Common;
public readonly struct StreamDataKey
{
public FollowedStream.FType Type { get; }
public string Name { get; }
public FollowedStream.FType Type { get; init; }
public string Name { get; init; }
[JsonConstructor]
public StreamDataKey(FollowedStream.FType type, string name)
{
Type = type;

View File

@@ -1,7 +1,5 @@
using NadekoBot.Db.Models;
using NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
using Newtonsoft.Json;
using StackExchange.Redis;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications;
@@ -9,30 +7,22 @@ public class NotifChecker
{
public event Func<List<StreamData>, Task> OnStreamsOffline = _ => Task.CompletedTask;
public event Func<List<StreamData>, Task> OnStreamsOnline = _ => Task.CompletedTask;
private readonly ConnectionMultiplexer _multi;
private readonly string _key;
private readonly Dictionary<FollowedStream.FType, Provider> _streamProviders;
private readonly IReadOnlyDictionary<FollowedStream.FType, Provider> _streamProviders;
private readonly HashSet<(FollowedStream.FType, string)> _offlineBuffer;
private readonly ConcurrentDictionary<StreamDataKey, StreamData?> _cache = new();
public NotifChecker(
IHttpClientFactory httpClientFactory,
IBotCredsProvider credsProvider,
ConnectionMultiplexer multi,
string uniqueCacheKey,
bool isMaster)
IBotCredsProvider credsProvider)
{
_multi = multi;
_key = $"{uniqueCacheKey}_followed_streams_data";
_streamProviders = new()
_streamProviders = new Dictionary<FollowedStream.FType, Provider>()
{
{ FollowedStream.FType.Twitch, new TwitchHelixProvider(httpClientFactory, credsProvider) },
{ FollowedStream.FType.Picarto, new PicartoProvider(httpClientFactory) },
{ FollowedStream.FType.Trovo, new TrovoProvider(httpClientFactory, credsProvider) }
};
_offlineBuffer = new();
if (isMaster)
CacheClearAllData();
}
// gets all streams which have been failing for more than the provided timespan
@@ -61,7 +51,7 @@ public class NotifChecker
{
try
{
var allStreamData = CacheGetAllData();
var allStreamData = GetAllData();
var oldStreamDataDict = allStreamData
// group by type
@@ -101,7 +91,7 @@ public class NotifChecker
|| !typeDict.TryGetValue(key.Name, out var oldData)
|| oldData is null)
{
CacheAddData(key, newData, true);
AddLastData(key, newData, true);
continue;
}
@@ -109,7 +99,7 @@ public class NotifChecker
if (string.IsNullOrWhiteSpace(newData.Game))
newData.Game = oldData.Game;
CacheAddData(key, newData, true);
AddLastData(key, newData, true);
// if the stream is offline, we need to check if it was
// marked as offline once previously
@@ -158,39 +148,22 @@ public class NotifChecker
}
});
public bool CacheAddData(StreamDataKey key, StreamData? data, bool replace)
public bool AddLastData(StreamDataKey key, StreamData? data, bool replace)
{
var db = _multi.GetDatabase();
return db.HashSet(_key,
JsonConvert.SerializeObject(key),
JsonConvert.SerializeObject(data),
replace ? When.Always : When.NotExists);
if (replace)
{
_cache[key] = data;
return true;
}
return _cache.TryAdd(key, data);
}
public void CacheDeleteData(StreamDataKey key)
{
var db = _multi.GetDatabase();
db.HashDelete(_key, JsonConvert.SerializeObject(key));
}
public void DeleteLastData(StreamDataKey key)
=> _cache.TryRemove(key, out _);
public void CacheClearAllData()
{
var db = _multi.GetDatabase();
db.KeyDelete(_key);
}
public Dictionary<StreamDataKey, StreamData?> CacheGetAllData()
{
var db = _multi.GetDatabase();
if (!db.KeyExists(_key))
return new();
return db.HashGetAll(_key)
.ToDictionary(entry => JsonConvert.DeserializeObject<StreamDataKey>(entry.Name),
entry => entry.Value.IsNullOrEmpty
? default
: JsonConvert.DeserializeObject<StreamData>(entry.Value));
}
public Dictionary<StreamDataKey, StreamData?> GetAllData()
=> _cache.ToDictionary(x => x.Key, x => x.Value);
public async Task<StreamData?> GetStreamDataByUrlAsync(string url)
{
@@ -234,9 +207,9 @@ public class NotifChecker
// if stream is found, add it to the cache for tracking only if it doesn't already exist
// because stream will be checked and events will fire in a loop. We don't want to override old state
return CacheAddData(data.CreateKey(), data, false);
return AddLastData(data.CreateKey(), data, false);
}
public void UntrackStreamByKey(in StreamDataKey key)
=> CacheDeleteData(key);
=> DeleteLastData(key);
}

View File

@@ -31,9 +31,10 @@ public sealed class PatronageService
private readonly DiscordSocketClient _client;
private readonly ISubscriptionHandler _subsHandler;
private readonly IEmbedBuilderService _eb;
private readonly ConnectionMultiplexer _redis;
private readonly IBotCredentials _creds;
private readonly TypedKey<bool> _quotaKey;
private static readonly TypedKey<long> _quotaKey
= new($"quota:last_hourly_reset");
private readonly IBotCache _cache;
public PatronageService(
PatronageConfig pConf,
@@ -41,18 +42,14 @@ public sealed class PatronageService
DiscordSocketClient client,
ISubscriptionHandler subsHandler,
IEmbedBuilderService eb,
ConnectionMultiplexer redis,
IBotCredentials creds)
IBotCache cache)
{
_pConf = pConf;
_db = db;
_client = client;
_subsHandler = subsHandler;
_eb = eb;
_redis = redis;
_creds = creds;
_quotaKey = new TypedKey<bool>($"{_creds.RedisKey()}:quota:last_hourly_reset");
_cache = cache;
}
public Task OnReadyAsync()
@@ -101,11 +98,10 @@ public sealed class PatronageService
var now = DateTime.UtcNow;
var lastRun = DateTime.MinValue;
var rdb = _redis.GetDatabase();
var lastVal = await rdb.StringGetAsync(_quotaKey.Key);
if (lastVal != default)
var result = await _cache.GetAsync(_quotaKey);
if (result.TryGetValue(out var lastVal) && lastVal != default)
{
lastRun = DateTime.FromBinary((long)lastVal);
lastRun = DateTime.FromBinary(lastVal);
}
var nowDate = now.ToDateOnly();
@@ -130,8 +126,6 @@ public sealed class PatronageService
HourlyCount = 0,
DailyCount = 0,
});
await rdb.StringSetAsync(_quotaKey.Key, true);
}
else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas
{
@@ -143,7 +137,7 @@ public sealed class PatronageService
}
// assumes that the code above runs in less than an hour
await rdb.StringSetAsync(_quotaKey.Key, now.ToBinary());
await _cache.AddAsync(_quotaKey, now.ToBinary());
await tran.CommitAsync();
}
catch (Exception ex)

View File

@@ -1,23 +1,24 @@
#nullable disable
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Modules.Utility.Common;
using Newtonsoft.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Utility.Services;
public class ConverterService : INService, IReadyExecutor
{
public ConvertUnit[] Units
=> _cache.Redis.GetDatabase().StringGet("converter_units").ToString().MapJson<ConvertUnit[]>();
private static readonly TypedKey<List<ConvertUnit>> _convertKey =
new("convert:units");
private readonly TimeSpan _updateInterval = new(12, 0, 0);
private readonly DiscordSocketClient _client;
private readonly IDataCache _cache;
private readonly IBotCache _cache;
private readonly IHttpClientFactory _httpFactory;
public ConverterService(
DiscordSocketClient client,
IDataCache cache,
IBotCache cache,
IHttpClientFactory factory)
{
_client = client;
@@ -48,7 +49,7 @@ public class ConverterService : INService, IReadyExecutor
{
using var http = _httpFactory.CreateClient();
var res = await http.GetStringAsync("https://convertapi.nadeko.bot/latest");
return JsonConvert.DeserializeObject<Rates>(res);
return JsonSerializer.Deserialize<Rates>(res);
}
private async Task UpdateCurrency()
@@ -61,29 +62,38 @@ public class ConverterService : INService, IReadyExecutor
Modifier = decimal.One,
UnitType = unitTypeString
};
var range = currencyRates.ConversionRates.Select(u => new ConvertUnit
var units = currencyRates.ConversionRates.Select(u => new ConvertUnit
{
Triggers = new[] { u.Key },
Modifier = u.Value,
UnitType = unitTypeString
})
.ToArray();
.ToList();
var fileData = JsonConvert.DeserializeObject<ConvertUnit[]>(File.ReadAllText("data/units.json"))
?.Where(x => x.UnitType != "currency");
if (fileData is null)
return;
var data = JsonConvert.SerializeObject(range.Append(baseType).Concat(fileData).ToList());
_cache.Redis.GetDatabase().StringSet("converter_units", data);
var stream = File.OpenRead("data/units.json");
var defaultUnits = await JsonSerializer.DeserializeAsync<ConvertUnit[]>(stream);
if(defaultUnits is not null)
units.AddRange(defaultUnits);
units.Add(baseType);
await _cache.AddAsync(_convertKey, units);
}
public async Task<IReadOnlyList<ConvertUnit>> GetUnitsAsync()
=> (await _cache.GetAsync(_convertKey)).TryGetValue(out var list)
? list
: Array.Empty<ConvertUnit>();
}
public class Rates
{
[JsonPropertyName("base")]
public string Base { get; set; }
[JsonPropertyName("date")]
public DateTime Date { get; set; }
[JsonProperty("rates")]
[JsonPropertyName("rates")]
public Dictionary<string, decimal> ConversionRates { get; set; }
}

View File

@@ -11,7 +11,7 @@ public partial class Utility
[Cmd]
public async partial Task ConvertList()
{
var units = _service.Units;
var units = await _service.GetUnitsAsync();
var embed = _eb.Create().WithTitle(GetText(strs.convertlist)).WithOkColor();
@@ -29,9 +29,10 @@ public partial class Utility
[Priority(0)]
public async partial Task Convert(string origin, string target, decimal value)
{
var originUnit = _service.Units.FirstOrDefault(x
var units = await _service.GetUnitsAsync();
var originUnit = units.FirstOrDefault(x
=> x.Triggers.Select(y => y.ToUpperInvariant()).Contains(origin.ToUpperInvariant()));
var targetUnit = _service.Units.FirstOrDefault(x
var targetUnit = units.FirstOrDefault(x
=> x.Triggers.Select(y => y.ToUpperInvariant()).Contains(target.ToUpperInvariant()));
if (originUnit is null || targetUnit is null)
{

View File

@@ -23,10 +23,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
public const int XP_REQUIRED_LVL_1 = 36;
private readonly DbService _db;
private readonly CommandHandler _cmd;
private readonly IImageCache _images;
private readonly IBotStrings _strings;
private readonly IDataCache _cache;
private readonly FontProvider _fonts;
private readonly IBotCredentials _creds;
private readonly ICurrencyService _cs;
@@ -45,14 +43,15 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
private readonly TypedKey<bool> _xpTemplateReloadKey;
private readonly IPatronageService _ps;
private readonly IBotCache _c;
public XpService(
DiscordSocketClient client,
CommandHandler cmd,
Bot bot,
DbService db,
IBotStrings strings,
IDataCache cache,
IImageCache images,
IBotCache c,
FontProvider fonts,
IBotCredentials creds,
ICurrencyService cs,
@@ -63,10 +62,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
IPatronageService ps)
{
_db = db;
_cmd = cmd;
_images = cache.LocalImages;
_images = images;
_strings = strings;
_cache = cache;
_fonts = fonts;
_creds = creds;
_cs = cs;
@@ -79,6 +76,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
_client = client;
_xpTemplateReloadKey = new("xp.template.reload");
_ps = ps;
_c = c;
InternalReloadXpTemplate();
@@ -453,10 +451,10 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
private Task Client_OnGuildAvailable(SocketGuild guild)
{
Task.Run(() =>
Task.Run(async () =>
{
foreach (var channel in guild.VoiceChannels)
ScanChannelForVoiceXp(channel);
await ScanChannelForVoiceXp(channel);
});
return Task.CompletedTask;
@@ -467,33 +465,33 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
if (socketUser is not SocketGuildUser user || user.IsBot)
return Task.CompletedTask;
_ = Task.Run(() =>
_ = Task.Run(async () =>
{
if (before.VoiceChannel is not null)
ScanChannelForVoiceXp(before.VoiceChannel);
await ScanChannelForVoiceXp(before.VoiceChannel);
if (after.VoiceChannel is not null && after.VoiceChannel != before.VoiceChannel)
ScanChannelForVoiceXp(after.VoiceChannel);
await ScanChannelForVoiceXp(after.VoiceChannel);
else if (after.VoiceChannel is 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.
UserLeftVoiceChannel(user, before.VoiceChannel);
await UserLeftVoiceChannel(user, before.VoiceChannel);
});
return Task.CompletedTask;
}
private void ScanChannelForVoiceXp(SocketVoiceChannel channel)
private async Task ScanChannelForVoiceXp(SocketVoiceChannel channel)
{
if (ShouldTrackVoiceChannel(channel))
{
foreach (var user in channel.Users)
ScanUserForVoiceXp(user, channel);
await ScanUserForVoiceXp(user, channel);
}
else
{
foreach (var user in channel.Users)
UserLeftVoiceChannel(user, channel);
await UserLeftVoiceChannel(user, channel);
}
}
@@ -502,12 +500,12 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
/// </summary>
/// <param name="user"></param>
/// <param name="channel"></param>
private void ScanUserForVoiceXp(SocketGuildUser user, SocketVoiceChannel channel)
private async Task ScanUserForVoiceXp(SocketGuildUser user, SocketVoiceChannel channel)
{
if (UserParticipatingInVoiceChannel(user) && ShouldTrackXp(user, channel.Id))
UserJoinedVoiceChannel(user);
await UserJoinedVoiceChannel(user);
else
UserLeftVoiceChannel(user, channel);
await UserLeftVoiceChannel(user, channel);
}
private bool ShouldTrackVoiceChannel(SocketVoiceChannel channel)
@@ -516,32 +514,31 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
private bool UserParticipatingInVoiceChannel(SocketGuildUser user)
=> !user.IsDeafened && !user.IsMuted && !user.IsSelfDeafened && !user.IsSelfMuted;
private void UserJoinedVoiceChannel(SocketGuildUser user)
private TypedKey<long> GetVoiceXpKey(ulong userId)
=> new($"xp:vc_join:{userId}");
private async Task 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);
await _c.AddAsync(GetVoiceXpKey(user.Id),
value,
TimeSpan.FromMinutes(_xpConfig.Data.VoiceMaxMinutes),
overwrite: false);
}
private void UserLeftVoiceChannel(SocketGuildUser user, SocketVoiceChannel channel)
private async Task 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);
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 (value.IsNull)
if (!result.TryGetValue(out var unixTime))
return;
if (!value.TryParse(out long startUnixTime))
return;
var dateStart = DateTimeOffset.FromUnixTimeSeconds(startUnixTime);
var dateStart = DateTimeOffset.FromUnixTimeSeconds(unixTime);
var dateEnd = DateTimeOffset.UtcNow;
var minutes = (dateEnd - dateStart).TotalMinutes;
var xp = _xpConfig.Data.VoiceXpPerMinute * minutes;
@@ -577,7 +574,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
if (arg.Author is not SocketGuildUser user || user.IsBot)
return Task.CompletedTask;
_ = Task.Run(() =>
_ = Task.Run(async () =>
{
if (!ShouldTrackXp(user, arg.Channel.Id))
return;
@@ -593,7 +590,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
if (xp <= 0)
return;
if (!SetUserRewarded(user.Id))
if (!await SetUserRewardedAsync(user.Id))
return;
_addMessageXp.Enqueue(new()
@@ -650,16 +647,14 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
return Enumerable.Empty<ulong>();
}
private bool SetUserRewarded(ulong userId)
{
var r = _cache.Redis.GetDatabase();
var key = $"{_creds.RedisKey()}_user_xp_gain_{userId}";
private static TypedKey<bool> GetUserRewKey(ulong userId)
=> new($"xp:user_gain:{userId}");
return r.StringSet(key,
private async Task<bool> SetUserRewardedAsync(ulong userId)
=> await _c.AddAsync(GetUserRewKey(userId),
true,
TimeSpan.FromMinutes(_xpConfig.Data.MessageXpCooldown),
when: When.NotExists);
}
expiry: TimeSpan.FromMinutes(_xpConfig.Data.MessageXpCooldown),
overwrite: false);
public async Task<FullUserStats> GetUserStatsAsync(IGuildUser user)
{
@@ -782,7 +777,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
}
}.WithFallbackFonts(_fonts.FallBackFonts);
using var img = Image.Load<Rgba32>(_images.XpBackground, out var imageFormat);
using var img = Image.Load<Rgba32>(await GetXpBackgroundAsync(stats.User.UserId), out var imageFormat);
if (template.User.Name.Show)
{
var fontSize = (int)(template.User.Name.FontSize * 0.9);
@@ -979,8 +974,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
{
var avatarUrl = stats.User.RealAvatarUrl();
var (succ, data) = await _cache.TryGetImageDataAsync(avatarUrl);
if (!succ)
var result = await _c.GetImageDataAsync(avatarUrl);
if (!result.TryPickT0(out var data, out _))
{
using (var http = _httpFactory.CreateClient())
{
@@ -999,7 +994,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
}
}
await _cache.SetImageDataAsync(avatarUrl, data);
await _c.SetImageDataAsync(avatarUrl, data);
}
using var toDraw = Image.Load(data);
@@ -1033,7 +1028,13 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
return output;
});
// #if GLOBAL_NADEKO
private async Task<byte[]> GetXpBackgroundAsync(ulong userId)
{
var img = await _images.GetXpBackgroundImageAsync();
return img;
}
// #if GLOBAL_NADEKO
private async Task DrawFrame(Image<Rgba32> img, ulong userId)
{
var patron = await _ps.GetPatronAsync(userId);
@@ -1103,8 +1104,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
try
{
var imgUrl = new Uri(stats.User.Club.ImageUrl);
var (succ, data) = await _cache.TryGetImageDataAsync(imgUrl);
if (!succ)
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))
@@ -1127,7 +1128,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
}
}
await _cache.SetImageDataAsync(imgUrl, data);
await _c.SetImageDataAsync(imgUrl, data);
}
using var toDraw = Image.Load(data);