mirror of
				https://gitlab.com/Kwoth/nadekobot.git
				synced 2025-11-03 16:24:27 -05:00 
			
		
		
		
	Abstract away cache. 2 implementations: redis and memory
This commit is contained in:
		
							
								
								
									
										20
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								CHANGELOG.md
									
									
									
									
									
								
							@@ -3,6 +3,26 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
 | 
					Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Unreleased
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Changed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- New cache abstraction added
 | 
				
			||||||
 | 
					  - 2 implemenations: redis and memory 
 | 
				
			||||||
 | 
					  - All current bots will stay on redis cache, all new bots will use in-process memory cache
 | 
				
			||||||
 | 
					  - This change removes bot's hard dependency on redis
 | 
				
			||||||
 | 
					  - Configurable in `creds.yml` (please read the comments)
 | 
				
			||||||
 | 
					  - You **MUST** use 'redis' if your bot runs on more than 1 shard (2000+ servers)
 | 
				
			||||||
 | 
					- [dev] Using new non-locking ConcurrentDictionary
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Fixed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `.xp` will now show default user avatars too
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Removed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Removed `.imagesreload` as images are now lazily loaded on request and then cached
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## [4.2.6] - 22.06.2022
 | 
					## [4.2.6] - 22.06.2022
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Fixed
 | 
					### Fixed
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,7 +21,7 @@ namespace NadekoBot.Tests
 | 
				
			|||||||
            var stringsSource = new LocalFileStringsSource(
 | 
					            var stringsSource = new LocalFileStringsSource(
 | 
				
			||||||
                responsesPath,
 | 
					                responsesPath,
 | 
				
			||||||
                commandsPath);
 | 
					                commandsPath);
 | 
				
			||||||
            var strings = new LocalBotStringsProvider(stringsSource);
 | 
					            var strings = new MemoryBotStringsProvider(stringsSource);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            var culture = new CultureInfo("en-US");
 | 
					            var culture = new CultureInfo("en-US");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -102,20 +102,20 @@ public sealed class Bot
 | 
				
			|||||||
        var svcs = new ServiceCollection().AddTransient(_ => _credsProvider.GetCreds()) // bot creds
 | 
					        var svcs = new ServiceCollection().AddTransient(_ => _credsProvider.GetCreds()) // bot creds
 | 
				
			||||||
                                          .AddSingleton(_credsProvider)
 | 
					                                          .AddSingleton(_credsProvider)
 | 
				
			||||||
                                          .AddSingleton(_db) // database
 | 
					                                          .AddSingleton(_db) // database
 | 
				
			||||||
                                          .AddRedis(_creds.RedisOptions) // redis
 | 
					 | 
				
			||||||
                                          .AddSingleton(Client) // discord socket client
 | 
					                                          .AddSingleton(Client) // discord socket client
 | 
				
			||||||
                                          .AddSingleton(_commandService)
 | 
					                                          .AddSingleton(_commandService)
 | 
				
			||||||
                                          // .AddSingleton(_interactionService)
 | 
					                                          // .AddSingleton(_interactionService)
 | 
				
			||||||
                                          .AddSingleton(this)
 | 
					                                          .AddSingleton(this)
 | 
				
			||||||
                                          .AddSingleton<ISeria, JsonSeria>()
 | 
					                                          .AddSingleton<ISeria, JsonSeria>()
 | 
				
			||||||
                                          .AddSingleton<IPubSub, RedisPubSub>()
 | 
					 | 
				
			||||||
                                          .AddSingleton<IConfigSeria, YamlSeria>()
 | 
					                                          .AddSingleton<IConfigSeria, YamlSeria>()
 | 
				
			||||||
                                          .AddBotStringsServices(_creds.TotalShards)
 | 
					 | 
				
			||||||
                                          .AddConfigServices()
 | 
					                                          .AddConfigServices()
 | 
				
			||||||
                                          .AddConfigMigrators()
 | 
					                                          .AddConfigMigrators()
 | 
				
			||||||
                                          .AddMemoryCache()
 | 
					                                          .AddMemoryCache()
 | 
				
			||||||
                                          // music
 | 
					                                          // music
 | 
				
			||||||
                                          .AddMusic();
 | 
					                                          .AddMusic()
 | 
				
			||||||
 | 
					                                          // cache
 | 
				
			||||||
 | 
					                                          .AddCache(_creds);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
        // admin
 | 
					        // admin
 | 
				
			||||||
#if GLOBAL_NADEKO
 | 
					#if GLOBAL_NADEKO
 | 
				
			||||||
        svcs.AddSingleton<ILogCommandService, DummyLogCommandService>();
 | 
					        svcs.AddSingleton<ILogCommandService, DummyLogCommandService>();
 | 
				
			||||||
@@ -143,13 +143,6 @@ public sealed class Bot
 | 
				
			|||||||
                .AddSingleton<IReadyExecutor>(x => x.GetRequiredService<RemoteGrpcCoordinator>());
 | 
					                .AddSingleton<IReadyExecutor>(x => x.GetRequiredService<RemoteGrpcCoordinator>());
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        svcs.AddSingleton<RedisLocalDataCache>()
 | 
					 | 
				
			||||||
            .AddSingleton<ILocalDataCache>(x => x.GetRequiredService<RedisLocalDataCache>())
 | 
					 | 
				
			||||||
            .AddSingleton<RedisImagesCache>()
 | 
					 | 
				
			||||||
            .AddSingleton<IImageCache>(x => x.GetRequiredService<RedisImagesCache>())
 | 
					 | 
				
			||||||
            .AddSingleton<IReadyExecutor>(x => x.GetRequiredService<RedisImagesCache>())
 | 
					 | 
				
			||||||
            .AddSingleton<IDataCache, RedisCache>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        svcs.Scan(scan => scan.FromAssemblyOf<IReadyExecutor>()
 | 
					        svcs.Scan(scan => scan.FromAssemblyOf<IReadyExecutor>()
 | 
				
			||||||
                              .AddClasses(classes => classes.AssignableToAny(
 | 
					                              .AddClasses(classes => classes.AssignableToAny(
 | 
				
			||||||
                                      // services
 | 
					                                      // services
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,22 +15,24 @@ public sealed class RatelimitAttribute : PreconditionAttribute
 | 
				
			|||||||
        Seconds = seconds;
 | 
					        Seconds = seconds;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public override Task<PreconditionResult> CheckPermissionsAsync(
 | 
					    public override async Task<PreconditionResult> CheckPermissionsAsync(
 | 
				
			||||||
        ICommandContext context,
 | 
					        ICommandContext context,
 | 
				
			||||||
        CommandInfo command,
 | 
					        CommandInfo command,
 | 
				
			||||||
        IServiceProvider services)
 | 
					        IServiceProvider services)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (Seconds == 0)
 | 
					        if (Seconds == 0)
 | 
				
			||||||
            return Task.FromResult(PreconditionResult.FromSuccess());
 | 
					            return PreconditionResult.FromSuccess();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var cache = services.GetRequiredService<IDataCache>();
 | 
					        var cache = services.GetRequiredService<IBotCache>();
 | 
				
			||||||
        var rem = cache.TryAddRatelimit(context.User.Id, command.Name, Seconds);
 | 
					        var rem = await cache.GetRatelimitAsync(
 | 
				
			||||||
 | 
					            new($"precondition:{context.User.Id}:{command.Name}"),
 | 
				
			||||||
 | 
					            Seconds.Seconds());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (rem is null)
 | 
					        if (rem is null)
 | 
				
			||||||
            return Task.FromResult(PreconditionResult.FromSuccess());
 | 
					            return PreconditionResult.FromSuccess();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s.";
 | 
					        var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s.";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return Task.FromResult(PreconditionResult.FromError(msgContent));
 | 
					        return PreconditionResult.FromError(msgContent);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										46
									
								
								src/NadekoBot/Common/Cache/BotCacheExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/NadekoBot/Common/Cache/BotCacheExtensions.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
				
			|||||||
 | 
					using OneOf;
 | 
				
			||||||
 | 
					using OneOf.Types;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace NadekoBot.Common;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public static class BotCacheExtensions
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public static async ValueTask<T?> GetOrDefaultAsync<T>(this IBotCache cache, TypedKey<T> key)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var result = await cache.GetAsync(key);
 | 
				
			||||||
 | 
					        if (result.TryGetValue(out var val))
 | 
				
			||||||
 | 
					            return val;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return default;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    private static TypedKey<byte[]> GetImgKey(Uri uri)
 | 
				
			||||||
 | 
					        => new($"image:{uri}");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static ValueTask SetImageDataAsync(this IBotCache c, string key, byte[] data)
 | 
				
			||||||
 | 
					        => c.SetImageDataAsync(new Uri(key), data);
 | 
				
			||||||
 | 
					    public static async ValueTask SetImageDataAsync(this IBotCache c, Uri key, byte[] data)
 | 
				
			||||||
 | 
					        => await c.AddAsync(GetImgKey(key), data, expiry: TimeSpan.FromHours(48));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static async ValueTask<OneOf<byte[], None>> GetImageDataAsync(this IBotCache c, Uri key)
 | 
				
			||||||
 | 
					        => await c.GetAsync(GetImgKey(key));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static async Task<TimeSpan?> GetRatelimitAsync(
 | 
				
			||||||
 | 
					        this IBotCache c,
 | 
				
			||||||
 | 
					        TypedKey<long> key,
 | 
				
			||||||
 | 
					        TimeSpan length)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var now = DateTime.UtcNow;
 | 
				
			||||||
 | 
					        var nowB = now.ToBinary();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var cachedValue = await c.GetOrAddAsync(key,
 | 
				
			||||||
 | 
					            () => Task.FromResult(now.ToBinary()),
 | 
				
			||||||
 | 
					            expiry: length);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (cachedValue == nowB)
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var diff = now - DateTime.FromBinary(cachedValue);
 | 
				
			||||||
 | 
					        return length - diff;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										47
									
								
								src/NadekoBot/Common/Cache/IBotCache.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/NadekoBot/Common/Cache/IBotCache.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					using OneOf;
 | 
				
			||||||
 | 
					using OneOf.Types;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace NadekoBot.Common;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public interface IBotCache
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Adds an item to the cache
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    /// <param name="key">Key to add</param>
 | 
				
			||||||
 | 
					    /// <param name="value">Value to add to the cache</param>
 | 
				
			||||||
 | 
					    /// <param name="expiry">Optional expiry</param>
 | 
				
			||||||
 | 
					    /// <param name="overwrite">Whether old value should be overwritten</param>
 | 
				
			||||||
 | 
					    /// <typeparam name="T">Type of the value</typeparam>
 | 
				
			||||||
 | 
					    /// <returns>Returns whether add was sucessful. Always true unless ovewrite = false</returns>
 | 
				
			||||||
 | 
					    ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Get an element from the cache
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    /// <param name="key">Key</param>
 | 
				
			||||||
 | 
					    /// <typeparam name="T">Type of the value</typeparam>
 | 
				
			||||||
 | 
					    /// <returns>Either a value or <see cref="None"/></returns>
 | 
				
			||||||
 | 
					    ValueTask<OneOf<T, None>> GetAsync<T>(TypedKey<T> key);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Remove a key from the cache
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    /// <param name="key">Key to remove</param>
 | 
				
			||||||
 | 
					    /// <typeparam name="T">Type of the value</typeparam>
 | 
				
			||||||
 | 
					    /// <returns>Whether there was item</returns>
 | 
				
			||||||
 | 
					    ValueTask<bool> RemoveAsync<T>(TypedKey<T> key);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// <summary>
 | 
				
			||||||
 | 
					    /// Get the key if it exists or add a new one
 | 
				
			||||||
 | 
					    /// </summary>
 | 
				
			||||||
 | 
					    /// <param name="key">Key to get and potentially add</param>
 | 
				
			||||||
 | 
					    /// <param name="createFactory">Value creation factory</param>
 | 
				
			||||||
 | 
					    /// <param name="expiry">Optional expiry</param>
 | 
				
			||||||
 | 
					    /// <typeparam name="T">Type of the value</typeparam>
 | 
				
			||||||
 | 
					    /// <returns>The retrieved or newly added value</returns>
 | 
				
			||||||
 | 
					    ValueTask<T?> GetOrAddAsync<T>(
 | 
				
			||||||
 | 
					        TypedKey<T> key,
 | 
				
			||||||
 | 
					        Func<Task<T?>> createFactory,
 | 
				
			||||||
 | 
					        TimeSpan? expiry = null);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										71
									
								
								src/NadekoBot/Common/Cache/MemoryBotCache.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/NadekoBot/Common/Cache/MemoryBotCache.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
				
			|||||||
 | 
					using Microsoft.Extensions.Caching.Memory;
 | 
				
			||||||
 | 
					using OneOf;
 | 
				
			||||||
 | 
					using OneOf.Types;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// ReSharper disable InconsistentlySynchronizedField
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace NadekoBot.Common;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public sealed class MemoryBotCache : IBotCache
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    // needed for overwrites and Delete return value
 | 
				
			||||||
 | 
					    private readonly object _cacheLock = new object();
 | 
				
			||||||
 | 
					    private readonly MemoryCache _cache;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public MemoryBotCache()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        _cache = new MemoryCache(new MemoryCacheOptions());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (overwrite)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            using var item = _cache.CreateEntry(key.Key);
 | 
				
			||||||
 | 
					            item.Value = value;
 | 
				
			||||||
 | 
					            item.AbsoluteExpirationRelativeToNow = expiry;
 | 
				
			||||||
 | 
					            return new(true);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        lock (_cacheLock)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (_cache.TryGetValue(key.Key, out var old) && old is not null)
 | 
				
			||||||
 | 
					                return new(false);
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					            using var item = _cache.CreateEntry(key.Key);
 | 
				
			||||||
 | 
					            item.Value = value;
 | 
				
			||||||
 | 
					            item.AbsoluteExpirationRelativeToNow = expiry;
 | 
				
			||||||
 | 
					            return new(true);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async ValueTask<T?> GetOrAddAsync<T>(
 | 
				
			||||||
 | 
					        TypedKey<T> key,
 | 
				
			||||||
 | 
					        Func<Task<T?>> createFactory,
 | 
				
			||||||
 | 
					        TimeSpan? expiry = null)
 | 
				
			||||||
 | 
					        => await _cache.GetOrCreateAsync(key.Key,
 | 
				
			||||||
 | 
					            async ce =>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                ce.AbsoluteExpirationRelativeToNow = expiry;
 | 
				
			||||||
 | 
					                var val = await createFactory();
 | 
				
			||||||
 | 
					                return val;
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public ValueTask<OneOf<T, None>> GetAsync<T>(TypedKey<T> key)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (!_cache.TryGetValue(key.Key, out var val) || val is null)
 | 
				
			||||||
 | 
					            return new(new None());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return new((T)val);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public ValueTask<bool> RemoveAsync<T>(TypedKey<T> key)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        lock (_cacheLock)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var toReturn = _cache.TryGetValue(key.Key, out var old ) && old is not null;
 | 
				
			||||||
 | 
					            _cache.Remove(key.Key);
 | 
				
			||||||
 | 
					            return new(toReturn);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										119
									
								
								src/NadekoBot/Common/Cache/RedisBotCache.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								src/NadekoBot/Common/Cache/RedisBotCache.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,119 @@
 | 
				
			|||||||
 | 
					using OneOf;
 | 
				
			||||||
 | 
					using OneOf.Types;
 | 
				
			||||||
 | 
					using StackExchange.Redis;
 | 
				
			||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace NadekoBot.Common;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public sealed class RedisBotCache : IBotCache
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    private static readonly Type[] _supportedTypes = new []
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        typeof(bool), typeof(int), typeof(uint), typeof(long),
 | 
				
			||||||
 | 
					        typeof(ulong), typeof(float), typeof(double),
 | 
				
			||||||
 | 
					        typeof(string), typeof(byte[]), typeof(ReadOnlyMemory<byte>), typeof(Memory<byte>),
 | 
				
			||||||
 | 
					        typeof(RedisValue),
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static readonly JsonSerializerOptions _opts = new()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        PropertyNameCaseInsensitive = true,
 | 
				
			||||||
 | 
					        NumberHandling = JsonNumberHandling.AllowReadingFromString,
 | 
				
			||||||
 | 
					        AllowTrailingCommas = true,
 | 
				
			||||||
 | 
					        IgnoreReadOnlyProperties = false,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    private readonly ConnectionMultiplexer _conn;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public RedisBotCache(ConnectionMultiplexer conn)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        _conn = conn;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        // if a null value is passed, remove the key
 | 
				
			||||||
 | 
					        if (value is null)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            await RemoveAsync(key);
 | 
				
			||||||
 | 
					            return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        var db = _conn.GetDatabase();
 | 
				
			||||||
 | 
					        RedisValue val = IsSupportedType(typeof(T)) 
 | 
				
			||||||
 | 
					            ? RedisValue.Unbox(value)
 | 
				
			||||||
 | 
					            : JsonSerializer.Serialize(value, _opts);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var success = await db.StringSetAsync(key.Key,
 | 
				
			||||||
 | 
					            val,
 | 
				
			||||||
 | 
					            expiry: expiry,
 | 
				
			||||||
 | 
					            when: overwrite ? When.Always : When.NotExists);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return success;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public bool IsSupportedType(Type type)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        if (type.IsGenericType)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            var typeDef = type.GetGenericTypeDefinition();
 | 
				
			||||||
 | 
					            if (typeDef == typeof(Nullable<>))
 | 
				
			||||||
 | 
					                return IsSupportedType(type.GenericTypeArguments[0]);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        foreach (var t in _supportedTypes)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            if (type == t)
 | 
				
			||||||
 | 
					                return true;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public async ValueTask<OneOf<T, None>> GetAsync<T>(TypedKey<T> key)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var db = _conn.GetDatabase();
 | 
				
			||||||
 | 
					        var val = await db.StringGetAsync(key.Key);
 | 
				
			||||||
 | 
					        if (val == default)
 | 
				
			||||||
 | 
					            return new None();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (IsSupportedType(typeof(T)))
 | 
				
			||||||
 | 
					            return (T)((IConvertible)val).ToType(typeof(T), null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return JsonSerializer.Deserialize<T>(val.ToString(), _opts)!;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async ValueTask<bool> RemoveAsync<T>(TypedKey<T> key)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var db = _conn.GetDatabase();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return await db.KeyDeleteAsync(key.Key);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async ValueTask<T?> GetOrAddAsync<T>(TypedKey<T> key, Func<Task<T?>> createFactory, TimeSpan? expiry = null)
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        var result = await GetAsync(key);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return await result.Match<Task<T?>>(
 | 
				
			||||||
 | 
					            v => Task.FromResult<T?>(v),
 | 
				
			||||||
 | 
					            async _ =>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                var factoryValue = await createFactory();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (factoryValue is null)
 | 
				
			||||||
 | 
					                    return default;
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                await AddAsync(key, factoryValue, expiry);
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                // get again to make sure it's the cached value
 | 
				
			||||||
 | 
					                // and not the late factory value, in case there's a race condition
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                var newResult = await GetAsync(key);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // it's fine to do this, it should blow up if something went wrong.
 | 
				
			||||||
 | 
					                return newResult.Match<T?>(
 | 
				
			||||||
 | 
					                    v => v,
 | 
				
			||||||
 | 
					                    _ => default);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -12,7 +12,7 @@ namespace NadekoBot.Common.Configs;
 | 
				
			|||||||
public sealed partial class BotConfig : ICloneable<BotConfig>
 | 
					public sealed partial class BotConfig : ICloneable<BotConfig>
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    [Comment(@"DO NOT CHANGE")]
 | 
					    [Comment(@"DO NOT CHANGE")]
 | 
				
			||||||
    public int Version { get; set; } = 2;
 | 
					    public int Version { get; set; } = 3;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [Comment(@"Most commands, when executed, have a small colored line
 | 
					    [Comment(@"Most commands, when executed, have a small colored line
 | 
				
			||||||
next to the response. The color depends whether the command
 | 
					next to the response. The color depends whether the command
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,7 +19,10 @@ public sealed class Creds : IBotCredentials
 | 
				
			|||||||
    public bool UsePrivilegedIntents { get; set; }
 | 
					    public bool UsePrivilegedIntents { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [Comment(@"The number of shards that the bot will be running on.
 | 
					    [Comment(@"The number of shards that the bot will be running on.
 | 
				
			||||||
Leave at 1 if you don't know what you're doing.")]
 | 
					Leave at 1 if you don't know what you're doing.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					note: If you are planning to have more than one shard, then you must change botCache to 'redis'.
 | 
				
			||||||
 | 
					      Also, in that case you should be using NadekoBot.Coordinator to start the bot, and it will correctly override this value.")]
 | 
				
			||||||
    public int TotalShards { get; set; }
 | 
					    public int TotalShards { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [Comment(   
 | 
					    [Comment(   
 | 
				
			||||||
@@ -51,7 +54,13 @@ go to https://www.patreon.com/portal -> my clients -> create client")]
 | 
				
			|||||||
    [Comment(@"Official cleverbot api key.")]
 | 
					    [Comment(@"Official cleverbot api key.")]
 | 
				
			||||||
    public string CleverbotApiKey { get; set; }
 | 
					    public string CleverbotApiKey { get; set; }
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    [Comment(@"Redis connection string. Don't change if you don't know what you're doing.")]
 | 
					    [Comment(@"Which cache implementation should bot use.
 | 
				
			||||||
 | 
					'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset. 
 | 
				
			||||||
 | 
					'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml")]
 | 
				
			||||||
 | 
					    public BotCacheImplemenation BotCache { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    [Comment(@"Redis connection string. Don't change if you don't know what you're doing.
 | 
				
			||||||
 | 
					Only used if botCache is set to 'redis'")]
 | 
				
			||||||
    public string RedisOptions { get; set; }
 | 
					    public string RedisOptions { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [Comment(@"Database options. Don't change if you don't know what you're doing. Leave null for default values")]
 | 
					    [Comment(@"Database options. Don't change if you don't know what you're doing. Leave null for default values")]
 | 
				
			||||||
@@ -104,12 +113,12 @@ Linux default
 | 
				
			|||||||
    args: ""NadekoBot.dll -- {0}""
 | 
					    args: ""NadekoBot.dll -- {0}""
 | 
				
			||||||
Windows default
 | 
					Windows default
 | 
				
			||||||
    cmd: NadekoBot.exe
 | 
					    cmd: NadekoBot.exe
 | 
				
			||||||
    args: {0}")]
 | 
					    args: ""{0}""")]
 | 
				
			||||||
    public RestartConfig RestartCommand { get; set; }
 | 
					    public RestartConfig RestartCommand { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Creds()
 | 
					    public Creds()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        Version = 5;
 | 
					        Version = 6;
 | 
				
			||||||
        Token = string.Empty;
 | 
					        Token = string.Empty;
 | 
				
			||||||
        UsePrivilegedIntents = true;
 | 
					        UsePrivilegedIntents = true;
 | 
				
			||||||
        OwnerIds = new List<ulong>();
 | 
					        OwnerIds = new List<ulong>();
 | 
				
			||||||
@@ -119,6 +128,7 @@ Windows default
 | 
				
			|||||||
        Patreon = new(string.Empty, string.Empty, string.Empty, string.Empty);
 | 
					        Patreon = new(string.Empty, string.Empty, string.Empty, string.Empty);
 | 
				
			||||||
        BotListToken = string.Empty;
 | 
					        BotListToken = string.Empty;
 | 
				
			||||||
        CleverbotApiKey = string.Empty;
 | 
					        CleverbotApiKey = string.Empty;
 | 
				
			||||||
 | 
					        BotCache = BotCacheImplemenation.Memory;
 | 
				
			||||||
        RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password=";
 | 
					        RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password=";
 | 
				
			||||||
        Db = new()
 | 
					        Db = new()
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -218,3 +228,9 @@ public class GoogleApiConfig
 | 
				
			|||||||
    public string SearchId { get; init; }
 | 
					    public string SearchId { get; init; }
 | 
				
			||||||
    public string ImageSearchId { get; init; }
 | 
					    public string ImageSearchId { get; init; }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public enum BotCacheImplemenation
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    Memory,
 | 
				
			||||||
 | 
					    Redis
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -26,6 +26,7 @@ public interface IBotCredentials
 | 
				
			|||||||
    string TwitchClientId { get; set; }
 | 
					    string TwitchClientId { get; set; }
 | 
				
			||||||
    string TwitchClientSecret { get; set; }
 | 
					    string TwitchClientSecret { get; set; }
 | 
				
			||||||
    GoogleApiConfig Google { get; set; }
 | 
					    GoogleApiConfig Google { get; set; }
 | 
				
			||||||
 | 
					    BotCacheImplemenation BotCache { get; set; }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class RestartConfig
 | 
					public class RestartConfig
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +1,11 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
using NadekoBot.Common.Yml;
 | 
					using NadekoBot.Common.Yml;
 | 
				
			||||||
 | 
					using Cloneable;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace NadekoBot.Common;
 | 
					namespace NadekoBot.Common;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class ImageUrls
 | 
					[Cloneable]
 | 
				
			||||||
 | 
					public partial class ImageUrls : ICloneable<ImageUrls> 
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    [Comment("DO NOT CHANGE")]
 | 
					    [Comment("DO NOT CHANGE")]
 | 
				
			||||||
    public int Version { get; set; } = 3;
 | 
					    public int Version { get; set; } = 3;
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										10
									
								
								src/NadekoBot/Common/OneOfExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/NadekoBot/Common/OneOfExtensions.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					using OneOf.Types;
 | 
				
			||||||
 | 
					using OneOf;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace NadekoBot.Common;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public static class OneOfExtensions
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public static bool TryGetValue<T>(this OneOf<T, None> oneOf, out T value)
 | 
				
			||||||
 | 
					        => oneOf.TryPickT0(out value, out _);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,11 +1,12 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
using Newtonsoft.Json;
 | 
					
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace NadekoBot.Common.Pokemon;
 | 
					namespace NadekoBot.Common.Pokemon;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class SearchPokemon
 | 
					public class SearchPokemon
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    [JsonProperty("num")]
 | 
					    [JsonPropertyName("num")]
 | 
				
			||||||
    public int Id { get; set; }
 | 
					    public int Id { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public string Species { get; set; }
 | 
					    public string Species { get; set; }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										13
									
								
								src/NadekoBot/Db/Models/StreamOnlineMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/NadekoBot/Db/Models/StreamOnlineMessage.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					using NadekoBot.Services.Database.Models;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace NadekoBot.Db.Models;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class StreamOnlineMessage : DbEntity
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public ulong ChannelId { get; set; }
 | 
				
			||||||
 | 
					    public ulong MessageId { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public FollowedStream.FType Type { get; set; }
 | 
				
			||||||
 | 
					    public string Name { get; set; }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -59,6 +59,9 @@ public abstract class NadekoContext : DbContext
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    public DbSet<PatronQuota> PatronQuotas { get; set; }
 | 
					    public DbSet<PatronQuota> PatronQuotas { get; set; }
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
 | 
					    public DbSet<StreamOnlineMessage> StreamOnlineMessages { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    #region Mandatory Provider-Specific Values
 | 
					    #region Mandatory Provider-Specific Values
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    protected abstract string CurrencyTransactionOtherIdDefaultValue { get; }
 | 
					    protected abstract string CurrencyTransactionOtherIdDefaultValue { get; }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										3514
									
								
								src/NadekoBot/Migrations/MySql/20220623090718_stondel-db-cache.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										3514
									
								
								src/NadekoBot/Migrations/MySql/20220623090718_stondel-db-cache.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Metadata;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace NadekoBot.Migrations.Mysql
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public partial class stondeldbcache : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "streamonlinemessages",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<int>(type: "int", nullable: false)
 | 
				
			||||||
 | 
					                        .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
 | 
				
			||||||
 | 
					                    channelid = table.Column<ulong>(type: "bigint unsigned", nullable: false),
 | 
				
			||||||
 | 
					                    messageid = table.Column<ulong>(type: "bigint unsigned", nullable: false),
 | 
				
			||||||
 | 
					                    type = table.Column<int>(type: "int", nullable: false),
 | 
				
			||||||
 | 
					                    name = table.Column<string>(type: "longtext", nullable: true)
 | 
				
			||||||
 | 
					                        .Annotation("MySql:CharSet", "utf8mb4"),
 | 
				
			||||||
 | 
					                    dateadded = table.Column<DateTime>(type: "datetime(6)", nullable: true)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("pk_streamonlinemessages", x => x.id);
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                .Annotation("MySql:CharSet", "utf8mb4");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "streamonlinemessages");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -16,7 +16,7 @@ namespace NadekoBot.Migrations.Mysql
 | 
				
			|||||||
        {
 | 
					        {
 | 
				
			||||||
#pragma warning disable 612, 618
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
            modelBuilder
 | 
					            modelBuilder
 | 
				
			||||||
                .HasAnnotation("ProductVersion", "6.0.5")
 | 
					                .HasAnnotation("ProductVersion", "6.0.6")
 | 
				
			||||||
                .HasAnnotation("Relational:MaxIdentifierLength", 64);
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 64);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            modelBuilder.Entity("NadekoBot.Db.Models.BankUser", b =>
 | 
					            modelBuilder.Entity("NadekoBot.Db.Models.BankUser", b =>
 | 
				
			||||||
@@ -333,6 +333,39 @@ namespace NadekoBot.Migrations.Mysql
 | 
				
			|||||||
                    b.ToTable("patrons", (string)null);
 | 
					                    b.ToTable("patrons", (string)null);
 | 
				
			||||||
                });
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("NadekoBot.Db.Models.StreamOnlineMessage", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<int>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("int")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<ulong>("ChannelId")
 | 
				
			||||||
 | 
					                        .HasColumnType("bigint unsigned")
 | 
				
			||||||
 | 
					                        .HasColumnName("channelid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<DateTime?>("DateAdded")
 | 
				
			||||||
 | 
					                        .HasColumnType("datetime(6)")
 | 
				
			||||||
 | 
					                        .HasColumnName("dateadded");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<ulong>("MessageId")
 | 
				
			||||||
 | 
					                        .HasColumnType("bigint unsigned")
 | 
				
			||||||
 | 
					                        .HasColumnName("messageid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .HasColumnType("longtext")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("Type")
 | 
				
			||||||
 | 
					                        .HasColumnType("int")
 | 
				
			||||||
 | 
					                        .HasColumnName("type");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_streamonlinemessages");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("streamonlinemessages", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b =>
 | 
					            modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b =>
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    b.Property<int>("Id")
 | 
					                    b.Property<int>("Id")
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										3656
									
								
								src/NadekoBot/Migrations/Postgresql/20220623090729_stondel-db-cache.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										3656
									
								
								src/NadekoBot/Migrations/Postgresql/20220623090729_stondel-db-cache.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace NadekoBot.Migrations.PostgreSql
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public partial class stondeldbcache : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "streamonlinemessages",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    id = table.Column<int>(type: "integer", nullable: false)
 | 
				
			||||||
 | 
					                        .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
 | 
				
			||||||
 | 
					                    channelid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
 | 
				
			||||||
 | 
					                    messageid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
 | 
				
			||||||
 | 
					                    type = table.Column<int>(type: "integer", nullable: false),
 | 
				
			||||||
 | 
					                    name = table.Column<string>(type: "text", nullable: true),
 | 
				
			||||||
 | 
					                    dateadded = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("pk_streamonlinemessages", x => x.id);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "streamonlinemessages");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -17,7 +17,7 @@ namespace NadekoBot.Migrations.PostgreSql
 | 
				
			|||||||
        {
 | 
					        {
 | 
				
			||||||
#pragma warning disable 612, 618
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
            modelBuilder
 | 
					            modelBuilder
 | 
				
			||||||
                .HasAnnotation("ProductVersion", "6.0.5")
 | 
					                .HasAnnotation("ProductVersion", "6.0.6")
 | 
				
			||||||
                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
					                .HasAnnotation("Relational:MaxIdentifierLength", 63);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
					            NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
 | 
				
			||||||
@@ -343,6 +343,41 @@ namespace NadekoBot.Migrations.PostgreSql
 | 
				
			|||||||
                    b.ToTable("patrons", (string)null);
 | 
					                    b.ToTable("patrons", (string)null);
 | 
				
			||||||
                });
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("NadekoBot.Db.Models.StreamOnlineMessage", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<int>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("integer")
 | 
				
			||||||
 | 
					                        .HasColumnName("id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<decimal>("ChannelId")
 | 
				
			||||||
 | 
					                        .HasColumnType("numeric(20,0)")
 | 
				
			||||||
 | 
					                        .HasColumnName("channelid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<DateTime?>("DateAdded")
 | 
				
			||||||
 | 
					                        .HasColumnType("timestamp with time zone")
 | 
				
			||||||
 | 
					                        .HasColumnName("dateadded");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<decimal>("MessageId")
 | 
				
			||||||
 | 
					                        .HasColumnType("numeric(20,0)")
 | 
				
			||||||
 | 
					                        .HasColumnName("messageid");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .HasColumnType("text")
 | 
				
			||||||
 | 
					                        .HasColumnName("name");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("Type")
 | 
				
			||||||
 | 
					                        .HasColumnType("integer")
 | 
				
			||||||
 | 
					                        .HasColumnName("type");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id")
 | 
				
			||||||
 | 
					                        .HasName("pk_streamonlinemessages");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("streamonlinemessages", (string)null);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b =>
 | 
					            modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b =>
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    b.Property<int>("Id")
 | 
					                    b.Property<int>("Id")
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										2823
									
								
								src/NadekoBot/Migrations/Sqlite/20220623073903_stondel-db-cache.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2823
									
								
								src/NadekoBot/Migrations/Sqlite/20220623073903_stondel-db-cache.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -0,0 +1,36 @@
 | 
				
			|||||||
 | 
					using System;
 | 
				
			||||||
 | 
					using Microsoft.EntityFrameworkCore.Migrations;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#nullable disable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace NadekoBot.Migrations
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    public partial class stondeldbcache : Migration
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        protected override void Up(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.CreateTable(
 | 
				
			||||||
 | 
					                name: "StreamOnlineMessages",
 | 
				
			||||||
 | 
					                columns: table => new
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    Id = table.Column<int>(type: "INTEGER", nullable: false)
 | 
				
			||||||
 | 
					                        .Annotation("Sqlite:Autoincrement", true),
 | 
				
			||||||
 | 
					                    ChannelId = table.Column<ulong>(type: "INTEGER", nullable: false),
 | 
				
			||||||
 | 
					                    MessageId = table.Column<ulong>(type: "INTEGER", nullable: false),
 | 
				
			||||||
 | 
					                    Type = table.Column<int>(type: "INTEGER", nullable: false),
 | 
				
			||||||
 | 
					                    Name = table.Column<string>(type: "TEXT", nullable: false),
 | 
				
			||||||
 | 
					                    DateAdded = table.Column<DateTime>(type: "TEXT", nullable: true)
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                constraints: table =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    table.PrimaryKey("PK_StreamOnlineMessages", x => x.Id);
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        protected override void Down(MigrationBuilder migrationBuilder)
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            migrationBuilder.DropTable(
 | 
				
			||||||
 | 
					                name: "StreamOnlineMessages");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -15,7 +15,7 @@ namespace NadekoBot.Migrations
 | 
				
			|||||||
        protected override void BuildModel(ModelBuilder modelBuilder)
 | 
					        protected override void BuildModel(ModelBuilder modelBuilder)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
#pragma warning disable 612, 618
 | 
					#pragma warning disable 612, 618
 | 
				
			||||||
            modelBuilder.HasAnnotation("ProductVersion", "6.0.5");
 | 
					            modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            modelBuilder.Entity("NadekoBot.Db.Models.BankUser", b =>
 | 
					            modelBuilder.Entity("NadekoBot.Db.Models.BankUser", b =>
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
@@ -262,6 +262,33 @@ namespace NadekoBot.Migrations
 | 
				
			|||||||
                    b.ToTable("Patrons");
 | 
					                    b.ToTable("Patrons");
 | 
				
			||||||
                });
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            modelBuilder.Entity("NadekoBot.Db.Models.StreamOnlineMessage", b =>
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    b.Property<int>("Id")
 | 
				
			||||||
 | 
					                        .ValueGeneratedOnAdd()
 | 
				
			||||||
 | 
					                        .HasColumnType("INTEGER");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<ulong>("ChannelId")
 | 
				
			||||||
 | 
					                        .HasColumnType("INTEGER");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<DateTime?>("DateAdded")
 | 
				
			||||||
 | 
					                        .HasColumnType("TEXT");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<ulong>("MessageId")
 | 
				
			||||||
 | 
					                        .HasColumnType("INTEGER");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<string>("Name")
 | 
				
			||||||
 | 
					                        .IsRequired()
 | 
				
			||||||
 | 
					                        .HasColumnType("TEXT");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.Property<int>("Type")
 | 
				
			||||||
 | 
					                        .HasColumnType("INTEGER");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.HasKey("Id");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    b.ToTable("StreamOnlineMessages");
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b =>
 | 
					            modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b =>
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    b.Property<int>("Id")
 | 
					                    b.Property<int>("Id")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -500,14 +500,6 @@ public partial class Administration
 | 
				
			|||||||
            await ReplyConfirmLocalizedAsync(strs.message_sent);
 | 
					            await ReplyConfirmLocalizedAsync(strs.message_sent);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        [Cmd]
 | 
					 | 
				
			||||||
        [OwnerOnly]
 | 
					 | 
				
			||||||
        public async partial Task ImagesReload()
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            await _service.ReloadImagesAsync();
 | 
					 | 
				
			||||||
            await ReplyConfirmLocalizedAsync(strs.images_loading);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        [Cmd]
 | 
					        [Cmd]
 | 
				
			||||||
        [OwnerOnly]
 | 
					        [OwnerOnly]
 | 
				
			||||||
        public async partial Task StringsReload()
 | 
					        public async partial Task StringsReload()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,7 +20,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private ConcurrentDictionary<ulong?, ConcurrentDictionary<int, Timer>> autoCommands = new();
 | 
					    private ConcurrentDictionary<ulong?, ConcurrentDictionary<int, Timer>> autoCommands = new();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private readonly IImageCache _imgs;
 | 
					 | 
				
			||||||
    private readonly IHttpClientFactory _httpFactory;
 | 
					    private readonly IHttpClientFactory _httpFactory;
 | 
				
			||||||
    private readonly BotConfigService _bss;
 | 
					    private readonly BotConfigService _bss;
 | 
				
			||||||
    private readonly IPubSub _pubSub;
 | 
					    private readonly IPubSub _pubSub;
 | 
				
			||||||
@@ -28,7 +27,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    //keys
 | 
					    //keys
 | 
				
			||||||
    private readonly TypedKey<ActivityPubData> _activitySetKey;
 | 
					    private readonly TypedKey<ActivityPubData> _activitySetKey;
 | 
				
			||||||
    private readonly TypedKey<bool> _imagesReloadKey;
 | 
					 | 
				
			||||||
    private readonly TypedKey<string> _guildLeaveKey;
 | 
					    private readonly TypedKey<string> _guildLeaveKey;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public SelfService(
 | 
					    public SelfService(
 | 
				
			||||||
@@ -37,7 +35,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
 | 
				
			|||||||
        DbService db,
 | 
					        DbService db,
 | 
				
			||||||
        IBotStrings strings,
 | 
					        IBotStrings strings,
 | 
				
			||||||
        IBotCredentials creds,
 | 
					        IBotCredentials creds,
 | 
				
			||||||
        IDataCache cache,
 | 
					 | 
				
			||||||
        IHttpClientFactory factory,
 | 
					        IHttpClientFactory factory,
 | 
				
			||||||
        BotConfigService bss,
 | 
					        BotConfigService bss,
 | 
				
			||||||
        IPubSub pubSub,
 | 
					        IPubSub pubSub,
 | 
				
			||||||
@@ -48,20 +45,15 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
 | 
				
			|||||||
        _strings = strings;
 | 
					        _strings = strings;
 | 
				
			||||||
        _client = client;
 | 
					        _client = client;
 | 
				
			||||||
        _creds = creds;
 | 
					        _creds = creds;
 | 
				
			||||||
        _imgs = cache.LocalImages;
 | 
					 | 
				
			||||||
        _httpFactory = factory;
 | 
					        _httpFactory = factory;
 | 
				
			||||||
        _bss = bss;
 | 
					        _bss = bss;
 | 
				
			||||||
        _pubSub = pubSub;
 | 
					        _pubSub = pubSub;
 | 
				
			||||||
        _eb = eb;
 | 
					        _eb = eb;
 | 
				
			||||||
        _activitySetKey = new("activity.set");
 | 
					        _activitySetKey = new("activity.set");
 | 
				
			||||||
        _imagesReloadKey = new("images.reload");
 | 
					 | 
				
			||||||
        _guildLeaveKey = new("guild.leave");
 | 
					        _guildLeaveKey = new("guild.leave");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        HandleStatusChanges();
 | 
					        HandleStatusChanges();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (_client.ShardId == 0)
 | 
					 | 
				
			||||||
            _pubSub.Sub(_imagesReloadKey, async _ => await _imgs.Reload());
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        _pubSub.Sub(_guildLeaveKey,
 | 
					        _pubSub.Sub(_guildLeaveKey,
 | 
				
			||||||
            async input =>
 | 
					            async input =>
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
@@ -325,9 +317,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
 | 
				
			|||||||
        uow.SaveChanges();
 | 
					        uow.SaveChanges();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Task ReloadImagesAsync()
 | 
					 | 
				
			||||||
        => _pubSub.Pub(_imagesReloadKey, true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public bool ForwardMessages()
 | 
					    public bool ForwardMessages()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var isForwarding = false;
 | 
					        var isForwarding = false;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,8 +19,8 @@ public partial class Gambling
 | 
				
			|||||||
        private static readonly char[] _fateRolls = { '-', ' ', '+' };
 | 
					        private static readonly char[] _fateRolls = { '-', ' ', '+' };
 | 
				
			||||||
        private readonly IImageCache _images;
 | 
					        private readonly IImageCache _images;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        public DiceRollCommands(IDataCache data)
 | 
					        public DiceRollCommands(ImageCache images)
 | 
				
			||||||
            => _images = data.LocalImages;
 | 
					            => _images = images;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        [Cmd]
 | 
					        [Cmd]
 | 
				
			||||||
        public async partial Task Roll()
 | 
					        public async partial Task Roll()
 | 
				
			||||||
@@ -31,10 +31,10 @@ public partial class Gambling
 | 
				
			|||||||
            var num1 = gen / 10;
 | 
					            var num1 = gen / 10;
 | 
				
			||||||
            var num2 = gen % 10;
 | 
					            var num2 = gen % 10;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            using var img1 = GetDice(num1);
 | 
					            using var img1 = await GetDiceAsync(num1);
 | 
				
			||||||
            using var img2 = GetDice(num2);
 | 
					            using var img2 = await GetDiceAsync(num2);
 | 
				
			||||||
            using var img = new[] { img1, img2 }.Merge(out var format);
 | 
					            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,
 | 
					            await ctx.Channel.SendFileAsync(ms,
 | 
				
			||||||
                $"dice.{format.FileExtensions.First()}",
 | 
					                $"dice.{format.FileExtensions.First()}",
 | 
				
			||||||
                Format.Bold(ctx.User.ToString()) + " " + GetText(strs.dice_rolled(Format.Code(gen.ToString()))));
 | 
					                Format.Bold(ctx.User.ToString()) + " " + GetText(strs.dice_rolled(Format.Code(gen.ToString()))));
 | 
				
			||||||
@@ -96,7 +96,7 @@ public partial class Gambling
 | 
				
			|||||||
                else
 | 
					                else
 | 
				
			||||||
                    toInsert = dice.Count;
 | 
					                    toInsert = dice.Count;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                dice.Insert(toInsert, GetDice(randomNumber));
 | 
					                dice.Insert(toInsert, await GetDiceAsync(randomNumber));
 | 
				
			||||||
                values.Insert(toInsert, randomNumber);
 | 
					                values.Insert(toInsert, randomNumber);
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -195,20 +195,19 @@ public partial class Gambling
 | 
				
			|||||||
            await ReplyConfirmLocalizedAsync(strs.dice_rolled(Format.Bold(rolled.ToString())));
 | 
					            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)
 | 
					            if (num is < 0 or > 10)
 | 
				
			||||||
                throw new ArgumentOutOfRangeException(nameof(num));
 | 
					                throw new ArgumentOutOfRangeException(nameof(num));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (num == 10)
 | 
					            if (num == 10)
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                var images = _images.Dice;
 | 
					                using var imgOne = Image.Load(await _images.GetDiceAsync(1));
 | 
				
			||||||
                using var imgOne = Image.Load(images[1]);
 | 
					                using var imgZero = Image.Load(await _images.GetDiceAsync(0));
 | 
				
			||||||
                using var imgZero = Image.Load(images[0]);
 | 
					 | 
				
			||||||
                return new[] { imgOne, imgZero }.Merge();
 | 
					                return new[] { imgOne, imgZero }.Merge();
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            return Image.Load(_images.Dice[num]);
 | 
					            return Image.Load(await _images.GetDiceAsync(num));
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -14,8 +14,8 @@ public partial class Gambling
 | 
				
			|||||||
        private static readonly ConcurrentDictionary<IGuild, Deck> _allDecks = new();
 | 
					        private static readonly ConcurrentDictionary<IGuild, Deck> _allDecks = new();
 | 
				
			||||||
        private readonly IImageCache _images;
 | 
					        private readonly IImageCache _images;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        public DrawCommands(IDataCache data)
 | 
					        public DrawCommands(IImageCache images)
 | 
				
			||||||
            => _images = data.LocalImages;
 | 
					            => _images = images;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        private async Task<(Stream ImageStream, string ToSend)> InternalDraw(int num, ulong? guildId = null)
 | 
					        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();
 | 
					                var currentCard = cards.Draw();
 | 
				
			||||||
                cardObjects.Add(currentCard);
 | 
					                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();
 | 
					            using var img = images.Merge();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,11 +25,17 @@ public partial class Gambling
 | 
				
			|||||||
        private static readonly NadekoRandom _rng = new();
 | 
					        private static readonly NadekoRandom _rng = new();
 | 
				
			||||||
        private readonly IImageCache _images;
 | 
					        private readonly IImageCache _images;
 | 
				
			||||||
        private readonly ICurrencyService _cs;
 | 
					        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)
 | 
					            : base(gss)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            _images = data.LocalImages;
 | 
					            _ic = ic;
 | 
				
			||||||
 | 
					            _images = images;
 | 
				
			||||||
            _cs = cs;
 | 
					            _cs = cs;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -47,8 +53,8 @@ public partial class Gambling
 | 
				
			|||||||
            var imgs = new Image<Rgba32>[count];
 | 
					            var imgs = new Image<Rgba32>[count];
 | 
				
			||||||
            for (var i = 0; i < count; i++)
 | 
					            for (var i = 0; i < count; i++)
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                var headsArr = _images.Heads[_rng.Next(0, _images.Heads.Count)];
 | 
					                var headsArr = await _images.GetHeadsImageAsync();
 | 
				
			||||||
                var tailsArr = _images.Tails[_rng.Next(0, _images.Tails.Count)];
 | 
					                var tailsArr = await _images.GetTailsImageAsync();
 | 
				
			||||||
                if (_rng.Next(0, 10) < 5)
 | 
					                if (_rng.Next(0, 10) < 5)
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    imgs[i] = Image.Load(headsArr);
 | 
					                    imgs[i] = Image.Load(headsArr);
 | 
				
			||||||
@@ -94,7 +100,7 @@ public partial class Gambling
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
            BetFlipGuess result;
 | 
					            BetFlipGuess result;
 | 
				
			||||||
            Uri imageToSend;
 | 
					            Uri imageToSend;
 | 
				
			||||||
            var coins = _images.ImageUrls.Coins;
 | 
					            var coins = _ic.Data.Coins;
 | 
				
			||||||
            if (_rng.Next(0, 1000) <= 499)
 | 
					            if (_rng.Next(0, 1000) <= 499)
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)];
 | 
					                imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)];
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -38,7 +38,6 @@ public partial class Gambling : GamblingModule<GamblingService>
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private readonly DbService _db;
 | 
					    private readonly DbService _db;
 | 
				
			||||||
    private readonly ICurrencyService _cs;
 | 
					    private readonly ICurrencyService _cs;
 | 
				
			||||||
    private readonly IDataCache _cache;
 | 
					 | 
				
			||||||
    private readonly DiscordSocketClient _client;
 | 
					    private readonly DiscordSocketClient _client;
 | 
				
			||||||
    private readonly NumberFormatInfo _enUsCulture;
 | 
					    private readonly NumberFormatInfo _enUsCulture;
 | 
				
			||||||
    private readonly DownloadTracker _tracker;
 | 
					    private readonly DownloadTracker _tracker;
 | 
				
			||||||
@@ -51,7 +50,6 @@ public partial class Gambling : GamblingModule<GamblingService>
 | 
				
			|||||||
    public Gambling(
 | 
					    public Gambling(
 | 
				
			||||||
        DbService db,
 | 
					        DbService db,
 | 
				
			||||||
        ICurrencyService currency,
 | 
					        ICurrencyService currency,
 | 
				
			||||||
        IDataCache cache,
 | 
					 | 
				
			||||||
        DiscordSocketClient client,
 | 
					        DiscordSocketClient client,
 | 
				
			||||||
        DownloadTracker tracker,
 | 
					        DownloadTracker tracker,
 | 
				
			||||||
        GamblingConfigService configService,
 | 
					        GamblingConfigService configService,
 | 
				
			||||||
@@ -61,7 +59,6 @@ public partial class Gambling : GamblingModule<GamblingService>
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        _db = db;
 | 
					        _db = db;
 | 
				
			||||||
        _cs = currency;
 | 
					        _cs = currency;
 | 
				
			||||||
        _cache = cache;
 | 
					 | 
				
			||||||
        _client = client;
 | 
					        _client = client;
 | 
				
			||||||
        _bank = bank;
 | 
					        _bank = bank;
 | 
				
			||||||
        _ps = ps;
 | 
					        _ps = ps;
 | 
				
			||||||
@@ -124,7 +121,7 @@ public partial class Gambling : GamblingModule<GamblingService>
 | 
				
			|||||||
            return;
 | 
					            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 now = DateTime.UtcNow;
 | 
				
			||||||
            var relativeTag = TimestampTag.FromDateTime(now.Add(rem), TimestampTagStyles.Relative);
 | 
					            var relativeTag = TimestampTag.FromDateTime(now.Add(rem), TimestampTagStyles.Relative);
 | 
				
			||||||
@@ -145,7 +142,7 @@ public partial class Gambling : GamblingModule<GamblingService>
 | 
				
			|||||||
    [OwnerOnly]
 | 
					    [OwnerOnly]
 | 
				
			||||||
    public async partial Task TimelyReset()
 | 
					    public async partial Task TimelyReset()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _cache.RemoveAllTimelyClaims();
 | 
					        await _service.RemoveAllTimelyClaimsAsync();
 | 
				
			||||||
        await ReplyConfirmLocalizedAsync(strs.timely_reset);
 | 
					        await ReplyConfirmLocalizedAsync(strs.timely_reset);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,16 +1,13 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
using LinqToDB;
 | 
					using LinqToDB;
 | 
				
			||||||
using LinqToDB.EntityFrameworkCore;
 | 
					using LinqToDB.EntityFrameworkCore;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					 | 
				
			||||||
using NadekoBot.Common.ModuleBehaviors;
 | 
					using NadekoBot.Common.ModuleBehaviors;
 | 
				
			||||||
using NadekoBot.Db;
 | 
					using NadekoBot.Db;
 | 
				
			||||||
using NadekoBot.Db.Models;
 | 
					using NadekoBot.Db.Models;
 | 
				
			||||||
using NadekoBot.Migrations;
 | 
					 | 
				
			||||||
using NadekoBot.Modules.Gambling.Common;
 | 
					using NadekoBot.Modules.Gambling.Common;
 | 
				
			||||||
using NadekoBot.Modules.Gambling.Common.Connect4;
 | 
					using NadekoBot.Modules.Gambling.Common.Connect4;
 | 
				
			||||||
using NadekoBot.Modules.Gambling.Common.Slot;
 | 
					using NadekoBot.Modules.Gambling.Common.Slot;
 | 
				
			||||||
using NadekoBot.Modules.Gambling.Common.WheelOfFortune;
 | 
					using NadekoBot.Modules.Gambling.Common.WheelOfFortune;
 | 
				
			||||||
using Newtonsoft.Json;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace NadekoBot.Modules.Gambling.Services;
 | 
					namespace NadekoBot.Modules.Gambling.Services;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -22,7 +19,7 @@ public class GamblingService : INService, IReadyExecutor
 | 
				
			|||||||
    private readonly ICurrencyService _cs;
 | 
					    private readonly ICurrencyService _cs;
 | 
				
			||||||
    private readonly Bot _bot;
 | 
					    private readonly Bot _bot;
 | 
				
			||||||
    private readonly DiscordSocketClient _client;
 | 
					    private readonly DiscordSocketClient _client;
 | 
				
			||||||
    private readonly IDataCache _cache;
 | 
					    private readonly IBotCache _cache;
 | 
				
			||||||
    private readonly GamblingConfigService _gss;
 | 
					    private readonly GamblingConfigService _gss;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public GamblingService(
 | 
					    public GamblingService(
 | 
				
			||||||
@@ -30,7 +27,7 @@ public class GamblingService : INService, IReadyExecutor
 | 
				
			|||||||
        Bot bot,
 | 
					        Bot bot,
 | 
				
			||||||
        ICurrencyService cs,
 | 
					        ICurrencyService cs,
 | 
				
			||||||
        DiscordSocketClient client,
 | 
					        DiscordSocketClient client,
 | 
				
			||||||
        IDataCache cache,
 | 
					        IBotCache cache,
 | 
				
			||||||
        GamblingConfigService gss)
 | 
					        GamblingConfigService gss)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _db = db;
 | 
					        _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()
 | 
					    private async Task CurrencyDecayLoopAsync()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (_bot.Client.ShardId != 0)
 | 
					        if (_bot.Client.ShardId != 0)
 | 
				
			||||||
@@ -88,11 +86,16 @@ public class GamblingService : INService, IReadyExecutor
 | 
				
			|||||||
                if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0)
 | 
					                if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0)
 | 
				
			||||||
                    continue;
 | 
					                    continue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                await using var uow = _db.GetDbContext();
 | 
					                var now = DateTime.UtcNow;
 | 
				
			||||||
                var lastCurrencyDecay = _cache.GetLastCurrencyDecay();
 | 
					 | 
				
			||||||
                
 | 
					                
 | 
				
			||||||
                if (DateTime.UtcNow - lastCurrencyDecay < TimeSpan.FromHours(config.Decay.HourInterval))
 | 
					                await using var uow = _db.GetDbContext();
 | 
				
			||||||
 | 
					                var result = await _cache.GetAsync(_curDecayKey);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (result.TryPickT0(out var bin, out _)
 | 
				
			||||||
 | 
					                    && (now - DateTime.FromBinary(bin) < TimeSpan.FromHours(config.Decay.HourInterval)))
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
                    continue;
 | 
					                    continue;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                Log.Information(@"Decaying users' currency - decay: {ConfigDecayPercent}% 
 | 
					                Log.Information(@"Decaying users' currency - decay: {ConfigDecayPercent}% 
 | 
				
			||||||
                                    | max: {MaxDecay} 
 | 
					                                    | max: {MaxDecay} 
 | 
				
			||||||
@@ -115,8 +118,9 @@ public class GamblingService : INService, IReadyExecutor
 | 
				
			|||||||
                                     : old.CurrencyAmount - maxDecay 
 | 
					                                     : old.CurrencyAmount - maxDecay 
 | 
				
			||||||
                         });
 | 
					                         });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                _cache.SetLastCurrencyDecay();
 | 
					 | 
				
			||||||
                await uow.SaveChangesAsync();
 | 
					                await uow.SaveChangesAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                await _cache.AddAsync(_curDecayKey, now.ToBinary());
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            catch (Exception ex)
 | 
					            catch (Exception ex)
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
@@ -161,34 +165,21 @@ public class GamblingService : INService, IReadyExecutor
 | 
				
			|||||||
        return toReturn;
 | 
					        return toReturn;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static readonly TypedKey<EconomyResult> _ecoKey = new("nadeko:economy");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<EconomyResult> GetEconomyAsync()
 | 
					    public async Task<EconomyResult> GetEconomyAsync()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (_cache.TryGetEconomy(out var data))
 | 
					        var data = await _cache.GetOrAddAsync(_ecoKey,
 | 
				
			||||||
 | 
					            async () =>
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
            try
 | 
					                await using var uow = _db.GetDbContext();
 | 
				
			||||||
            {
 | 
					                var cash = uow.DiscordUser.GetTotalCurrency();
 | 
				
			||||||
                return JsonConvert.DeserializeObject<EconomyResult>(data);
 | 
					                var onePercent = uow.DiscordUser.GetTopOnePercentCurrency(_client.CurrentUser.Id);
 | 
				
			||||||
            }
 | 
					                decimal planted = uow.PlantedCurrency.AsQueryable().Sum(x => x.Amount);
 | 
				
			||||||
            catch { }
 | 
					                var waifus = uow.WaifuInfo.GetTotalValue();
 | 
				
			||||||
        }
 | 
					                var bot = uow.DiscordUser.GetUserCurrency(_client.CurrentUser.Id);
 | 
				
			||||||
 | 
					                decimal bank = await uow.GetTable<BankUser>()
 | 
				
			||||||
        decimal cash;
 | 
					 | 
				
			||||||
        decimal onePercent;
 | 
					 | 
				
			||||||
        decimal planted;
 | 
					 | 
				
			||||||
        decimal waifus;
 | 
					 | 
				
			||||||
        decimal bank;
 | 
					 | 
				
			||||||
        long bot;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        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);
 | 
					                                        .SumAsyncLinqToDB(x => x.Balance);
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                var result = new EconomyResult
 | 
					                var result = new EconomyResult
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
@@ -200,21 +191,74 @@ public class GamblingService : INService, IReadyExecutor
 | 
				
			|||||||
                    Bank = bank
 | 
					                    Bank = bank
 | 
				
			||||||
                };
 | 
					                };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        _cache.SetEconomy(JsonConvert.SerializeObject(result));
 | 
					 | 
				
			||||||
                return result;
 | 
					                return result;
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            TimeSpan.FromMinutes(3));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return data;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Task<WheelOfFortuneGame.Result> WheelOfFortuneSpinAsync(ulong userId, long bet)
 | 
					    public Task<WheelOfFortuneGame.Result> WheelOfFortuneSpinAsync(ulong userId, long bet)
 | 
				
			||||||
        => new WheelOfFortuneGame(userId, bet, _gss.Data, _cs).SpinAsync();
 | 
					        => 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; }
 | 
					        if (period == 0)
 | 
				
			||||||
        public decimal Planted { get; set; }
 | 
					            return null;
 | 
				
			||||||
        public decimal Waifus { get; set; }
 | 
					
 | 
				
			||||||
        public decimal OnePercent { get; set; }
 | 
					        await _timelyLock.WaitAsync();
 | 
				
			||||||
        public decimal Bank { get; set; }
 | 
					        try
 | 
				
			||||||
        public long Bot { get; set; }
 | 
					        {
 | 
				
			||||||
 | 
					            // 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; }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -34,7 +34,7 @@ public class PlantPickService : INService, IExecNoCommand
 | 
				
			|||||||
        DbService db,
 | 
					        DbService db,
 | 
				
			||||||
        CommandHandler cmd,
 | 
					        CommandHandler cmd,
 | 
				
			||||||
        IBotStrings strings,
 | 
					        IBotStrings strings,
 | 
				
			||||||
        IDataCache cache,
 | 
					        IImageCache images,
 | 
				
			||||||
        FontProvider fonts,
 | 
					        FontProvider fonts,
 | 
				
			||||||
        ICurrencyService cs,
 | 
					        ICurrencyService cs,
 | 
				
			||||||
        CommandHandler cmdHandler,
 | 
					        CommandHandler cmdHandler,
 | 
				
			||||||
@@ -43,7 +43,7 @@ public class PlantPickService : INService, IExecNoCommand
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        _db = db;
 | 
					        _db = db;
 | 
				
			||||||
        _strings = strings;
 | 
					        _strings = strings;
 | 
				
			||||||
        _images = cache.LocalImages;
 | 
					        _images = images;
 | 
				
			||||||
        _fonts = fonts;
 | 
					        _fonts = fonts;
 | 
				
			||||||
        _cs = cs;
 | 
					        _cs = cs;
 | 
				
			||||||
        _cmdHandler = cmdHandler;
 | 
					        _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="pass">Optional password to add to top left corner.</param>
 | 
				
			||||||
    /// <param name="extension">Extension of the file, defaults to png</param>
 | 
					    /// <param name="extension">Extension of the file, defaults to png</param>
 | 
				
			||||||
    /// <returns>Stream of the currency image</returns>
 | 
					    /// <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 curImg = await _images.GetCurrencyImageAsync();
 | 
				
			||||||
        var rng = new NadekoRandom();
 | 
					 | 
				
			||||||
        var curImg = _images.Currency[rng.Next(0, _images.Currency.Count)];
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (string.IsNullOrWhiteSpace(pass))
 | 
					        if (string.IsNullOrWhiteSpace(pass))
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            // determine the extension
 | 
					            // determine the extension
 | 
				
			||||||
            using (_ = Image.Load(curImg, out var format))
 | 
					            using var load = _ = Image.Load(curImg, out var format);
 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                extension = format.FileExtensions.FirstOrDefault() ?? "png";
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // return the image
 | 
					            // return the image
 | 
				
			||||||
            return curImg.ToStream();
 | 
					            return (curImg.ToStream(), format.FileExtensions.FirstOrDefault() ?? "png");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // get the image stream and extension
 | 
					        // get the image stream and extension
 | 
				
			||||||
        var (s, ext) = AddPassword(curImg, pass);
 | 
					        return AddPassword(curImg, pass);
 | 
				
			||||||
        // set the out extension parameter to the extension we've got
 | 
					 | 
				
			||||||
        extension = ext;
 | 
					 | 
				
			||||||
        // return the image
 | 
					 | 
				
			||||||
        return s;
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /// <summary>
 | 
					    /// <summary>
 | 
				
			||||||
@@ -214,10 +205,10 @@ public class PlantPickService : INService, IExecNoCommand
 | 
				
			|||||||
                        var pw = config.Generation.HasPassword ? GenerateCurrencyPassword().ToUpperInvariant() : null;
 | 
					                        var pw = config.Generation.HasPassword ? GenerateCurrencyPassword().ToUpperInvariant() : null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        IUserMessage sent;
 | 
					                        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);
 | 
					                            sent = await channel.SendFileAsync(stream, $"currency_image.{ext}", toSend);
 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        await AddPlantToDatabase(channel.GuildId,
 | 
					                        await AddPlantToDatabase(channel.GuildId,
 | 
				
			||||||
                            channel.Id,
 | 
					                            channel.Id,
 | 
				
			||||||
@@ -278,7 +269,7 @@ public class PlantPickService : INService, IExecNoCommand
 | 
				
			|||||||
                if (amount > 0)
 | 
					                if (amount > 0)
 | 
				
			||||||
                    // give the picked currency to the user
 | 
					                    // give the picked currency to the user
 | 
				
			||||||
                    await _cs.AddAsync(uid, amount, new("currency", "collect"));
 | 
					                    await _cs.AddAsync(uid, amount, new("currency", "collect"));
 | 
				
			||||||
                uow.SaveChanges();
 | 
					                await uow.SaveChangesAsync();
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            try
 | 
					            try
 | 
				
			||||||
@@ -316,12 +307,15 @@ public class PlantPickService : INService, IExecNoCommand
 | 
				
			|||||||
                msgToSend += " " + GetText(gid, strs.pick_sn(prefix));
 | 
					                msgToSend += " " + GetText(gid, strs.pick_sn(prefix));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            //get the image
 | 
					            //get the image
 | 
				
			||||||
            await using var stream = GetRandomCurrencyImage(pass, out var ext);
 | 
					            var (stream, ext) = await GetRandomCurrencyImageAsync(pass);
 | 
				
			||||||
            // send it
 | 
					            // send it
 | 
				
			||||||
 | 
					            await using (stream)
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
                var msg = await ch.SendFileAsync(stream, $"img.{ext}", msgToSend);
 | 
					                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 sent message's id (in order to be able to delete it when it's picked)
 | 
				
			||||||
                return msg.Id;
 | 
					                return msg.Id;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        catch
 | 
					        catch
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            // if sending fails, return null as message id
 | 
					            // if sending fails, return null as message id
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -32,13 +32,13 @@ public partial class Gambling
 | 
				
			|||||||
        private readonly DbService _db;
 | 
					        private readonly DbService _db;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        public SlotCommands(
 | 
					        public SlotCommands(
 | 
				
			||||||
            IDataCache data,
 | 
					            ImageCache images,
 | 
				
			||||||
            FontProvider fonts,
 | 
					            FontProvider fonts,
 | 
				
			||||||
            DbService db,
 | 
					            DbService db,
 | 
				
			||||||
            GamblingConfigService gamb)
 | 
					            GamblingConfigService gamb)
 | 
				
			||||||
            : base(gamb)
 | 
					            : base(gamb)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            _images = data.LocalImages;
 | 
					            _images = images;
 | 
				
			||||||
            _fonts = fonts;
 | 
					            _fonts = fonts;
 | 
				
			||||||
            _db = db;
 | 
					            _db = db;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -130,7 +130,8 @@ public partial class Gambling
 | 
				
			|||||||
                                  ?? 0;
 | 
					                                  ?? 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];
 | 
					                    var numbers = new int[3];
 | 
				
			||||||
                    result.Rolls.CopyTo(numbers, 0);
 | 
					                    result.Rolls.CopyTo(numbers, 0);
 | 
				
			||||||
@@ -184,7 +185,7 @@ public partial class Gambling
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
                    for (var i = 0; i < 3; i++)
 | 
					                    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));
 | 
					                        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));
 | 
					                            msg = GetText(strs.slot_jackpot(30));
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    await using (var imgStream = bgImage.ToStream())
 | 
					                    await using (var imgStream = await bgImage.ToStreamAsync())
 | 
				
			||||||
                    {
 | 
					                    {
 | 
				
			||||||
                        await ctx.Channel.SendFileAsync(imgStream,
 | 
					                        await ctx.Channel.SendFileAsync(imgStream,
 | 
				
			||||||
                            "result.png",
 | 
					                            "result.png",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
using LinqToDB;
 | 
					using LinqToDB;
 | 
				
			||||||
 | 
					using LinqToDB.EntityFrameworkCore;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using NadekoBot.Common.ModuleBehaviors;
 | 
					using NadekoBot.Common.ModuleBehaviors;
 | 
				
			||||||
using NadekoBot.Db;
 | 
					using NadekoBot.Db;
 | 
				
			||||||
@@ -14,7 +15,7 @@ public class WaifuService : INService, IReadyExecutor
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
    private readonly DbService _db;
 | 
					    private readonly DbService _db;
 | 
				
			||||||
    private readonly ICurrencyService _cs;
 | 
					    private readonly ICurrencyService _cs;
 | 
				
			||||||
    private readonly IDataCache _cache;
 | 
					    private readonly IBotCache _cache;
 | 
				
			||||||
    private readonly GamblingConfigService _gss;
 | 
					    private readonly GamblingConfigService _gss;
 | 
				
			||||||
    private readonly IBotCredentials _creds;
 | 
					    private readonly IBotCredentials _creds;
 | 
				
			||||||
    private readonly DiscordSocketClient _client;
 | 
					    private readonly DiscordSocketClient _client;
 | 
				
			||||||
@@ -22,7 +23,7 @@ public class WaifuService : INService, IReadyExecutor
 | 
				
			|||||||
    public WaifuService(
 | 
					    public WaifuService(
 | 
				
			||||||
        DbService db,
 | 
					        DbService db,
 | 
				
			||||||
        ICurrencyService cs,
 | 
					        ICurrencyService cs,
 | 
				
			||||||
        IDataCache cache,
 | 
					        IBotCache cache,
 | 
				
			||||||
        GamblingConfigService gss,
 | 
					        GamblingConfigService gss,
 | 
				
			||||||
        IBotCredentials creds,
 | 
					        IBotCredentials creds,
 | 
				
			||||||
        DiscordSocketClient client)
 | 
					        DiscordSocketClient client)
 | 
				
			||||||
@@ -236,8 +237,13 @@ public class WaifuService : INService, IReadyExecutor
 | 
				
			|||||||
            var newAff = target is null ? null : uow.GetOrCreateUser(target);
 | 
					            var newAff = target is null ? null : uow.GetOrCreateUser(target);
 | 
				
			||||||
            if (w?.Affinity?.UserId == target?.Id)
 | 
					            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)
 | 
					            else if (w is null)
 | 
				
			||||||
@@ -294,6 +300,12 @@ public class WaifuService : INService, IReadyExecutor
 | 
				
			|||||||
        return uow.WaifuInfo.GetWaifuUserId(ownerId, name);
 | 
					        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)
 | 
					    public async Task<(WaifuInfo, DivorceResult, long, TimeSpan?)> DivorceWaifuAsync(IUser user, ulong targetId)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        DivorceResult result;
 | 
					        DivorceResult result;
 | 
				
			||||||
@@ -305,10 +317,15 @@ public class WaifuService : INService, IReadyExecutor
 | 
				
			|||||||
            w = uow.WaifuInfo.ByWaifuUserId(targetId);
 | 
					            w = uow.WaifuInfo.ByWaifuUserId(targetId);
 | 
				
			||||||
            if (w?.Claimer is null || w.Claimer.UserId != user.Id)
 | 
					            if (w?.Claimer is null || w.Claimer.UserId != user.Id)
 | 
				
			||||||
                result = DivorceResult.NotYourWife;
 | 
					                result = DivorceResult.NotYourWife;
 | 
				
			||||||
            else if (!_cache.TryAddDivorceCooldown(user.Id, out remaining))
 | 
					 | 
				
			||||||
                result = DivorceResult.Cooldown;
 | 
					 | 
				
			||||||
            else
 | 
					            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;
 | 
					                amount = w.Price / 2;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if (w.Affinity?.UserId == user.Id)
 | 
					                if (w.Affinity?.UserId == user.Id)
 | 
				
			||||||
@@ -486,13 +503,13 @@ public class WaifuService : INService, IReadyExecutor
 | 
				
			|||||||
                   .ToList();
 | 
					                   .ToList();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static readonly TypedKey<long> _waifuDecayKey = $"waifu:last_decay";
 | 
				
			||||||
    public async Task OnReadyAsync()
 | 
					    public async Task OnReadyAsync()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        // only decay waifu values from shard 0
 | 
					        // only decay waifu values from shard 0
 | 
				
			||||||
        if (_client.ShardId != 0)
 | 
					        if (_client.ShardId != 0)
 | 
				
			||||||
            return;
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var redisKey = $"{_creds.RedisKey()}_last_waifu_decay";
 | 
					 | 
				
			||||||
        while (true)
 | 
					        while (true)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            try
 | 
					            try
 | 
				
			||||||
@@ -504,28 +521,31 @@ public class WaifuService : INService, IReadyExecutor
 | 
				
			|||||||
                if (multi is < 0f or > 1f || decayInterval < 0)
 | 
					                if (multi is < 0f or > 1f || decayInterval < 0)
 | 
				
			||||||
                    continue;
 | 
					                    continue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                var val = await _cache.Redis.GetDatabase().StringGetAsync(redisKey);
 | 
					                var now = DateTime.UtcNow;
 | 
				
			||||||
                if (val != default)
 | 
					                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);
 | 
					                    var toWait = decayInterval.Hours() - (DateTime.UtcNow - lastDecay);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    if (toWait > 0.Hours())
 | 
					                    if (toWait > 0.Hours())
 | 
				
			||||||
                        continue;
 | 
					                        continue;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                await _cache.Redis.GetDatabase().StringSetAsync(redisKey, DateTime.UtcNow.ToBinary());
 | 
					                await _cache.AddAsync(_waifuDecayKey, nowB);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                await using var uow = _db.GetDbContext();
 | 
					                await using var uow = _db.GetDbContext();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                await uow.WaifuInfo
 | 
					                await uow.GetTable<WaifuInfo>()
 | 
				
			||||||
                         .Where(x => x.Price > minPrice && x.ClaimerId == null)
 | 
					                         .Where(x => x.Price > minPrice && x.ClaimerId == null)
 | 
				
			||||||
                         .UpdateAsync(old => new()
 | 
					                         .UpdateAsync(old => new()
 | 
				
			||||||
                         {
 | 
					                         {
 | 
				
			||||||
                             Price = (long)(old.Price * multi)
 | 
					                             Price = (long)(old.Price * multi)
 | 
				
			||||||
                         });
 | 
					                         });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                await uow.SaveChangesAsync();
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            catch (Exception ex)
 | 
					            catch (Exception ex)
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,9 +14,9 @@ public partial class Games : NadekoModule<GamesService>
 | 
				
			|||||||
    private readonly IHttpClientFactory _httpFactory;
 | 
					    private readonly IHttpClientFactory _httpFactory;
 | 
				
			||||||
    private readonly Random _rng = new();
 | 
					    private readonly Random _rng = new();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Games(IDataCache data, IHttpClientFactory factory)
 | 
					    public Games(IImageCache images, IHttpClientFactory factory)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _images = data.LocalImages;
 | 
					        _images = images;
 | 
				
			||||||
        _httpFactory = factory;
 | 
					        _httpFactory = factory;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,11 +28,12 @@ public class GirlRating
 | 
				
			|||||||
        Roll = roll;
 | 
					        Roll = roll;
 | 
				
			||||||
        Advice = advice; // convenient to have it here, even though atm there are only few different ones.
 | 
					        Advice = advice; // convenient to have it here, even though atm there are only few different ones.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Stream = new(() =>
 | 
					        Stream = new(async () =>
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            try
 | 
					            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 minx = 35;
 | 
				
			||||||
                const int miny = 385;
 | 
					                const int miny = 385;
 | 
				
			||||||
                const int length = 345;
 | 
					                const int length = 345;
 | 
				
			||||||
@@ -40,7 +41,8 @@ public class GirlRating
 | 
				
			|||||||
                var pointx = (int)(minx + (length * (Hot / 10)));
 | 
					                var pointx = (int)(minx + (length * (Hot / 10)));
 | 
				
			||||||
                var pointy = (int)(miny - (length * ((Crazy - 4) / 6)));
 | 
					                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()));
 | 
					                    img.Mutate(x => x.DrawImage(pointImg, new(pointx - 10, pointy - 10), new GraphicsOptions()));
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,14 +9,14 @@ public partial class Games
 | 
				
			|||||||
    [Group]
 | 
					    [Group]
 | 
				
			||||||
    public partial class TriviaCommands : NadekoModule<GamesService>
 | 
					    public partial class TriviaCommands : NadekoModule<GamesService>
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        private readonly IDataCache _cache;
 | 
					        private readonly ILocalDataCache _cache;
 | 
				
			||||||
        private readonly ICurrencyService _cs;
 | 
					        private readonly ICurrencyService _cs;
 | 
				
			||||||
        private readonly GamesConfigService _gamesConfig;
 | 
					        private readonly GamesConfigService _gamesConfig;
 | 
				
			||||||
        private readonly DiscordSocketClient _client;
 | 
					        private readonly DiscordSocketClient _client;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        public TriviaCommands(
 | 
					        public TriviaCommands(
 | 
				
			||||||
            DiscordSocketClient client,
 | 
					            DiscordSocketClient client,
 | 
				
			||||||
            IDataCache cache,
 | 
					            ILocalDataCache cache,
 | 
				
			||||||
            ICurrencyService cs,
 | 
					            ICurrencyService cs,
 | 
				
			||||||
            GamesConfigService gamesConfig)
 | 
					            GamesConfigService gamesConfig)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,7 +17,7 @@ public class TriviaGame
 | 
				
			|||||||
    public bool GameActive { get; private set; }
 | 
					    public bool GameActive { get; private set; }
 | 
				
			||||||
    public bool ShouldStopGame { get; private set; }
 | 
					    public bool ShouldStopGame { get; private set; }
 | 
				
			||||||
    private readonly SemaphoreSlim _guessLock = new(1, 1);
 | 
					    private readonly SemaphoreSlim _guessLock = new(1, 1);
 | 
				
			||||||
    private readonly IDataCache _cache;
 | 
					    private readonly ILocalDataCache _cache;
 | 
				
			||||||
    private readonly IBotStrings _strings;
 | 
					    private readonly IBotStrings _strings;
 | 
				
			||||||
    private readonly DiscordSocketClient _client;
 | 
					    private readonly DiscordSocketClient _client;
 | 
				
			||||||
    private readonly GamesConfig _config;
 | 
					    private readonly GamesConfig _config;
 | 
				
			||||||
@@ -35,7 +35,7 @@ public class TriviaGame
 | 
				
			|||||||
        IBotStrings strings,
 | 
					        IBotStrings strings,
 | 
				
			||||||
        DiscordSocketClient client,
 | 
					        DiscordSocketClient client,
 | 
				
			||||||
        GamesConfig config,
 | 
					        GamesConfig config,
 | 
				
			||||||
        IDataCache cache,
 | 
					        ILocalDataCache cache,
 | 
				
			||||||
        ICurrencyService cs,
 | 
					        ICurrencyService cs,
 | 
				
			||||||
        IGuild guild,
 | 
					        IGuild guild,
 | 
				
			||||||
        ITextChannel channel,
 | 
					        ITextChannel channel,
 | 
				
			||||||
@@ -70,7 +70,7 @@ public class TriviaGame
 | 
				
			|||||||
            showHowToQuit = !showHowToQuit;
 | 
					            showHowToQuit = !showHowToQuit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // load question
 | 
					            // load question
 | 
				
			||||||
            CurrentQuestion = _questionPool.GetRandomQuestion(OldQuestions, _options.IsPokemon);
 | 
					            CurrentQuestion = await _questionPool.GetRandomQuestionAsync(OldQuestions, _options.IsPokemon);
 | 
				
			||||||
            if (string.IsNullOrWhiteSpace(CurrentQuestion?.Answer)
 | 
					            if (string.IsNullOrWhiteSpace(CurrentQuestion?.Answer)
 | 
				
			||||||
                || string.IsNullOrWhiteSpace(CurrentQuestion.Question))
 | 
					                || string.IsNullOrWhiteSpace(CurrentQuestion.Question))
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,6 +4,15 @@ using System.Text.RegularExpressions;
 | 
				
			|||||||
// THANKS @ShoMinamimoto for suggestions and coding help
 | 
					// THANKS @ShoMinamimoto for suggestions and coding help
 | 
				
			||||||
namespace NadekoBot.Modules.Games.Common.Trivia;
 | 
					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 class TriviaQuestion
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public const int MAX_STRING_LENGTH = 22;
 | 
					    public const int MAX_STRING_LENGTH = 22;
 | 
				
			||||||
@@ -17,29 +26,30 @@ public class TriviaQuestion
 | 
				
			|||||||
        new(22, 3)
 | 
					        new(22, 3)
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public string Category { get; set; }
 | 
					    public string Category
 | 
				
			||||||
    public string Question { get; set; }
 | 
					        => _qModel.Category;
 | 
				
			||||||
    public string ImageUrl { get; set; }
 | 
					
 | 
				
			||||||
    public string AnswerImageUrl { get; set; }
 | 
					    public string Question
 | 
				
			||||||
    public string Answer { get; set; }
 | 
					        => _qModel.Question;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public string ImageUrl
 | 
				
			||||||
 | 
					        => _qModel.ImageUrl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public string AnswerImageUrl
 | 
				
			||||||
 | 
					        => _qModel.AnswerImageUrl ?? ImageUrl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public string Answer
 | 
				
			||||||
 | 
					        => _qModel.Answer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public string CleanAnswer
 | 
					    public string CleanAnswer
 | 
				
			||||||
        => cleanAnswer ?? (cleanAnswer = Clean(Answer));
 | 
					        => cleanAnswer ?? (cleanAnswer = Clean(Answer));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private string cleanAnswer;
 | 
					    private string cleanAnswer;
 | 
				
			||||||
 | 
					    private readonly TriviaQuestionModel _qModel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public TriviaQuestion(
 | 
					    public TriviaQuestion(TriviaQuestionModel qModel)
 | 
				
			||||||
        string q,
 | 
					 | 
				
			||||||
        string a,
 | 
					 | 
				
			||||||
        string c,
 | 
					 | 
				
			||||||
        string img = null,
 | 
					 | 
				
			||||||
        string answerImage = null)
 | 
					 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        Question = q;
 | 
					        _qModel = qModel;
 | 
				
			||||||
        Answer = a;
 | 
					 | 
				
			||||||
        Category = c;
 | 
					 | 
				
			||||||
        ImageUrl = img;
 | 
					 | 
				
			||||||
        AnswerImageUrl = answerImage ?? img;
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public string GetHint()
 | 
					    public string GetHint()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,45 +1,48 @@
 | 
				
			|||||||
#nullable disable
 | 
					 | 
				
			||||||
namespace NadekoBot.Modules.Games.Common.Trivia;
 | 
					namespace NadekoBot.Modules.Games.Common.Trivia;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class TriviaQuestionPool
 | 
					public class TriviaQuestionPool
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private TriviaQuestion[] Pool
 | 
					    private readonly ILocalDataCache _cache;
 | 
				
			||||||
        => _cache.LocalData.TriviaQuestions;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private IReadOnlyDictionary<int, string> Map
 | 
					 | 
				
			||||||
        => _cache.LocalData.PokemonMap;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private readonly IDataCache _cache;
 | 
					 | 
				
			||||||
    private readonly int _maxPokemonId;
 | 
					    private readonly int _maxPokemonId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private readonly NadekoRandom _rng = new();
 | 
					    private readonly NadekoRandom _rng = new();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public TriviaQuestionPool(IDataCache cache)
 | 
					    public TriviaQuestionPool(ILocalDataCache cache)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _cache = cache;
 | 
					        _cache = cache;
 | 
				
			||||||
        _maxPokemonId = 721; //xd
 | 
					        _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)
 | 
					        if (isPokemon)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
 | 
					            var pokes = await _cache.GetPokemonMapAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (pokes is null or { Length: 0 })
 | 
				
			||||||
 | 
					                return default;
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
            var num = _rng.Next(1, _maxPokemonId + 1);
 | 
					            var num = _rng.Next(1, _maxPokemonId + 1);
 | 
				
			||||||
            return new("Who's That Pokémon?",
 | 
					            return new(new()
 | 
				
			||||||
                Map[num].ToTitleCase(),
 | 
					            {
 | 
				
			||||||
                "Pokemon",
 | 
					                Question = "Who's That Pokémon?",
 | 
				
			||||||
                $@"https://nadeko.bot/images/pokemon/shadows/{num}.png",
 | 
					                Answer = pokes[num].Name.ToTitleCase(),
 | 
				
			||||||
                $@"https://nadeko.bot/images/pokemon/real/{num}.png");
 | 
					                Category = "Pokemon",
 | 
				
			||||||
 | 
					                ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png",
 | 
				
			||||||
 | 
					                AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        TriviaQuestion randomQuestion;
 | 
					        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 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();
 | 
					                exclude.Clear();
 | 
				
			||||||
                break;
 | 
					                break;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										95
									
								
								src/NadekoBot/Modules/Music/_Common/Impl/TrackCacher.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/NadekoBot/Modules/Music/_Common/Impl/TrackCacher.cs
									
									
									
									
									
										Normal 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));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,34 +1,38 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
using Newtonsoft.Json;
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace NadekoBot.Modules.Searches.Common;
 | 
					namespace NadekoBot.Modules.Searches.Common;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class AnimeResult
 | 
					public class AnimeResult
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
 | 
					    [JsonPropertyName("id")]
 | 
				
			||||||
    public int Id { get; set; }
 | 
					    public int Id { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [JsonPropertyName("airing_status")]
 | 
				
			||||||
 | 
					    public string AiringStatusParsed { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [JsonPropertyName("title_english")]
 | 
				
			||||||
 | 
					    public string TitleEnglish { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [JsonPropertyName("total_episodes")]
 | 
				
			||||||
 | 
					    public int TotalEpisodes { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    [JsonPropertyName("description")]
 | 
				
			||||||
 | 
					    public string Description { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [JsonPropertyName("image_url_lge")]
 | 
				
			||||||
 | 
					    public string ImageUrlLarge { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [JsonPropertyName("genres")]
 | 
				
			||||||
 | 
					    public string[] Genres { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [JsonPropertyName("average_score")]
 | 
				
			||||||
 | 
					    public float AverageScore { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
    public string AiringStatus
 | 
					    public string AiringStatus
 | 
				
			||||||
        => AiringStatusParsed.ToTitleCase();
 | 
					        => AiringStatusParsed.ToTitleCase();
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    [JsonProperty("airing_status")]
 | 
					 | 
				
			||||||
    public string AiringStatusParsed { get; set; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [JsonProperty("title_english")]
 | 
					 | 
				
			||||||
    public string TitleEnglish { get; set; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [JsonProperty("total_episodes")]
 | 
					 | 
				
			||||||
    public int TotalEpisodes { get; set; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public string Description { get; set; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [JsonProperty("image_url_lge")]
 | 
					 | 
				
			||||||
    public string ImageUrlLarge { get; set; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public string[] Genres { get; set; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [JsonProperty("average_score")]
 | 
					 | 
				
			||||||
    public string AverageScore { get; set; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public string Link
 | 
					    public string Link
 | 
				
			||||||
        => "http://anilist.co/anime/" + Id;
 | 
					        => "http://anilist.co/anime/" + Id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,17 +1,15 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
using AngleSharp;
 | 
					 | 
				
			||||||
using AngleSharp.Html.Dom;
 | 
					 | 
				
			||||||
using NadekoBot.Modules.Searches.Common;
 | 
					using NadekoBot.Modules.Searches.Common;
 | 
				
			||||||
using Newtonsoft.Json;
 | 
					using System.Net.Http.Json;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace NadekoBot.Modules.Searches.Services;
 | 
					namespace NadekoBot.Modules.Searches.Services;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class AnimeSearchService : INService
 | 
					public class AnimeSearchService : INService
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private readonly IDataCache _cache;
 | 
					    private readonly IBotCache _cache;
 | 
				
			||||||
    private readonly IHttpClientFactory _httpFactory;
 | 
					    private readonly IHttpClientFactory _httpFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public AnimeSearchService(IDataCache cache, IHttpClientFactory httpFactory)
 | 
					    public AnimeSearchService(IBotCache cache, IHttpClientFactory httpFactory)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _cache = cache;
 | 
					        _cache = cache;
 | 
				
			||||||
        _httpFactory = httpFactory;
 | 
					        _httpFactory = httpFactory;
 | 
				
			||||||
@@ -21,24 +19,25 @@ public class AnimeSearchService : INService
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        if (string.IsNullOrWhiteSpace(query))
 | 
					        if (string.IsNullOrWhiteSpace(query))
 | 
				
			||||||
            throw new ArgumentNullException(nameof(query));
 | 
					            throw new ArgumentNullException(nameof(query));
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        TypedKey<AnimeResult> GetKey(string link)
 | 
				
			||||||
 | 
					            => new TypedKey<AnimeResult>($"anime2:{link}");
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var link = "https://aniapi.nadeko.bot/anime/"
 | 
					            var suffix = Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
 | 
				
			||||||
                       + Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
 | 
					            var link = $"https://aniapi.nadeko.bot/anime/{suffix}";
 | 
				
			||||||
            link = link.ToLowerInvariant();
 | 
					            link = link.ToLowerInvariant();
 | 
				
			||||||
            var (ok, data) = await _cache.TryGetAnimeDataAsync(link);
 | 
					            var result = await _cache.GetAsync(GetKey(link));
 | 
				
			||||||
            if (!ok)
 | 
					            if (!result.TryPickT0(out var data, out _))
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                using (var http = _httpFactory.CreateClient())
 | 
					                using var http = _httpFactory.CreateClient();
 | 
				
			||||||
                {
 | 
					                data = await http.GetFromJsonAsync<AnimeResult>(link);
 | 
				
			||||||
                    data = await http.GetStringAsync(link);
 | 
					
 | 
				
			||||||
 | 
					                await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(12));
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                await _cache.SetAnimeDataAsync(link, data);
 | 
					            return data;
 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            return JsonConvert.DeserializeObject<AnimeResult>(data);
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch
 | 
					        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)
 | 
					    public async Task<MangaResult> GetMangaData(string query)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (string.IsNullOrWhiteSpace(query))
 | 
					        if (string.IsNullOrWhiteSpace(query))
 | 
				
			||||||
            throw new ArgumentNullException(nameof(query));
 | 
					            throw new ArgumentNullException(nameof(query));
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        TypedKey<MangaResult> GetKey(string link)
 | 
				
			||||||
 | 
					            => new TypedKey<MangaResult>($"manga2:{link}");
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var link = "https://aniapi.nadeko.bot/manga/"
 | 
					            var link = "https://aniapi.nadeko.bot/manga/"
 | 
				
			||||||
                       + Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
 | 
					                       + Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
 | 
				
			||||||
            link = link.ToLowerInvariant();
 | 
					            link = link.ToLowerInvariant();
 | 
				
			||||||
            var (ok, data) = await _cache.TryGetAnimeDataAsync(link);
 | 
					 | 
				
			||||||
            if (!ok)
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                using (var http = _httpFactory.CreateClient())
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    data = await http.GetStringAsync(link);
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
                await _cache.SetAnimeDataAsync(link, data);
 | 
					            var result = await _cache.GetAsync(GetKey(link));
 | 
				
			||||||
 | 
					            if (!result.TryPickT0(out var data, out _))
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                using var http = _httpFactory.CreateClient();
 | 
				
			||||||
 | 
					                data = await http.GetFromJsonAsync<MangaResult>(link);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(3));
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            return JsonConvert.DeserializeObject<MangaResult>(data);
 | 
					            return data;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch
 | 
					        catch
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,32 +1,36 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
using Newtonsoft.Json;
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace NadekoBot.Modules.Searches.Common;
 | 
					namespace NadekoBot.Modules.Searches.Common;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class MangaResult
 | 
					public class MangaResult
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
 | 
					    [JsonPropertyName("id")]
 | 
				
			||||||
    public int Id { get; set; }
 | 
					    public int Id { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [JsonProperty("publishing_status")]
 | 
					    [JsonPropertyName("publishing_status")]
 | 
				
			||||||
    public string PublishingStatus { get; set; }
 | 
					    public string PublishingStatus { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [JsonProperty("image_url_lge")]
 | 
					    [JsonPropertyName("image_url_lge")]
 | 
				
			||||||
    public string ImageUrlLge { get; set; }
 | 
					    public string ImageUrlLge { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [JsonProperty("title_english")]
 | 
					    [JsonPropertyName("title_english")]
 | 
				
			||||||
    public string TitleEnglish { get; set; }
 | 
					    public string TitleEnglish { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [JsonProperty("total_chapters")]
 | 
					    [JsonPropertyName("total_chapters")]
 | 
				
			||||||
    public int TotalChapters { get; set; }
 | 
					    public int TotalChapters { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [JsonProperty("total_volumes")]
 | 
					    [JsonPropertyName("total_volumes")]
 | 
				
			||||||
    public int TotalVolumes { get; set; }
 | 
					    public int TotalVolumes { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [JsonPropertyName("description")]
 | 
				
			||||||
    public string Description { get; set; }
 | 
					    public string Description { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    [JsonPropertyName("genres")]
 | 
				
			||||||
    public string[] Genres { get; set; }
 | 
					    public string[] Genres { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [JsonProperty("average_score")]
 | 
					    [JsonPropertyName("average_score")]
 | 
				
			||||||
    public string AverageScore { get; set; }
 | 
					    public float AverageScore { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public string Link
 | 
					    public string Link
 | 
				
			||||||
        => "http://anilist.co/manga/" + Id;
 | 
					        => "http://anilist.co/manga/" + Id;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,13 +15,13 @@ namespace NadekoBot.Modules.Searches.Services;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public class CryptoService : INService
 | 
					public class CryptoService : INService
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private readonly IDataCache _cache;
 | 
					    private readonly IBotCache _cache;
 | 
				
			||||||
    private readonly IHttpClientFactory _httpFactory;
 | 
					    private readonly IHttpClientFactory _httpFactory;
 | 
				
			||||||
    private readonly IBotCredentials _creds;
 | 
					    private readonly IBotCredentials _creds;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private readonly SemaphoreSlim _getCryptoLock = new(1, 1);
 | 
					    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;
 | 
					        _cache = cache;
 | 
				
			||||||
        _httpFactory = httpFactory;
 | 
					        _httpFactory = httpFactory;
 | 
				
			||||||
@@ -40,7 +40,8 @@ public class CryptoService : INService
 | 
				
			|||||||
        Span<PointF> points = new PointF[gElement.ChildNodes.Count];
 | 
					        Span<PointF> points = new PointF[gElement.ChildNodes.Count];
 | 
				
			||||||
        var cnt = 0;
 | 
					        var cnt = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        bool GetValuesFromAttributes(XmlAttributeCollection attrs,
 | 
					        bool GetValuesFromAttributes(
 | 
				
			||||||
 | 
					            XmlAttributeCollection attrs,
 | 
				
			||||||
            out float x1,
 | 
					            out float x1,
 | 
				
			||||||
            out float y1,
 | 
					            out float y1,
 | 
				
			||||||
            out float x2,
 | 
					            out float x2,
 | 
				
			||||||
@@ -67,7 +68,7 @@ public class CryptoService : INService
 | 
				
			|||||||
                points[cnt++] = new(x1, y1);
 | 
					                points[cnt++] = new(x1, y1);
 | 
				
			||||||
                // this point will be set twice to the same value
 | 
					                // this point will be set twice to the same value
 | 
				
			||||||
                // on all points except the last one
 | 
					                // on all points except the last one
 | 
				
			||||||
                if(cnt + 1 < points.Length)
 | 
					                if (cnt + 1 < points.Length)
 | 
				
			||||||
                    points[cnt + 1] = new(x2, y2);
 | 
					                    points[cnt + 1] = new(x2, y2);
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -130,20 +131,20 @@ public class CryptoService : INService
 | 
				
			|||||||
        await _getCryptoLock.WaitAsync();
 | 
					        await _getCryptoLock.WaitAsync();
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var fullStrData = await _cache.GetOrAddCachedDataAsync("nadeko:crypto_data",
 | 
					            var data = await _cache.GetOrAddAsync(new("nadeko:crypto_data"),
 | 
				
			||||||
                async _ =>
 | 
					                async () =>
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    try
 | 
					                    try
 | 
				
			||||||
                    {
 | 
					                    {
 | 
				
			||||||
                        using var http = _httpFactory.CreateClient();
 | 
					                        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?"
 | 
					                            "https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?"
 | 
				
			||||||
                            + $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}"
 | 
					                            + $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}"
 | 
				
			||||||
                            + "&start=1"
 | 
					                            + "&start=1"
 | 
				
			||||||
                            + "&limit=5000"
 | 
					                            + "&limit=5000"
 | 
				
			||||||
                            + "&convert=USD");
 | 
					                            + "&convert=USD");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                        return JsonSerializer.Serialize(strData);
 | 
					                        return data;
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                    catch (Exception ex)
 | 
					                    catch (Exception ex)
 | 
				
			||||||
                    {
 | 
					                    {
 | 
				
			||||||
@@ -151,13 +152,12 @@ public class CryptoService : INService
 | 
				
			|||||||
                        return default;
 | 
					                        return default;
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                "",
 | 
					 | 
				
			||||||
                TimeSpan.FromHours(2));
 | 
					                TimeSpan.FromHours(2));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (fullStrData is null)
 | 
					            if (data is null)
 | 
				
			||||||
                return default;
 | 
					                return default;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            return JsonSerializer.Deserialize<CryptoResponse>(fullStrData)?.Data ?? new();
 | 
					            return data.Data;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        catch (Exception ex)
 | 
					        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)
 | 
					    public async Task<Stream?> GetSparklineAsync(int id, bool up)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var key = $"crypto:sparkline:{id}";
 | 
					        try
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            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));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // 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 })
 | 
					            if (bytes is { Length: > 0 })
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                return bytes.ToStream();
 | 
					                return bytes.ToStream();
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // if it fails, generate a new one
 | 
					 | 
				
			||||||
        var points = await DownloadSparklinePointsAsync(id);
 | 
					 | 
				
			||||||
        if (points is null)
 | 
					 | 
				
			||||||
            return default;
 | 
					            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;
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					        catch (Exception ex)
 | 
				
			||||||
    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;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        catch(Exception ex)
 | 
					 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            Log.Warning(ex,
 | 
					            Log.Warning(ex,
 | 
				
			||||||
                "Exception occurred while downloading sparkline points: {ErrorMessage}",
 | 
					                "Exception occurred while downloading sparkline points: {ErrorMessage}",
 | 
				
			||||||
@@ -215,4 +204,13 @@ public class CryptoService : INService
 | 
				
			|||||||
            return default;
 | 
					            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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -9,15 +9,9 @@ public partial class Searches
 | 
				
			|||||||
    [Group]
 | 
					    [Group]
 | 
				
			||||||
    public partial class PokemonSearchCommands : NadekoModule<SearchesService>
 | 
					    public partial class PokemonSearchCommands : NadekoModule<SearchesService>
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        public IReadOnlyDictionary<string, SearchPokemon> Pokemons
 | 
					        private readonly ILocalDataCache _cache;
 | 
				
			||||||
            => _cache.LocalData.Pokemons;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        public IReadOnlyDictionary<string, SearchPokemonAbility> PokemonAbilities
 | 
					        public PokemonSearchCommands(ILocalDataCache cache)
 | 
				
			||||||
            => _cache.LocalData.PokemonAbilities;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        private readonly IDataCache _cache;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        public PokemonSearchCommands(IDataCache cache)
 | 
					 | 
				
			||||||
            => _cache = cache;
 | 
					            => _cache = cache;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        [Cmd]
 | 
					        [Cmd]
 | 
				
			||||||
@@ -27,7 +21,7 @@ public partial class Searches
 | 
				
			|||||||
            if (string.IsNullOrWhiteSpace(pokemon))
 | 
					            if (string.IsNullOrWhiteSpace(pokemon))
 | 
				
			||||||
                return;
 | 
					                return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            foreach (var kvp in Pokemons)
 | 
					            foreach (var kvp in await _cache.GetPokemonsAsync())
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                if (kvp.Key.ToUpperInvariant() == pokemon.ToUpperInvariant())
 | 
					                if (kvp.Key.ToUpperInvariant() == pokemon.ToUpperInvariant())
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
@@ -58,7 +52,7 @@ public partial class Searches
 | 
				
			|||||||
            ability = ability?.Trim().ToUpperInvariant().Replace(" ", "", StringComparison.InvariantCulture);
 | 
					            ability = ability?.Trim().ToUpperInvariant().Replace(" ", "", StringComparison.InvariantCulture);
 | 
				
			||||||
            if (string.IsNullOrWhiteSpace(ability))
 | 
					            if (string.IsNullOrWhiteSpace(ability))
 | 
				
			||||||
                return;
 | 
					                return;
 | 
				
			||||||
            foreach (var kvp in PokemonAbilities)
 | 
					            foreach (var kvp in await _cache.GetPokemonAbilitiesAsync())
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                if (kvp.Key.ToUpperInvariant() == ability)
 | 
					                if (kvp.Key.ToUpperInvariant() == ability)
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,14 +10,14 @@ public partial class Searches
 | 
				
			|||||||
    public partial class SearchCommands : NadekoModule
 | 
					    public partial class SearchCommands : NadekoModule
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        private readonly ISearchServiceFactory _searchFactory;
 | 
					        private readonly ISearchServiceFactory _searchFactory;
 | 
				
			||||||
        private readonly ConnectionMultiplexer _redis;
 | 
					        private readonly IBotCache _cache;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        public SearchCommands(
 | 
					        public SearchCommands(
 | 
				
			||||||
            ISearchServiceFactory searchFactory,
 | 
					            ISearchServiceFactory searchFactory,
 | 
				
			||||||
            ConnectionMultiplexer redis)
 | 
					            IBotCache cache)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            _searchFactory = searchFactory;
 | 
					            _searchFactory = searchFactory;
 | 
				
			||||||
            _redis = redis;
 | 
					            _cache = cache;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        [Cmd]
 | 
					        [Cmd]
 | 
				
			||||||
@@ -127,18 +127,17 @@ public partial class Searches
 | 
				
			|||||||
            await ctx.Channel.EmbedAsync(null, embeds: embeds);
 | 
					            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)
 | 
					        private async Task AddYoutubeUrlToCacheAsync(string query, string url)
 | 
				
			||||||
        {
 | 
					            => await _cache.AddAsync(GetYtCacheKey(query), url, expiry: 1.Hours());
 | 
				
			||||||
            var db = _redis.GetDatabase();
 | 
					 | 
				
			||||||
            await db.StringSetAsync($"search:youtube:{query}", url, expiry: 1.Hours());
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        private async Task<VideoInfo?> GetYoutubeUrlFromCacheAsync(string query)
 | 
					        private async Task<VideoInfo?> GetYoutubeUrlFromCacheAsync(string query)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var db = _redis.GetDatabase();
 | 
					            var result = await _cache.GetAsync(GetYtCacheKey(query));
 | 
				
			||||||
            var url = await db.StringGetAsync($"search:youtube:{query}");
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (string.IsNullOrWhiteSpace(url))
 | 
					            if (!result.TryGetValue(out var url) || string.IsNullOrWhiteSpace(url))
 | 
				
			||||||
                return null;
 | 
					                return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            return new VideoInfo()
 | 
					            return new VideoInfo()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,4 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
using AngleSharp.Html.Dom;
 | 
					 | 
				
			||||||
using AngleSharp.Html.Parser;
 | 
					 | 
				
			||||||
using Html2Markdown;
 | 
					using Html2Markdown;
 | 
				
			||||||
using NadekoBot.Modules.Searches.Common;
 | 
					using NadekoBot.Modules.Searches.Common;
 | 
				
			||||||
using Newtonsoft.Json;
 | 
					using Newtonsoft.Json;
 | 
				
			||||||
@@ -10,7 +8,6 @@ using SixLabors.ImageSharp;
 | 
				
			|||||||
using SixLabors.ImageSharp.Drawing.Processing;
 | 
					using SixLabors.ImageSharp.Drawing.Processing;
 | 
				
			||||||
using SixLabors.ImageSharp.PixelFormats;
 | 
					using SixLabors.ImageSharp.PixelFormats;
 | 
				
			||||||
using SixLabors.ImageSharp.Processing;
 | 
					using SixLabors.ImageSharp.Processing;
 | 
				
			||||||
using System.Net;
 | 
					 | 
				
			||||||
using Color = SixLabors.ImageSharp.Color;
 | 
					using Color = SixLabors.ImageSharp.Color;
 | 
				
			||||||
using Image = SixLabors.ImageSharp.Image;
 | 
					using Image = SixLabors.ImageSharp.Image;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -31,9 +28,9 @@ public class SearchesService : INService
 | 
				
			|||||||
    private readonly IHttpClientFactory _httpFactory;
 | 
					    private readonly IHttpClientFactory _httpFactory;
 | 
				
			||||||
    private readonly IGoogleApiService _google;
 | 
					    private readonly IGoogleApiService _google;
 | 
				
			||||||
    private readonly IImageCache _imgs;
 | 
					    private readonly IImageCache _imgs;
 | 
				
			||||||
    private readonly IDataCache _cache;
 | 
					    private readonly IBotCache _c;
 | 
				
			||||||
    private readonly FontProvider _fonts;
 | 
					    private readonly FontProvider _fonts;
 | 
				
			||||||
    private readonly IBotCredentials _creds;
 | 
					    private readonly IBotCredsProvider _creds;
 | 
				
			||||||
    private readonly NadekoRandom _rng;
 | 
					    private readonly NadekoRandom _rng;
 | 
				
			||||||
    private readonly List<string> _yomamaJokes;
 | 
					    private readonly List<string> _yomamaJokes;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -42,15 +39,16 @@ public class SearchesService : INService
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    public SearchesService(
 | 
					    public SearchesService(
 | 
				
			||||||
        IGoogleApiService google,
 | 
					        IGoogleApiService google,
 | 
				
			||||||
        IDataCache cache,
 | 
					        IImageCache images,
 | 
				
			||||||
 | 
					        IBotCache c,
 | 
				
			||||||
        IHttpClientFactory factory,
 | 
					        IHttpClientFactory factory,
 | 
				
			||||||
        FontProvider fonts,
 | 
					        FontProvider fonts,
 | 
				
			||||||
        IBotCredentials creds)
 | 
					        IBotCredsProvider creds)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _httpFactory = factory;
 | 
					        _httpFactory = factory;
 | 
				
			||||||
        _google = google;
 | 
					        _google = google;
 | 
				
			||||||
        _imgs = cache.LocalImages;
 | 
					        _imgs = images;
 | 
				
			||||||
        _cache = cache;
 | 
					        _c = c;
 | 
				
			||||||
        _fonts = fonts;
 | 
					        _fonts = fonts;
 | 
				
			||||||
        _creds = creds;
 | 
					        _creds = creds;
 | 
				
			||||||
        _rng = new();
 | 
					        _rng = new();
 | 
				
			||||||
@@ -76,36 +74,28 @@ public class SearchesService : INService
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<Stream> GetRipPictureAsync(string text, Uri imgUrl)
 | 
					    public async Task<Stream> GetRipPictureAsync(string text, Uri imgUrl)
 | 
				
			||||||
    {
 | 
					        => (await GetRipPictureFactory(text, imgUrl)).ToStream();
 | 
				
			||||||
        var data = await _cache.GetOrAddCachedDataAsync($"nadeko_rip_{text}_{imgUrl}",
 | 
					 | 
				
			||||||
            GetRipPictureFactory,
 | 
					 | 
				
			||||||
            (text, imgUrl),
 | 
					 | 
				
			||||||
            TimeSpan.FromDays(1));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return data.ToStream();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private void DrawAvatar(Image bg, Image avatarImage)
 | 
					    private void DrawAvatar(Image bg, Image avatarImage)
 | 
				
			||||||
        => bg.Mutate(x => x.Grayscale().DrawImage(avatarImage, new(83, 139), new GraphicsOptions()));
 | 
					        => 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>(await _imgs.GetRipBgAsync());
 | 
				
			||||||
        using var bg = Image.Load<Rgba32>(_imgs.Rip.ToArray());
 | 
					        var result = await _c.GetImageDataAsync(avatarUrl);
 | 
				
			||||||
        var (succ, data) = (false, (byte[])null); //await _cache.TryGetImageDataAsync(avatarUrl);
 | 
					        if (!result.TryPickT0(out var data, out _))
 | 
				
			||||||
        if (!succ)
 | 
					 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            using var http = _httpFactory.CreateClient();
 | 
					            using var http = _httpFactory.CreateClient();
 | 
				
			||||||
            data = await http.GetByteArrayAsync(avatarUrl);
 | 
					            data = await http.GetByteArrayAsync(avatarUrl);
 | 
				
			||||||
            using (var avatarImg = Image.Load<Rgba32>(data))
 | 
					            using (var avatarImg = Image.Load<Rgba32>(data))
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                avatarImg.Mutate(x => x.Resize(85, 85).ApplyRoundedCorners(42));
 | 
					                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();
 | 
					                data = avStream.ToArray();
 | 
				
			||||||
                DrawAvatar(bg, avatarImg);
 | 
					                DrawAvatar(bg, avatarImg);
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            await _cache.SetImageDataAsync(avatarUrl, data);
 | 
					            await _c.SetImageDataAsync(avatarUrl, data);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        else
 | 
					        else
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -128,7 +118,7 @@ public class SearchesService : INService
 | 
				
			|||||||
            new(25, 225)));
 | 
					            new(25, 225)));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        //flowa
 | 
					        //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()));
 | 
					            bg.Mutate(x => x.DrawImage(flowers, new(0, 0), new GraphicsOptions()));
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -137,13 +127,12 @@ public class SearchesService : INService
 | 
				
			|||||||
        return stream.ToArray();
 | 
					        return stream.ToArray();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Task<WeatherData> GetWeatherDataAsync(string query)
 | 
					    public async Task<WeatherData> GetWeatherDataAsync(string query)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        query = query.Trim().ToLowerInvariant();
 | 
					        query = query.Trim().ToLowerInvariant();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return _cache.GetOrAddCachedDataAsync($"nadeko_weather_{query}",
 | 
					        return await _c.GetOrAddAsync(new($"nadeko_weather_{query}"),
 | 
				
			||||||
            GetWeatherDataFactory,
 | 
					            async () => await GetWeatherDataFactory(query),
 | 
				
			||||||
            query,
 | 
					 | 
				
			||||||
            TimeSpan.FromHours(3));
 | 
					            TimeSpan.FromHours(3));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -184,26 +173,28 @@ public class SearchesService : INService
 | 
				
			|||||||
        if (string.IsNullOrEmpty(query))
 | 
					        if (string.IsNullOrEmpty(query))
 | 
				
			||||||
            return (default, TimeErrors.InvalidInput);
 | 
					            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);
 | 
					            return (default, TimeErrors.ApiKeyMissing);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            using var http = _httpFactory.CreateClient();
 | 
					            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?"
 | 
					                    var url = "https://eu1.locationiq.com/v1/search.php?"
 | 
				
			||||||
                              + (string.IsNullOrWhiteSpace(_creds.LocationIqApiKey)
 | 
					                              + (string.IsNullOrWhiteSpace(locIqKey)
 | 
				
			||||||
                                  ? "key="
 | 
					                                  ? "key="
 | 
				
			||||||
                                  : $"key={_creds.LocationIqApiKey}&")
 | 
					                                  : $"key={locIqKey}&")
 | 
				
			||||||
                              + $"q={Uri.EscapeDataString(query)}&"
 | 
					                              + $"q={Uri.EscapeDataString(query)}&"
 | 
				
			||||||
                              + "format=json";
 | 
					                              + "format=json";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    var res = http.GetStringAsync(url);
 | 
					                    var res = await http.GetStringAsync(url);
 | 
				
			||||||
                    return res;
 | 
					                    return res;
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                "",
 | 
					 | 
				
			||||||
                TimeSpan.FromHours(1));
 | 
					                TimeSpan.FromHours(1));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            var responses = JsonConvert.DeserializeObject<LocationIqResponse[]>(res);
 | 
					            var responses = JsonConvert.DeserializeObject<LocationIqResponse[]>(res);
 | 
				
			||||||
@@ -217,7 +208,7 @@ public class SearchesService : INService
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
            using var req = new HttpRequestMessage(HttpMethod.Get,
 | 
					            using var req = new HttpRequestMessage(HttpMethod.Get,
 | 
				
			||||||
                "http://api.timezonedb.com/v2.1/get-time-zone?"
 | 
					                "http://api.timezonedb.com/v2.1/get-time-zone?"
 | 
				
			||||||
                + $"key={_creds.TimezoneDbApiKey}"
 | 
					                + $"key={tzDbKey}"
 | 
				
			||||||
                + $"&format=json"
 | 
					                + $"&format=json"
 | 
				
			||||||
                + $"&by=position"
 | 
					                + $"&by=position"
 | 
				
			||||||
                + $"&lat={geoData.Lat}"
 | 
					                + $"&lat={geoData.Lat}"
 | 
				
			||||||
@@ -315,9 +306,8 @@ public class SearchesService : INService
 | 
				
			|||||||
    public async Task<MtgData> GetMtgCardAsync(string search)
 | 
					    public async Task<MtgData> GetMtgCardAsync(string search)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        search = search.Trim().ToLowerInvariant();
 | 
					        search = search.Trim().ToLowerInvariant();
 | 
				
			||||||
        var data = await _cache.GetOrAddCachedDataAsync($"nadeko_mtg_{search}",
 | 
					        var data = await _c.GetOrAddAsync(new($"mtg:{search}"),
 | 
				
			||||||
            GetMtgCardFactory,
 | 
					            async () => await GetMtgCardFactory(search),
 | 
				
			||||||
            search,
 | 
					 | 
				
			||||||
            TimeSpan.FromDays(1));
 | 
					            TimeSpan.FromDays(1));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (data is null || data.Length == 0)
 | 
					        if (data is null || data.Length == 0)
 | 
				
			||||||
@@ -368,12 +358,11 @@ public class SearchesService : INService
 | 
				
			|||||||
        return await cards.Select(GetMtgDataAsync).WhenAll();
 | 
					        return await cards.Select(GetMtgDataAsync).WhenAll();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Task<HearthstoneCardData> GetHearthstoneCardDataAsync(string name)
 | 
					    public async Task<HearthstoneCardData> GetHearthstoneCardDataAsync(string name)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        name = name.ToLowerInvariant();
 | 
					        name = name.ToLowerInvariant();
 | 
				
			||||||
        return _cache.GetOrAddCachedDataAsync($"nadeko_hearthstone_{name}",
 | 
					        return await _c.GetOrAddAsync($"hearthstone:{name}",
 | 
				
			||||||
            HearthstoneCardDataFactory,
 | 
					            () => HearthstoneCardDataFactory(name),
 | 
				
			||||||
            name,
 | 
					 | 
				
			||||||
            TimeSpan.FromDays(1));
 | 
					            TimeSpan.FromDays(1));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -381,7 +370,7 @@ public class SearchesService : INService
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        using var http = _httpFactory.CreateClient();
 | 
					        using var http = _httpFactory.CreateClient();
 | 
				
			||||||
        http.DefaultRequestHeaders.Clear();
 | 
					        http.DefaultRequestHeaders.Clear();
 | 
				
			||||||
        http.DefaultRequestHeaders.Add("x-rapidapi-key", _creds.RapidApiKey);
 | 
					        http.DefaultRequestHeaders.Add("x-rapidapi-key", _creds.GetCreds().RapidApiKey);
 | 
				
			||||||
        try
 | 
					        try
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var response = await http.GetStringAsync("https://omgvamp-hearthstone-v1.p.rapidapi.com/"
 | 
					            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();
 | 
					        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)
 | 
					    private async Task<OmdbMovie> GetMovieDataFactory(string name)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        using var http = _httpFactory.CreateClient();
 | 
					        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(' ', '+')));
 | 
					            name.Trim().Replace(' ', '+')));
 | 
				
			||||||
        var movie = JsonConvert.DeserializeObject<OmdbMovie>(res);
 | 
					        var movie = JsonConvert.DeserializeObject<OmdbMovie>(res);
 | 
				
			||||||
        if (movie?.Title is null)
 | 
					        if (movie?.Title is null)
 | 
				
			||||||
@@ -432,10 +427,11 @@ public class SearchesService : INService
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        const string steamGameIdsKey = "steam_names_to_appid";
 | 
					        const string steamGameIdsKey = "steam_names_to_appid";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var gamesMap = await _cache.GetOrAddCachedDataAsync(steamGameIdsKey,
 | 
					        var gamesMap = await _c.GetOrAddAsync(new(steamGameIdsKey),
 | 
				
			||||||
            async _ =>
 | 
					            async () =>
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                using var http = _httpFactory.CreateClient();
 | 
					                using var http = _httpFactory.CreateClient();
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
                // https://api.steampowered.com/ISteamApps/GetAppList/v2/
 | 
					                // https://api.steampowered.com/ISteamApps/GetAppList/v2/
 | 
				
			||||||
                var gamesStr = await http.GetStringAsync("https://api.steampowered.com/ISteamApps/GetAppList/v2/");
 | 
					                var gamesStr = await http.GetStringAsync("https://api.steampowered.com/ISteamApps/GetAppList/v2/");
 | 
				
			||||||
                var apps = JsonConvert
 | 
					                var apps = JsonConvert
 | 
				
			||||||
@@ -446,23 +442,18 @@ public class SearchesService : INService
 | 
				
			|||||||
                                   {
 | 
					                                   {
 | 
				
			||||||
                                       apps = new List<SteamGameId>()
 | 
					                                       apps = new List<SteamGameId>()
 | 
				
			||||||
                                   }
 | 
					                                   }
 | 
				
			||||||
                               })
 | 
					                               })!
 | 
				
			||||||
                           .applist.apps;
 | 
					                           .applist.apps;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                return apps.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 | 
					                return apps.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 | 
				
			||||||
                           .GroupBy(x => x.Name)
 | 
					                           .GroupBy(x => x.Name)
 | 
				
			||||||
                           .ToDictionary(x => x.Key, x => x.First().AppId);
 | 
					                           .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));
 | 
					            TimeSpan.FromHours(24));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (gamesMap is null)
 | 
					        if (gamesMap is null)
 | 
				
			||||||
            return -1;
 | 
					            return -1;
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
 | 
					 | 
				
			||||||
        query = query.Trim();
 | 
					        query = query.Trim();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var keyList = gamesMap.Keys.ToList();
 | 
					        var keyList = gamesMap.Keys.ToList();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,6 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
 | 
					using LinqToDB;
 | 
				
			||||||
 | 
					using LinqToDB.EntityFrameworkCore;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					using Microsoft.EntityFrameworkCore;
 | 
				
			||||||
using NadekoBot.Common.ModuleBehaviors;
 | 
					using NadekoBot.Common.ModuleBehaviors;
 | 
				
			||||||
using NadekoBot.Db;
 | 
					using NadekoBot.Db;
 | 
				
			||||||
@@ -6,10 +8,98 @@ using NadekoBot.Db.Models;
 | 
				
			|||||||
using NadekoBot.Modules.Searches.Common;
 | 
					using NadekoBot.Modules.Searches.Common;
 | 
				
			||||||
using NadekoBot.Modules.Searches.Common.StreamNotifications;
 | 
					using NadekoBot.Modules.Searches.Common.StreamNotifications;
 | 
				
			||||||
using NadekoBot.Services.Database.Models;
 | 
					using NadekoBot.Services.Database.Models;
 | 
				
			||||||
using StackExchange.Redis;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace NadekoBot.Modules.Searches.Services;
 | 
					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
 | 
					public sealed class StreamNotificationService : INService, IReadyExecutor
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private readonly DbService _db;
 | 
					    private readonly DbService _db;
 | 
				
			||||||
@@ -29,18 +119,22 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
 | 
				
			|||||||
    private readonly IPubSub _pubSub;
 | 
					    private readonly IPubSub _pubSub;
 | 
				
			||||||
    private readonly IEmbedBuilderService _eb;
 | 
					    private readonly IEmbedBuilderService _eb;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private readonly TypedKey<List<StreamData>> _streamsOnlineKey;
 | 
					    public TypedKey<List<StreamData>> StreamsOnlineKey { get; }
 | 
				
			||||||
    private readonly TypedKey<List<StreamData>> _streamsOfflineKey;
 | 
					    public TypedKey<List<StreamData>> StreamsOfflineKey { get; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private readonly TypedKey<FollowStreamPubData> _streamFollowKey;
 | 
					    private readonly TypedKey<FollowStreamPubData> _streamFollowKey;
 | 
				
			||||||
    private readonly TypedKey<FollowStreamPubData> _streamUnfollowKey;
 | 
					    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(
 | 
					    public StreamNotificationService(
 | 
				
			||||||
        DbService db,
 | 
					        DbService db,
 | 
				
			||||||
        DiscordSocketClient client,
 | 
					        DiscordSocketClient client,
 | 
				
			||||||
        IBotStrings strings,
 | 
					        IBotStrings strings,
 | 
				
			||||||
        ConnectionMultiplexer redis,
 | 
					 | 
				
			||||||
        IBotCredsProvider creds,
 | 
					        IBotCredsProvider creds,
 | 
				
			||||||
        IHttpClientFactory httpFactory,
 | 
					        IHttpClientFactory httpFactory,
 | 
				
			||||||
        Bot bot,
 | 
					        Bot bot,
 | 
				
			||||||
@@ -52,11 +146,11 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
 | 
				
			|||||||
        _strings = strings;
 | 
					        _strings = strings;
 | 
				
			||||||
        _pubSub = pubSub;
 | 
					        _pubSub = pubSub;
 | 
				
			||||||
        _eb = eb;
 | 
					        _eb = eb;
 | 
				
			||||||
        _redis = redis;
 | 
					 | 
				
			||||||
        _streamTracker = new(httpFactory, creds, redis, creds.GetCreds().RedisKey(), client.ShardId == 0);
 | 
					 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        _streamsOnlineKey = new("streams.online");
 | 
					        _streamTracker = new(httpFactory, creds);
 | 
				
			||||||
        _streamsOfflineKey = new("streams.offline");
 | 
					
 | 
				
			||||||
 | 
					        StreamsOnlineKey = new("streams.online");
 | 
				
			||||||
 | 
					        StreamsOfflineKey = new("streams.offline");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        _streamFollowKey = new("stream.follow");
 | 
					        _streamFollowKey = new("stream.follow");
 | 
				
			||||||
        _streamUnfollowKey = new("stream.unfollow");
 | 
					        _streamUnfollowKey = new("stream.unfollow");
 | 
				
			||||||
@@ -100,7 +194,7 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
 | 
				
			|||||||
                var allFollowedStreams = uow.Set<FollowedStream>().AsQueryable().ToList();
 | 
					                var allFollowedStreams = uow.Set<FollowedStream>().AsQueryable().ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                foreach (var fs in allFollowedStreams)
 | 
					                foreach (var fs in allFollowedStreams)
 | 
				
			||||||
                    _streamTracker.CacheAddData(fs.CreateKey(), null, false);
 | 
					                    _streamTracker.AddLastData(fs.CreateKey(), null, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                _trackCounter = allFollowedStreams.GroupBy(x => new
 | 
					                _trackCounter = allFollowedStreams.GroupBy(x => new
 | 
				
			||||||
                                                  {
 | 
					                                                  {
 | 
				
			||||||
@@ -112,8 +206,8 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        _pubSub.Sub(_streamsOfflineKey, HandleStreamsOffline);
 | 
					        _pubSub.Sub(StreamsOfflineKey, HandleStreamsOffline);
 | 
				
			||||||
        _pubSub.Sub(_streamsOnlineKey, HandleStreamsOnline);
 | 
					        _pubSub.Sub(StreamsOnlineKey, HandleStreamsOnline);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (client.ShardId == 0)
 | 
					        if (client.ShardId == 0)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -186,7 +280,7 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
 | 
				
			|||||||
    /// </summary>
 | 
					    /// </summary>
 | 
				
			||||||
    private ValueTask HandleFollowStream(FollowStreamPubData info)
 | 
					    private ValueTask HandleFollowStream(FollowStreamPubData info)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _streamTracker.CacheAddData(info.Key, null, false);
 | 
					        _streamTracker.AddLastData(info.Key, null, false);
 | 
				
			||||||
        lock (_shardLock)
 | 
					        lock (_shardLock)
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var key = info.Key;
 | 
					            var key = info.Key;
 | 
				
			||||||
@@ -251,45 +345,8 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
 | 
				
			|||||||
                      .WhenAll();
 | 
					                      .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)
 | 
					    private async ValueTask HandleStreamsOnline(List<StreamData> onlineStreams)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -331,14 +388,11 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
 | 
				
			|||||||
                {
 | 
					                {
 | 
				
			||||||
                    var pairs = messages
 | 
					                    var pairs = messages
 | 
				
			||||||
                                .Where(x => x != default)
 | 
					                                .Where(x => x != default)
 | 
				
			||||||
                                .Select(x => (RedisValue)$"{x.Item1},{x.Item2}")
 | 
					                                .Select(x => (x.Item1, x.Item2))
 | 
				
			||||||
                                .ToArray();
 | 
					                                .ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    if (pairs.Length > 0)
 | 
					                    if (pairs.Count > 0)
 | 
				
			||||||
                    {
 | 
					                        await OnlineMessagesSent(key.Type, key.Name, pairs);
 | 
				
			||||||
                        var db = _redis.GetDatabase();
 | 
					 | 
				
			||||||
                        await db.ListRightPushAsync($"streams_online_del:{key}", pairs);
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                catch
 | 
					                catch
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
@@ -349,10 +403,10 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private Task OnStreamsOnline(List<StreamData> data)
 | 
					    private Task OnStreamsOnline(List<StreamData> data)
 | 
				
			||||||
        => _pubSub.Pub(_streamsOnlineKey, data);
 | 
					        => _pubSub.Pub(StreamsOnlineKey, data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private Task OnStreamsOffline(List<StreamData> data)
 | 
					    private Task OnStreamsOffline(List<StreamData> data)
 | 
				
			||||||
        => _pubSub.Pub(_streamsOfflineKey, data);
 | 
					        => _pubSub.Pub(StreamsOfflineKey, data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private Task ClientOnJoinedGuild(GuildConfig guildConfig)
 | 
					    private Task ClientOnJoinedGuild(GuildConfig guildConfig)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,10 +6,9 @@ namespace NadekoBot.Modules.Searches.Common;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public readonly struct StreamDataKey
 | 
					public readonly struct StreamDataKey
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public FollowedStream.FType Type { get; }
 | 
					    public FollowedStream.FType Type { get; init; }
 | 
				
			||||||
    public string Name { get; }
 | 
					    public string Name { get; init; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [JsonConstructor]
 | 
					 | 
				
			||||||
    public StreamDataKey(FollowedStream.FType type, string name)
 | 
					    public StreamDataKey(FollowedStream.FType type, string name)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        Type = type;
 | 
					        Type = type;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,5 @@
 | 
				
			|||||||
using NadekoBot.Db.Models;
 | 
					using NadekoBot.Db.Models;
 | 
				
			||||||
using NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
 | 
					using NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
 | 
				
			||||||
using Newtonsoft.Json;
 | 
					 | 
				
			||||||
using StackExchange.Redis;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace NadekoBot.Modules.Searches.Common.StreamNotifications;
 | 
					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> OnStreamsOffline = _ => Task.CompletedTask;
 | 
				
			||||||
    public event Func<List<StreamData>, Task> OnStreamsOnline = _ => 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 HashSet<(FollowedStream.FType, string)> _offlineBuffer;
 | 
				
			||||||
 | 
					    private readonly ConcurrentDictionary<StreamDataKey, StreamData?> _cache = new();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public NotifChecker(
 | 
					    public NotifChecker(
 | 
				
			||||||
        IHttpClientFactory httpClientFactory,
 | 
					        IHttpClientFactory httpClientFactory,
 | 
				
			||||||
        IBotCredsProvider credsProvider,
 | 
					        IBotCredsProvider credsProvider)
 | 
				
			||||||
        ConnectionMultiplexer multi,
 | 
					 | 
				
			||||||
        string uniqueCacheKey,
 | 
					 | 
				
			||||||
        bool isMaster)
 | 
					 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _multi = multi;
 | 
					        _streamProviders = new Dictionary<FollowedStream.FType, Provider>()
 | 
				
			||||||
        _key = $"{uniqueCacheKey}_followed_streams_data";
 | 
					 | 
				
			||||||
        _streamProviders = new()
 | 
					 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            { FollowedStream.FType.Twitch, new TwitchHelixProvider(httpClientFactory, credsProvider) },
 | 
					            { FollowedStream.FType.Twitch, new TwitchHelixProvider(httpClientFactory, credsProvider) },
 | 
				
			||||||
            { FollowedStream.FType.Picarto, new PicartoProvider(httpClientFactory) },
 | 
					            { FollowedStream.FType.Picarto, new PicartoProvider(httpClientFactory) },
 | 
				
			||||||
            { FollowedStream.FType.Trovo, new TrovoProvider(httpClientFactory, credsProvider) }
 | 
					            { FollowedStream.FType.Trovo, new TrovoProvider(httpClientFactory, credsProvider) }
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
        _offlineBuffer = new();
 | 
					        _offlineBuffer = new();
 | 
				
			||||||
        if (isMaster)
 | 
					 | 
				
			||||||
            CacheClearAllData();
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // gets all streams which have been failing for more than the provided timespan
 | 
					    // gets all streams which have been failing for more than the provided timespan
 | 
				
			||||||
@@ -61,7 +51,7 @@ public class NotifChecker
 | 
				
			|||||||
            {
 | 
					            {
 | 
				
			||||||
                try
 | 
					                try
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    var allStreamData = CacheGetAllData();
 | 
					                    var allStreamData = GetAllData();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    var oldStreamDataDict = allStreamData
 | 
					                    var oldStreamDataDict = allStreamData
 | 
				
			||||||
                                            // group by type
 | 
					                                            // group by type
 | 
				
			||||||
@@ -101,7 +91,7 @@ public class NotifChecker
 | 
				
			|||||||
                            || !typeDict.TryGetValue(key.Name, out var oldData)
 | 
					                            || !typeDict.TryGetValue(key.Name, out var oldData)
 | 
				
			||||||
                            || oldData is null)
 | 
					                            || oldData is null)
 | 
				
			||||||
                        {
 | 
					                        {
 | 
				
			||||||
                            CacheAddData(key, newData, true);
 | 
					                            AddLastData(key, newData, true);
 | 
				
			||||||
                            continue;
 | 
					                            continue;
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                        
 | 
					                        
 | 
				
			||||||
@@ -109,7 +99,7 @@ public class NotifChecker
 | 
				
			|||||||
                        if (string.IsNullOrWhiteSpace(newData.Game))
 | 
					                        if (string.IsNullOrWhiteSpace(newData.Game))
 | 
				
			||||||
                            newData.Game = oldData.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
 | 
					                        // if the stream is offline, we need to check if it was
 | 
				
			||||||
                        // marked as offline once previously
 | 
					                        // 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();
 | 
					        if (replace)
 | 
				
			||||||
        return db.HashSet(_key,
 | 
					        {
 | 
				
			||||||
            JsonConvert.SerializeObject(key),
 | 
					            _cache[key] = data;
 | 
				
			||||||
            JsonConvert.SerializeObject(data),
 | 
					            return true;
 | 
				
			||||||
            replace ? When.Always : When.NotExists);
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
    public void CacheDeleteData(StreamDataKey key)
 | 
					        return _cache.TryAdd(key, data);
 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = _multi.GetDatabase();
 | 
					 | 
				
			||||||
        db.HashDelete(_key, JsonConvert.SerializeObject(key));
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public void CacheClearAllData()
 | 
					    public void DeleteLastData(StreamDataKey key)
 | 
				
			||||||
    {
 | 
					        => _cache.TryRemove(key, out _);
 | 
				
			||||||
        var db = _multi.GetDatabase();
 | 
					 | 
				
			||||||
        db.KeyDelete(_key);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Dictionary<StreamDataKey, StreamData?> CacheGetAllData()
 | 
					    public Dictionary<StreamDataKey, StreamData?> GetAllData()
 | 
				
			||||||
    {
 | 
					        => _cache.ToDictionary(x => x.Key, x => x.Value);
 | 
				
			||||||
        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 async Task<StreamData?> GetStreamDataByUrlAsync(string url)
 | 
					    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
 | 
					        // 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
 | 
					        // 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)
 | 
					    public void UntrackStreamByKey(in StreamDataKey key)
 | 
				
			||||||
        => CacheDeleteData(key);
 | 
					        => DeleteLastData(key);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -31,9 +31,10 @@ public sealed class PatronageService
 | 
				
			|||||||
    private readonly DiscordSocketClient _client;
 | 
					    private readonly DiscordSocketClient _client;
 | 
				
			||||||
    private readonly ISubscriptionHandler _subsHandler;
 | 
					    private readonly ISubscriptionHandler _subsHandler;
 | 
				
			||||||
    private readonly IEmbedBuilderService _eb;
 | 
					    private readonly IEmbedBuilderService _eb;
 | 
				
			||||||
    private readonly ConnectionMultiplexer _redis;
 | 
					    private static readonly TypedKey<long> _quotaKey 
 | 
				
			||||||
    private readonly IBotCredentials _creds;
 | 
					        = new($"quota:last_hourly_reset");
 | 
				
			||||||
    private readonly TypedKey<bool> _quotaKey;
 | 
					
 | 
				
			||||||
 | 
					    private readonly IBotCache _cache;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public PatronageService(
 | 
					    public PatronageService(
 | 
				
			||||||
        PatronageConfig pConf,
 | 
					        PatronageConfig pConf,
 | 
				
			||||||
@@ -41,18 +42,14 @@ public sealed class PatronageService
 | 
				
			|||||||
        DiscordSocketClient client,
 | 
					        DiscordSocketClient client,
 | 
				
			||||||
        ISubscriptionHandler subsHandler,
 | 
					        ISubscriptionHandler subsHandler,
 | 
				
			||||||
        IEmbedBuilderService eb,
 | 
					        IEmbedBuilderService eb,
 | 
				
			||||||
        ConnectionMultiplexer redis,
 | 
					        IBotCache cache)
 | 
				
			||||||
        IBotCredentials creds)
 | 
					 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _pConf = pConf;
 | 
					        _pConf = pConf;
 | 
				
			||||||
        _db = db;
 | 
					        _db = db;
 | 
				
			||||||
        _client = client;
 | 
					        _client = client;
 | 
				
			||||||
        _subsHandler = subsHandler;
 | 
					        _subsHandler = subsHandler;
 | 
				
			||||||
        _eb = eb;
 | 
					        _eb = eb;
 | 
				
			||||||
        _redis = redis;
 | 
					        _cache = cache;
 | 
				
			||||||
        _creds = creds;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        _quotaKey = new TypedKey<bool>($"{_creds.RedisKey()}:quota:last_hourly_reset");
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Task OnReadyAsync()
 | 
					    public Task OnReadyAsync()
 | 
				
			||||||
@@ -101,11 +98,10 @@ public sealed class PatronageService
 | 
				
			|||||||
                var now = DateTime.UtcNow;
 | 
					                var now = DateTime.UtcNow;
 | 
				
			||||||
                var lastRun = DateTime.MinValue;
 | 
					                var lastRun = DateTime.MinValue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                var rdb = _redis.GetDatabase();
 | 
					                var result = await _cache.GetAsync(_quotaKey);
 | 
				
			||||||
                var lastVal = await rdb.StringGetAsync(_quotaKey.Key);
 | 
					                if (result.TryGetValue(out var lastVal) && lastVal != default)
 | 
				
			||||||
                if (lastVal != default)
 | 
					 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    lastRun = DateTime.FromBinary((long)lastVal);
 | 
					                    lastRun = DateTime.FromBinary(lastVal);
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                var nowDate = now.ToDateOnly();
 | 
					                var nowDate = now.ToDateOnly();
 | 
				
			||||||
@@ -130,8 +126,6 @@ public sealed class PatronageService
 | 
				
			|||||||
                                 HourlyCount = 0,
 | 
					                                 HourlyCount = 0,
 | 
				
			||||||
                                 DailyCount = 0,
 | 
					                                 DailyCount = 0,
 | 
				
			||||||
                             });
 | 
					                             });
 | 
				
			||||||
 | 
					 | 
				
			||||||
                    await rdb.StringSetAsync(_quotaKey.Key, true);
 | 
					 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas
 | 
					                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
 | 
					                // 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();
 | 
					                await tran.CommitAsync();
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            catch (Exception ex)
 | 
					            catch (Exception ex)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,23 +1,24 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
using NadekoBot.Common.ModuleBehaviors;
 | 
					using NadekoBot.Common.ModuleBehaviors;
 | 
				
			||||||
using NadekoBot.Modules.Utility.Common;
 | 
					using NadekoBot.Modules.Utility.Common;
 | 
				
			||||||
using Newtonsoft.Json;
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace NadekoBot.Modules.Utility.Services;
 | 
					namespace NadekoBot.Modules.Utility.Services;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class ConverterService : INService, IReadyExecutor
 | 
					public class ConverterService : INService, IReadyExecutor
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public ConvertUnit[] Units
 | 
					    private static readonly TypedKey<List<ConvertUnit>> _convertKey =
 | 
				
			||||||
        => _cache.Redis.GetDatabase().StringGet("converter_units").ToString().MapJson<ConvertUnit[]>();
 | 
					        new("convert:units");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private readonly TimeSpan _updateInterval = new(12, 0, 0);
 | 
					    private readonly TimeSpan _updateInterval = new(12, 0, 0);
 | 
				
			||||||
    private readonly DiscordSocketClient _client;
 | 
					    private readonly DiscordSocketClient _client;
 | 
				
			||||||
    private readonly IDataCache _cache;
 | 
					    private readonly IBotCache _cache;
 | 
				
			||||||
    private readonly IHttpClientFactory _httpFactory;
 | 
					    private readonly IHttpClientFactory _httpFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public ConverterService(
 | 
					    public ConverterService(
 | 
				
			||||||
        DiscordSocketClient client,
 | 
					        DiscordSocketClient client,
 | 
				
			||||||
        IDataCache cache,
 | 
					        IBotCache cache,
 | 
				
			||||||
        IHttpClientFactory factory)
 | 
					        IHttpClientFactory factory)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _client = client;
 | 
					        _client = client;
 | 
				
			||||||
@@ -48,7 +49,7 @@ public class ConverterService : INService, IReadyExecutor
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        using var http = _httpFactory.CreateClient();
 | 
					        using var http = _httpFactory.CreateClient();
 | 
				
			||||||
        var res = await http.GetStringAsync("https://convertapi.nadeko.bot/latest");
 | 
					        var res = await http.GetStringAsync("https://convertapi.nadeko.bot/latest");
 | 
				
			||||||
        return JsonConvert.DeserializeObject<Rates>(res);
 | 
					        return JsonSerializer.Deserialize<Rates>(res);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private async Task UpdateCurrency()
 | 
					    private async Task UpdateCurrency()
 | 
				
			||||||
@@ -61,29 +62,38 @@ public class ConverterService : INService, IReadyExecutor
 | 
				
			|||||||
            Modifier = decimal.One,
 | 
					            Modifier = decimal.One,
 | 
				
			||||||
            UnitType = unitTypeString
 | 
					            UnitType = unitTypeString
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
        var range = currencyRates.ConversionRates.Select(u => new ConvertUnit
 | 
					        var units = currencyRates.ConversionRates.Select(u => new ConvertUnit
 | 
				
			||||||
                                 {
 | 
					                                 {
 | 
				
			||||||
                                     Triggers = new[] { u.Key },
 | 
					                                     Triggers = new[] { u.Key },
 | 
				
			||||||
                                     Modifier = u.Value,
 | 
					                                     Modifier = u.Value,
 | 
				
			||||||
                                     UnitType = unitTypeString
 | 
					                                     UnitType = unitTypeString
 | 
				
			||||||
                                 })
 | 
					                                 })
 | 
				
			||||||
                                 .ToArray();
 | 
					                                 .ToList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var fileData = JsonConvert.DeserializeObject<ConvertUnit[]>(File.ReadAllText("data/units.json"))
 | 
					        var stream =  File.OpenRead("data/units.json");
 | 
				
			||||||
                                  ?.Where(x => x.UnitType != "currency");
 | 
					        var defaultUnits = await JsonSerializer.DeserializeAsync<ConvertUnit[]>(stream);
 | 
				
			||||||
        if (fileData is null)
 | 
					        if(defaultUnits is not null)
 | 
				
			||||||
            return;
 | 
					            units.AddRange(defaultUnits);
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        var data = JsonConvert.SerializeObject(range.Append(baseType).Concat(fileData).ToList());
 | 
					        units.Add(baseType);
 | 
				
			||||||
        _cache.Redis.GetDatabase().StringSet("converter_units", data);
 | 
					        
 | 
				
			||||||
 | 
					        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
 | 
					public class Rates
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
 | 
					    [JsonPropertyName("base")]
 | 
				
			||||||
    public string Base { get; set; }
 | 
					    public string Base { get; set; }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    [JsonPropertyName("date")]
 | 
				
			||||||
    public DateTime Date { get; set; }
 | 
					    public DateTime Date { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [JsonProperty("rates")]
 | 
					    [JsonPropertyName("rates")]
 | 
				
			||||||
    public Dictionary<string, decimal> ConversionRates { get; set; }
 | 
					    public Dictionary<string, decimal> ConversionRates { get; set; }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -11,7 +11,7 @@ public partial class Utility
 | 
				
			|||||||
        [Cmd]
 | 
					        [Cmd]
 | 
				
			||||||
        public async partial Task ConvertList()
 | 
					        public async partial Task ConvertList()
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var units = _service.Units;
 | 
					            var units = await _service.GetUnitsAsync();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            var embed = _eb.Create().WithTitle(GetText(strs.convertlist)).WithOkColor();
 | 
					            var embed = _eb.Create().WithTitle(GetText(strs.convertlist)).WithOkColor();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -29,9 +29,10 @@ public partial class Utility
 | 
				
			|||||||
        [Priority(0)]
 | 
					        [Priority(0)]
 | 
				
			||||||
        public async partial Task Convert(string origin, string target, decimal value)
 | 
					        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()));
 | 
					                => 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()));
 | 
					                => x.Triggers.Select(y => y.ToUpperInvariant()).Contains(target.ToUpperInvariant()));
 | 
				
			||||||
            if (originUnit is null || targetUnit is null)
 | 
					            if (originUnit is null || targetUnit is null)
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,10 +23,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
    public const int XP_REQUIRED_LVL_1 = 36;
 | 
					    public const int XP_REQUIRED_LVL_1 = 36;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private readonly DbService _db;
 | 
					    private readonly DbService _db;
 | 
				
			||||||
    private readonly CommandHandler _cmd;
 | 
					 | 
				
			||||||
    private readonly IImageCache _images;
 | 
					    private readonly IImageCache _images;
 | 
				
			||||||
    private readonly IBotStrings _strings;
 | 
					    private readonly IBotStrings _strings;
 | 
				
			||||||
    private readonly IDataCache _cache;
 | 
					 | 
				
			||||||
    private readonly FontProvider _fonts;
 | 
					    private readonly FontProvider _fonts;
 | 
				
			||||||
    private readonly IBotCredentials _creds;
 | 
					    private readonly IBotCredentials _creds;
 | 
				
			||||||
    private readonly ICurrencyService _cs;
 | 
					    private readonly ICurrencyService _cs;
 | 
				
			||||||
@@ -45,14 +43,15 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private readonly TypedKey<bool> _xpTemplateReloadKey;
 | 
					    private readonly TypedKey<bool> _xpTemplateReloadKey;
 | 
				
			||||||
    private readonly IPatronageService _ps;
 | 
					    private readonly IPatronageService _ps;
 | 
				
			||||||
 | 
					    private readonly IBotCache _c;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public XpService(
 | 
					    public XpService(
 | 
				
			||||||
        DiscordSocketClient client,
 | 
					        DiscordSocketClient client,
 | 
				
			||||||
        CommandHandler cmd,
 | 
					 | 
				
			||||||
        Bot bot,
 | 
					        Bot bot,
 | 
				
			||||||
        DbService db,
 | 
					        DbService db,
 | 
				
			||||||
        IBotStrings strings,
 | 
					        IBotStrings strings,
 | 
				
			||||||
        IDataCache cache,
 | 
					        IImageCache images,
 | 
				
			||||||
 | 
					        IBotCache c,
 | 
				
			||||||
        FontProvider fonts,
 | 
					        FontProvider fonts,
 | 
				
			||||||
        IBotCredentials creds,
 | 
					        IBotCredentials creds,
 | 
				
			||||||
        ICurrencyService cs,
 | 
					        ICurrencyService cs,
 | 
				
			||||||
@@ -63,10 +62,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
        IPatronageService ps)
 | 
					        IPatronageService ps)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _db = db;
 | 
					        _db = db;
 | 
				
			||||||
        _cmd = cmd;
 | 
					        _images = images;
 | 
				
			||||||
        _images = cache.LocalImages;
 | 
					 | 
				
			||||||
        _strings = strings;
 | 
					        _strings = strings;
 | 
				
			||||||
        _cache = cache;
 | 
					 | 
				
			||||||
        _fonts = fonts;
 | 
					        _fonts = fonts;
 | 
				
			||||||
        _creds = creds;
 | 
					        _creds = creds;
 | 
				
			||||||
        _cs = cs;
 | 
					        _cs = cs;
 | 
				
			||||||
@@ -79,6 +76,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
        _client = client;
 | 
					        _client = client;
 | 
				
			||||||
        _xpTemplateReloadKey = new("xp.template.reload");
 | 
					        _xpTemplateReloadKey = new("xp.template.reload");
 | 
				
			||||||
        _ps = ps;
 | 
					        _ps = ps;
 | 
				
			||||||
 | 
					        _c = c;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        InternalReloadXpTemplate();
 | 
					        InternalReloadXpTemplate();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -453,10 +451,10 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private Task Client_OnGuildAvailable(SocketGuild guild)
 | 
					    private Task Client_OnGuildAvailable(SocketGuild guild)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        Task.Run(() =>
 | 
					        Task.Run(async () =>
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            foreach (var channel in guild.VoiceChannels)
 | 
					            foreach (var channel in guild.VoiceChannels)
 | 
				
			||||||
                ScanChannelForVoiceXp(channel);
 | 
					                await ScanChannelForVoiceXp(channel);
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return Task.CompletedTask;
 | 
					        return Task.CompletedTask;
 | 
				
			||||||
@@ -467,33 +465,33 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
        if (socketUser is not SocketGuildUser user || user.IsBot)
 | 
					        if (socketUser is not SocketGuildUser user || user.IsBot)
 | 
				
			||||||
            return Task.CompletedTask;
 | 
					            return Task.CompletedTask;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        _ = Task.Run(() =>
 | 
					        _ = Task.Run(async () =>
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            if (before.VoiceChannel is not null)
 | 
					            if (before.VoiceChannel is not null)
 | 
				
			||||||
                ScanChannelForVoiceXp(before.VoiceChannel);
 | 
					                await ScanChannelForVoiceXp(before.VoiceChannel);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (after.VoiceChannel is not null && after.VoiceChannel != 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)
 | 
					            else if (after.VoiceChannel is null)
 | 
				
			||||||
                // In this case, the user left the channel and the previous for loops didn't catch
 | 
					                // 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.
 | 
					                // 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;
 | 
					        return Task.CompletedTask;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private void ScanChannelForVoiceXp(SocketVoiceChannel channel)
 | 
					    private async Task ScanChannelForVoiceXp(SocketVoiceChannel channel)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        if (ShouldTrackVoiceChannel(channel))
 | 
					        if (ShouldTrackVoiceChannel(channel))
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            foreach (var user in channel.Users)
 | 
					            foreach (var user in channel.Users)
 | 
				
			||||||
                ScanUserForVoiceXp(user, channel);
 | 
					                await ScanUserForVoiceXp(user, channel);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        else
 | 
					        else
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            foreach (var user in channel.Users)
 | 
					            foreach (var user in channel.Users)
 | 
				
			||||||
                UserLeftVoiceChannel(user, channel);
 | 
					                await UserLeftVoiceChannel(user, channel);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -502,12 +500,12 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
    /// </summary>
 | 
					    /// </summary>
 | 
				
			||||||
    /// <param name="user"></param>
 | 
					    /// <param name="user"></param>
 | 
				
			||||||
    /// <param name="channel"></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))
 | 
					        if (UserParticipatingInVoiceChannel(user) && ShouldTrackXp(user, channel.Id))
 | 
				
			||||||
            UserJoinedVoiceChannel(user);
 | 
					            await UserJoinedVoiceChannel(user);
 | 
				
			||||||
        else
 | 
					        else
 | 
				
			||||||
            UserLeftVoiceChannel(user, channel);
 | 
					            await UserLeftVoiceChannel(user, channel);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private bool ShouldTrackVoiceChannel(SocketVoiceChannel channel)
 | 
					    private bool ShouldTrackVoiceChannel(SocketVoiceChannel channel)
 | 
				
			||||||
@@ -516,32 +514,31 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
    private bool UserParticipatingInVoiceChannel(SocketGuildUser user)
 | 
					    private bool UserParticipatingInVoiceChannel(SocketGuildUser user)
 | 
				
			||||||
        => !user.IsDeafened && !user.IsMuted && !user.IsSelfDeafened && !user.IsSelfMuted;
 | 
					        => !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();
 | 
					        var value = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        _cache.Redis.GetDatabase()
 | 
					        await _c.AddAsync(GetVoiceXpKey(user.Id),
 | 
				
			||||||
              .StringSet(key,
 | 
					 | 
				
			||||||
            value,
 | 
					            value,
 | 
				
			||||||
            TimeSpan.FromMinutes(_xpConfig.Data.VoiceMaxMinutes),
 | 
					            TimeSpan.FromMinutes(_xpConfig.Data.VoiceMaxMinutes),
 | 
				
			||||||
                  when: When.NotExists);
 | 
					            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 key = GetVoiceXpKey(user.Id);
 | 
				
			||||||
        var value = _cache.Redis.GetDatabase().StringGet(key);
 | 
					        var result = await _c.GetAsync(key);
 | 
				
			||||||
        _cache.Redis.GetDatabase().KeyDelete(key);
 | 
					        if (!await _c.RemoveAsync(key))
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Allow for if this function gets called multiple times when a user leaves a channel.
 | 
					        // 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;
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!value.TryParse(out long startUnixTime))
 | 
					        var dateStart = DateTimeOffset.FromUnixTimeSeconds(unixTime);
 | 
				
			||||||
            return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var dateStart = DateTimeOffset.FromUnixTimeSeconds(startUnixTime);
 | 
					 | 
				
			||||||
        var dateEnd = DateTimeOffset.UtcNow;
 | 
					        var dateEnd = DateTimeOffset.UtcNow;
 | 
				
			||||||
        var minutes = (dateEnd - dateStart).TotalMinutes;
 | 
					        var minutes = (dateEnd - dateStart).TotalMinutes;
 | 
				
			||||||
        var xp = _xpConfig.Data.VoiceXpPerMinute * minutes;
 | 
					        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)
 | 
					        if (arg.Author is not SocketGuildUser user || user.IsBot)
 | 
				
			||||||
            return Task.CompletedTask;
 | 
					            return Task.CompletedTask;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        _ = Task.Run(() =>
 | 
					        _ = Task.Run(async () =>
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            if (!ShouldTrackXp(user, arg.Channel.Id))
 | 
					            if (!ShouldTrackXp(user, arg.Channel.Id))
 | 
				
			||||||
                return;
 | 
					                return;
 | 
				
			||||||
@@ -593,7 +590,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
            if (xp <= 0)
 | 
					            if (xp <= 0)
 | 
				
			||||||
                return;
 | 
					                return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (!SetUserRewarded(user.Id))
 | 
					            if (!await SetUserRewardedAsync(user.Id))
 | 
				
			||||||
                return;
 | 
					                return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            _addMessageXp.Enqueue(new()
 | 
					            _addMessageXp.Enqueue(new()
 | 
				
			||||||
@@ -650,16 +647,14 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
        return Enumerable.Empty<ulong>();
 | 
					        return Enumerable.Empty<ulong>();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private bool SetUserRewarded(ulong userId)
 | 
					    private static TypedKey<bool> GetUserRewKey(ulong userId)
 | 
				
			||||||
    {
 | 
					        => new($"xp:user_gain:{userId}");
 | 
				
			||||||
        var r = _cache.Redis.GetDatabase();
 | 
					 | 
				
			||||||
        var key = $"{_creds.RedisKey()}_user_xp_gain_{userId}";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return r.StringSet(key,
 | 
					    private async Task<bool> SetUserRewardedAsync(ulong userId)
 | 
				
			||||||
 | 
					        => await _c.AddAsync(GetUserRewKey(userId),
 | 
				
			||||||
            true,
 | 
					            true,
 | 
				
			||||||
            TimeSpan.FromMinutes(_xpConfig.Data.MessageXpCooldown),
 | 
					            expiry: TimeSpan.FromMinutes(_xpConfig.Data.MessageXpCooldown),
 | 
				
			||||||
            when: When.NotExists);
 | 
					            overwrite: false);
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<FullUserStats> GetUserStatsAsync(IGuildUser user)
 | 
					    public async Task<FullUserStats> GetUserStatsAsync(IGuildUser user)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -782,7 +777,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
                }
 | 
					                }
 | 
				
			||||||
            }.WithFallbackFonts(_fonts.FallBackFonts);
 | 
					            }.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)
 | 
					            if (template.User.Name.Show)
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                var fontSize = (int)(template.User.Name.FontSize * 0.9);
 | 
					                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 avatarUrl = stats.User.RealAvatarUrl();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    var (succ, data) = await _cache.TryGetImageDataAsync(avatarUrl);
 | 
					                    var result = await _c.GetImageDataAsync(avatarUrl);
 | 
				
			||||||
                    if (!succ)
 | 
					                    if (!result.TryPickT0(out var data, out _))
 | 
				
			||||||
                    {
 | 
					                    {
 | 
				
			||||||
                        using (var http = _httpFactory.CreateClient())
 | 
					                        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);
 | 
					                    using var toDraw = Image.Load(data);
 | 
				
			||||||
@@ -1033,7 +1028,13 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
            return output;
 | 
					            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)
 | 
					    private async Task DrawFrame(Image<Rgba32> img, ulong userId)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var patron = await _ps.GetPatronAsync(userId);
 | 
					        var patron = await _ps.GetPatronAsync(userId);
 | 
				
			||||||
@@ -1103,8 +1104,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
 | 
				
			|||||||
            try
 | 
					            try
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                var imgUrl = new Uri(stats.User.Club.ImageUrl);
 | 
					                var imgUrl = new Uri(stats.User.Club.ImageUrl);
 | 
				
			||||||
                var (succ, data) = await _cache.TryGetImageDataAsync(imgUrl);
 | 
					                var result = await _c.GetImageDataAsync(imgUrl);
 | 
				
			||||||
                if (!succ)
 | 
					                if (!result.TryPickT0(out var data, out _))
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
                    using (var http = _httpFactory.CreateClient())
 | 
					                    using (var http = _httpFactory.CreateClient())
 | 
				
			||||||
                    using (var temp = await http.GetAsync(imgUrl, HttpCompletionOption.ResponseHeadersRead))
 | 
					                    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);
 | 
					                using var toDraw = Image.Load(data);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -52,6 +52,7 @@
 | 
				
			|||||||
        <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
 | 
					        <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
 | 
				
			||||||
        <PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" />
 | 
					        <PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" />
 | 
				
			||||||
        <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
 | 
					        <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
 | 
				
			||||||
 | 
					        <PackageReference Include="NonBlocking" Version="2.0.0" />
 | 
				
			||||||
        <PackageReference Include="OneOf" Version="3.0.216" />
 | 
					        <PackageReference Include="OneOf" Version="3.0.216" />
 | 
				
			||||||
        <PackageReference Include="Scrutor" Version="4.1.0" />
 | 
					        <PackageReference Include="Scrutor" Version="4.1.0" />
 | 
				
			||||||
        <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
 | 
					        <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,35 +0,0 @@
 | 
				
			|||||||
#nullable disable
 | 
					 | 
				
			||||||
using StackExchange.Redis;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
namespace NadekoBot.Services;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public interface IDataCache
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    ConnectionMultiplexer Redis { get; }
 | 
					 | 
				
			||||||
    IImageCache LocalImages { get; }
 | 
					 | 
				
			||||||
    ILocalDataCache LocalData { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Task<(bool Success, byte[] Data)> TryGetImageDataAsync(Uri key);
 | 
					 | 
				
			||||||
    Task<(bool Success, string Data)> TryGetAnimeDataAsync(string key);
 | 
					 | 
				
			||||||
    Task<(bool Success, string Data)> TryGetNovelDataAsync(string key);
 | 
					 | 
				
			||||||
    Task SetImageDataAsync(Uri key, byte[] data);
 | 
					 | 
				
			||||||
    Task SetAnimeDataAsync(string link, string data);
 | 
					 | 
				
			||||||
    Task SetNovelDataAsync(string link, string data);
 | 
					 | 
				
			||||||
    TimeSpan? AddTimelyClaim(ulong id, int period);
 | 
					 | 
				
			||||||
    TimeSpan? TryAddRatelimit(ulong id, string name, int expireIn);
 | 
					 | 
				
			||||||
    void RemoveAllTimelyClaims();
 | 
					 | 
				
			||||||
    bool TryAddAffinityCooldown(ulong userId, out TimeSpan? time);
 | 
					 | 
				
			||||||
    bool TryAddDivorceCooldown(ulong userId, out TimeSpan? time);
 | 
					 | 
				
			||||||
    bool TryGetEconomy(out string data);
 | 
					 | 
				
			||||||
    void SetEconomy(string data);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Task<TOut> GetOrAddCachedDataAsync<TParam, TOut>(
 | 
					 | 
				
			||||||
        string key,
 | 
					 | 
				
			||||||
        Func<TParam, Task<TOut>> factory,
 | 
					 | 
				
			||||||
        TParam param,
 | 
					 | 
				
			||||||
        TimeSpan expiry)
 | 
					 | 
				
			||||||
        where TOut : class;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    DateTime GetLastCurrencyDecay();
 | 
					 | 
				
			||||||
    void SetLastCurrencyDecay();
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,29 +0,0 @@
 | 
				
			|||||||
#nullable disable
 | 
					 | 
				
			||||||
namespace NadekoBot.Services;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public interface IImageCache
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    ImageUrls ImageUrls { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    IReadOnlyList<byte[]> Heads { get; }
 | 
					 | 
				
			||||||
    IReadOnlyList<byte[]> Tails { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    IReadOnlyList<byte[]> Dice { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    IReadOnlyList<byte[]> SlotEmojis { get; }
 | 
					 | 
				
			||||||
    IReadOnlyList<byte[]> Currency { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    byte[] SlotBackground { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    byte[] RategirlMatrix { get; }
 | 
					 | 
				
			||||||
    byte[] RategirlDot { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    byte[] XpBackground { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    byte[] Rip { get; }
 | 
					 | 
				
			||||||
    byte[] RipOverlay { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    byte[] GetCard(string key);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Task Reload();
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -6,8 +6,8 @@ namespace NadekoBot.Services;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public interface ILocalDataCache
 | 
					public interface ILocalDataCache
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    IReadOnlyDictionary<string, SearchPokemon> Pokemons { get; }
 | 
					    Task<IReadOnlyDictionary<string, SearchPokemon>> GetPokemonsAsync();
 | 
				
			||||||
    IReadOnlyDictionary<string, SearchPokemonAbility> PokemonAbilities { get; }
 | 
					    Task<IReadOnlyDictionary<string, SearchPokemonAbility>> GetPokemonAbilitiesAsync();
 | 
				
			||||||
    IReadOnlyDictionary<int, string> PokemonMap { get; }
 | 
					    Task<TriviaQuestionModel[]> GetTriviaQuestionsAsync();
 | 
				
			||||||
    TriviaQuestion[] TriviaQuestions { get; }
 | 
					    Task<PokemonNameId[]> GetPokemonMapAsync();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -172,9 +172,10 @@ public sealed class BotCredsProvider : IBotCredsProvider
 | 
				
			|||||||
        if (File.Exists(CREDS_FILE_NAME))
 | 
					        if (File.Exists(CREDS_FILE_NAME))
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            var creds = Yaml.Deserializer.Deserialize<Creds>(File.ReadAllText(CREDS_FILE_NAME));
 | 
					            var creds = Yaml.Deserializer.Deserialize<Creds>(File.ReadAllText(CREDS_FILE_NAME));
 | 
				
			||||||
            if (creds.Version <= 4)
 | 
					            if (creds.Version <= 5)
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                creds.Version = 5;
 | 
					                creds.Version = 6;
 | 
				
			||||||
 | 
					                creds.BotCache = BotCacheImplemenation.Redis;
 | 
				
			||||||
                File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds));
 | 
					                File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds));
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										82
									
								
								src/NadekoBot/Services/Impl/LocalDataCache.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/NadekoBot/Services/Impl/LocalDataCache.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,82 @@
 | 
				
			|||||||
 | 
					using NadekoBot.Common.Pokemon;
 | 
				
			||||||
 | 
					using NadekoBot.Modules.Games.Common.Trivia;
 | 
				
			||||||
 | 
					using System.Text.Json;
 | 
				
			||||||
 | 
					using System.Text.Json.Serialization;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					namespace NadekoBot.Services;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public sealed class LocalDataCache : ILocalDataCache, INService
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    private const string POKEMON_ABILITIES_FILE = "data/pokemon/pokemon_abilities.json";
 | 
				
			||||||
 | 
					    private const string POKEMON_LIST_FILE = "data/pokemon/pokemon_list.json";
 | 
				
			||||||
 | 
					    private const string POKEMON_MAP_PATH = "data/pokemon/name-id_map.json";
 | 
				
			||||||
 | 
					    private const string QUESTIONS_FILE = "data/trivia_questions.json";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private readonly IBotCache _cache;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private readonly JsonSerializerOptions _opts = new JsonSerializerOptions()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        AllowTrailingCommas = true,
 | 
				
			||||||
 | 
					        NumberHandling = JsonNumberHandling.AllowReadingFromString,
 | 
				
			||||||
 | 
					        PropertyNameCaseInsensitive = true
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public LocalDataCache(IBotCache cache)
 | 
				
			||||||
 | 
					        => _cache = cache;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private async Task<T?> GetOrCreateCachedDataAsync<T>(
 | 
				
			||||||
 | 
					        TypedKey<T> key,
 | 
				
			||||||
 | 
					        string fileName)
 | 
				
			||||||
 | 
					        => await _cache.GetOrAddAsync(key,
 | 
				
			||||||
 | 
					            async () =>
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                if (!File.Exists(fileName))
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    Log.Warning($"{fileName} is missing. Relevant data can't be loaded");
 | 
				
			||||||
 | 
					                    return default;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                try
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    await using var stream = File.OpenRead(fileName);
 | 
				
			||||||
 | 
					                    return await JsonSerializer.DeserializeAsync<T>(stream, _opts);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                catch (Exception ex)
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    Log.Error(ex,
 | 
				
			||||||
 | 
					                        "Error reading {FileName} file: {ErrorMessage}",
 | 
				
			||||||
 | 
					                        fileName,
 | 
				
			||||||
 | 
					                        ex.Message);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    return default;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static TypedKey<IReadOnlyDictionary<string, SearchPokemon>> _pokemonListKey
 | 
				
			||||||
 | 
					        = new("pokemon:list");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<IReadOnlyDictionary<string, SearchPokemon>?> GetPokemonsAsync()
 | 
				
			||||||
 | 
					        => await GetOrCreateCachedDataAsync(_pokemonListKey, POKEMON_LIST_FILE);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static TypedKey<IReadOnlyDictionary<string, SearchPokemonAbility>> _pokemonAbilitiesKey
 | 
				
			||||||
 | 
					        = new("pokemon:abilities");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<IReadOnlyDictionary<string, SearchPokemonAbility>?> GetPokemonAbilitiesAsync()
 | 
				
			||||||
 | 
					        => await GetOrCreateCachedDataAsync(_pokemonAbilitiesKey, POKEMON_ABILITIES_FILE);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static TypedKey<PokemonNameId[]> _pokeMapKey
 | 
				
			||||||
 | 
					        = new("pokemon:ab_map");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<PokemonNameId[]?> GetPokemonMapAsync()
 | 
				
			||||||
 | 
					        => await GetOrCreateCachedDataAsync(_pokeMapKey, POKEMON_MAP_PATH);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static TypedKey<TriviaQuestionModel[]> _triviaKey
 | 
				
			||||||
 | 
					        = new("trivia:questions");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async Task<TriviaQuestionModel[]?> GetTriviaQuestionsAsync()
 | 
				
			||||||
 | 
					        => await GetOrCreateCachedDataAsync(_triviaKey, QUESTIONS_FILE);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,216 +0,0 @@
 | 
				
			|||||||
#nullable disable
 | 
					 | 
				
			||||||
using Newtonsoft.Json;
 | 
					 | 
				
			||||||
using StackExchange.Redis;
 | 
					 | 
				
			||||||
using System.Net;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
namespace NadekoBot.Services;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public class RedisCache : IDataCache
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    public ConnectionMultiplexer Redis { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public IImageCache LocalImages { get; }
 | 
					 | 
				
			||||||
    public ILocalDataCache LocalData { get; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private readonly string _redisKey;
 | 
					 | 
				
			||||||
    private readonly EndPoint _redisEndpoint;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private readonly object _timelyLock = new();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public RedisCache(
 | 
					 | 
				
			||||||
        ConnectionMultiplexer redis,
 | 
					 | 
				
			||||||
        IBotCredentials creds,
 | 
					 | 
				
			||||||
        IImageCache imageCache,
 | 
					 | 
				
			||||||
        ILocalDataCache dataCache)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        Redis = redis;
 | 
					 | 
				
			||||||
        _redisEndpoint = Redis.GetEndPoints().First();
 | 
					 | 
				
			||||||
        LocalImages = imageCache;
 | 
					 | 
				
			||||||
        LocalData = dataCache;
 | 
					 | 
				
			||||||
        _redisKey = creds.RedisKey();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // things here so far don't need the bot id
 | 
					 | 
				
			||||||
    // because it's a good thing if different bots 
 | 
					 | 
				
			||||||
    // which are hosted on the same PC
 | 
					 | 
				
			||||||
    // can re-use the same image/anime data
 | 
					 | 
				
			||||||
    public async Task<(bool Success, byte[] Data)> TryGetImageDataAsync(Uri key)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        byte[] x = await db.StringGetAsync("image_" + key);
 | 
					 | 
				
			||||||
        return (x is not null, x);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public Task SetImageDataAsync(Uri key, byte[] data)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        return db.StringSetAsync("image_" + key, data);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public async Task<(bool Success, string Data)> TryGetAnimeDataAsync(string key)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        string x = await db.StringGetAsync("anime_" + key);
 | 
					 | 
				
			||||||
        return (x is not null, x);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public Task SetAnimeDataAsync(string key, string data)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        return db.StringSetAsync("anime_" + key, data, TimeSpan.FromHours(3));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public async Task<(bool Success, string Data)> TryGetNovelDataAsync(string key)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        string x = await db.StringGetAsync("novel_" + key);
 | 
					 | 
				
			||||||
        return (x is not null, x);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public Task SetNovelDataAsync(string key, string data)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        return db.StringSetAsync("novel_" + key, data, TimeSpan.FromHours(3));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public TimeSpan? AddTimelyClaim(ulong id, int period)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        if (period == 0)
 | 
					 | 
				
			||||||
            return null;
 | 
					 | 
				
			||||||
        lock (_timelyLock)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            var time = TimeSpan.FromHours(period);
 | 
					 | 
				
			||||||
            var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
            if ((bool?)db.StringGet($"{_redisKey}_timelyclaim_{id}") is null)
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                db.StringSet($"{_redisKey}_timelyclaim_{id}", true, time);
 | 
					 | 
				
			||||||
                return null;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            return db.KeyTimeToLive($"{_redisKey}_timelyclaim_{id}");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public void RemoveAllTimelyClaims()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var server = Redis.GetServer(_redisEndpoint);
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        foreach (var k in server.Keys(pattern: $"{_redisKey}_timelyclaim_*"))
 | 
					 | 
				
			||||||
            db.KeyDelete(k, CommandFlags.FireAndForget);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public bool TryAddAffinityCooldown(ulong userId, out TimeSpan? time)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        time = db.KeyTimeToLive($"{_redisKey}_affinity_{userId}");
 | 
					 | 
				
			||||||
        if (time is null)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            time = TimeSpan.FromMinutes(30);
 | 
					 | 
				
			||||||
            db.StringSet($"{_redisKey}_affinity_{userId}", true, time);
 | 
					 | 
				
			||||||
            return true;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public bool TryAddDivorceCooldown(ulong userId, out TimeSpan? time)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        time = db.KeyTimeToLive($"{_redisKey}_divorce_{userId}");
 | 
					 | 
				
			||||||
        if (time is null)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            time = TimeSpan.FromHours(6);
 | 
					 | 
				
			||||||
            db.StringSet($"{_redisKey}_divorce_{userId}", true, time);
 | 
					 | 
				
			||||||
            return true;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public Task SetStreamDataAsync(string url, string data)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        return db.StringSetAsync($"{_redisKey}_stream_{url}", data, TimeSpan.FromHours(6));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public bool TryGetStreamData(string url, out string dataStr)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        dataStr = db.StringGet($"{_redisKey}_stream_{url}");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return !string.IsNullOrWhiteSpace(dataStr);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public TimeSpan? TryAddRatelimit(ulong id, string name, int expireIn)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        if (db.StringSet($"{_redisKey}_ratelimit_{id}_{name}",
 | 
					 | 
				
			||||||
                0, // i don't use the value
 | 
					 | 
				
			||||||
                TimeSpan.FromSeconds(expireIn),
 | 
					 | 
				
			||||||
                when: When.NotExists))
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            return null;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return db.KeyTimeToLive($"{_redisKey}_ratelimit_{id}_{name}");
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public bool TryGetEconomy(out string data)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        data = db.StringGet($"{_redisKey}_economy");
 | 
					 | 
				
			||||||
        if (data is not null)
 | 
					 | 
				
			||||||
            return true;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public void SetEconomy(string data)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
        db.StringSet($"{_redisKey}_economy", data, TimeSpan.FromMinutes(3));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public async Task<TOut> GetOrAddCachedDataAsync<TParam, TOut>(
 | 
					 | 
				
			||||||
        string key,
 | 
					 | 
				
			||||||
        Func<TParam, Task<TOut>> factory,
 | 
					 | 
				
			||||||
        TParam param,
 | 
					 | 
				
			||||||
        TimeSpan expiry)
 | 
					 | 
				
			||||||
        where TOut : class
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var data = await db.StringGetAsync(key);
 | 
					 | 
				
			||||||
        if (!data.HasValue)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            var obj = await factory(param);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if (obj is null)
 | 
					 | 
				
			||||||
                return default;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            await db.StringSetAsync(key, JsonConvert.SerializeObject(obj), expiry);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            return obj;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return (TOut)JsonConvert.DeserializeObject(data, typeof(TOut));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public DateTime GetLastCurrencyDecay()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var str = (string)db.StringGet($"{_redisKey}_last_currency_decay");
 | 
					 | 
				
			||||||
        if (string.IsNullOrEmpty(str))
 | 
					 | 
				
			||||||
            return DateTime.MinValue;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return JsonConvert.DeserializeObject<DateTime>(str);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public void SetLastCurrencyDecay()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var db = Redis.GetDatabase();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        db.StringSet($"{_redisKey}_last_currency_decay", JsonConvert.SerializeObject(DateTime.UtcNow));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,297 +1,111 @@
 | 
				
			|||||||
#nullable disable
 | 
					using NadekoBot.Common.Configs;
 | 
				
			||||||
using NadekoBot.Common.ModuleBehaviors;
 | 
					 | 
				
			||||||
using NadekoBot.Common.Yml;
 | 
					 | 
				
			||||||
using Newtonsoft.Json;
 | 
					 | 
				
			||||||
using StackExchange.Redis;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace NadekoBot.Services;
 | 
					namespace NadekoBot.Services;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public sealed class RedisImagesCache : IImageCache, IReadyExecutor
 | 
					public sealed class ImagesConfig : ConfigServiceBase<ImageUrls>
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public enum ImageKeys
 | 
					    private const string PATH = "data/images.yml";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static readonly TypedKey<ImageUrls> _changeKey =
 | 
				
			||||||
 | 
					        new("config.images.updated");
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    public override string Name
 | 
				
			||||||
 | 
					        => "images";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public ImagesConfig(IConfigSeria serializer, IPubSub pubSub)
 | 
				
			||||||
 | 
					        : base(PATH, serializer, pubSub, _changeKey)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        CoinHeads,
 | 
					 | 
				
			||||||
        CoinTails,
 | 
					 | 
				
			||||||
        Dice,
 | 
					 | 
				
			||||||
        SlotBg,
 | 
					 | 
				
			||||||
        SlotEmojis,
 | 
					 | 
				
			||||||
        Currency,
 | 
					 | 
				
			||||||
        RategirlMatrix,
 | 
					 | 
				
			||||||
        RategirlDot,
 | 
					 | 
				
			||||||
        RipOverlay,
 | 
					 | 
				
			||||||
        RipBg,
 | 
					 | 
				
			||||||
        XpBg
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
    private const string BASE_PATH = "data/";
 | 
					
 | 
				
			||||||
    private const string CARDS_PATH = $"{BASE_PATH}images/cards";
 | 
					public interface IImageCache
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
    private IDatabase Db
 | 
					    Task<byte[]?> GetHeadsImageAsync();
 | 
				
			||||||
        => _con.GetDatabase();
 | 
					    Task<byte[]?> GetTailsImageAsync();
 | 
				
			||||||
 | 
					    Task<byte[]?> GetCurrencyImageAsync();
 | 
				
			||||||
    public ImageUrls ImageUrls { get; private set; }
 | 
					    Task<byte[]?> GetXpBackgroundImageAsync();
 | 
				
			||||||
 | 
					    Task<byte[]?> GetRategirlBgAsync();
 | 
				
			||||||
    public IReadOnlyList<byte[]> Heads
 | 
					    Task<byte[]?> GetRategirlDotAsync();
 | 
				
			||||||
        => GetByteArrayData(ImageKeys.CoinHeads);
 | 
					    Task<byte[]?> GetDiceAsync(int num);
 | 
				
			||||||
 | 
					    Task<byte[]?> GetSlotEmojiAsync(int number);
 | 
				
			||||||
    public IReadOnlyList<byte[]> Tails
 | 
					    Task<byte[]?> GetSlotBgAsync();
 | 
				
			||||||
        => GetByteArrayData(ImageKeys.CoinTails);
 | 
					    Task<byte[]?> GetRipBgAsync();
 | 
				
			||||||
 | 
					    Task<byte[]?> GetRipOverlayAsync();
 | 
				
			||||||
    public IReadOnlyList<byte[]> Dice
 | 
					}
 | 
				
			||||||
        => GetByteArrayData(ImageKeys.Dice);
 | 
					
 | 
				
			||||||
 | 
					public sealed class ImageCache : IImageCache, INService
 | 
				
			||||||
    public IReadOnlyList<byte[]> SlotEmojis
 | 
					{
 | 
				
			||||||
        => GetByteArrayData(ImageKeys.SlotEmojis);
 | 
					    private readonly IBotCache _cache;
 | 
				
			||||||
 | 
					    private readonly ImagesConfig _ic;
 | 
				
			||||||
    public IReadOnlyList<byte[]> Currency
 | 
					    private readonly Random _rng;
 | 
				
			||||||
        => GetByteArrayData(ImageKeys.Currency);
 | 
					    private readonly IHttpClientFactory _httpFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public byte[] SlotBackground
 | 
					    public ImageCache(
 | 
				
			||||||
        => GetByteData(ImageKeys.SlotBg);
 | 
					        IBotCache cache,
 | 
				
			||||||
 | 
					        ImagesConfig ic,
 | 
				
			||||||
    public byte[] RategirlMatrix
 | 
					        IHttpClientFactory httpFactory)
 | 
				
			||||||
        => GetByteData(ImageKeys.RategirlMatrix);
 | 
					    {
 | 
				
			||||||
 | 
					        _cache = cache;
 | 
				
			||||||
    public byte[] RategirlDot
 | 
					        _ic = ic;
 | 
				
			||||||
        => GetByteData(ImageKeys.RategirlDot);
 | 
					        _httpFactory = httpFactory;
 | 
				
			||||||
 | 
					        _rng = new NadekoRandom();
 | 
				
			||||||
    public byte[] XpBackground
 | 
					    }
 | 
				
			||||||
        => GetByteData(ImageKeys.XpBg);
 | 
					
 | 
				
			||||||
 | 
					    private static TypedKey<byte[]> GetImageKey(Uri url)
 | 
				
			||||||
    public byte[] Rip
 | 
					        => new($"image:{url}");
 | 
				
			||||||
        => GetByteData(ImageKeys.RipBg);
 | 
					
 | 
				
			||||||
 | 
					    private async Task<byte[]?> GetImageDataAsync(Uri url)
 | 
				
			||||||
    public byte[] RipOverlay
 | 
					        => await _cache.GetOrAddAsync(
 | 
				
			||||||
        => GetByteData(ImageKeys.RipOverlay);
 | 
					            GetImageKey(url),
 | 
				
			||||||
 | 
					            async () =>
 | 
				
			||||||
    private readonly ConnectionMultiplexer _con;
 | 
					            {
 | 
				
			||||||
    private readonly IBotCredentials _creds;
 | 
					                using var http = _httpFactory.CreateClient();
 | 
				
			||||||
    private readonly HttpClient _http;
 | 
					                var bytes = await http.GetByteArrayAsync(url);
 | 
				
			||||||
    private readonly string _imagesPath;
 | 
					                return bytes;
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
    public RedisImagesCache(ConnectionMultiplexer con, IBotCredentials creds)
 | 
					            expiry: 48.Hours());
 | 
				
			||||||
    {
 | 
					
 | 
				
			||||||
        _con = con;
 | 
					    private async Task<byte[]?> GetRandomImageDataAsync(Uri[] urls)
 | 
				
			||||||
        _creds = creds;
 | 
					    {
 | 
				
			||||||
        _http = new();
 | 
					        if (urls.Length == 0)
 | 
				
			||||||
        _imagesPath = Path.Combine(BASE_PATH, "images.yml");
 | 
					            return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Migrate();
 | 
					        var url = urls[_rng.Next(0, urls.Length)];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        ImageUrls = Yaml.Deserializer.Deserialize<ImageUrls>(File.ReadAllText(_imagesPath));
 | 
					        var data = await GetImageDataAsync(url);
 | 
				
			||||||
    }
 | 
					        return data;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    public byte[] GetCard(string key)
 | 
					
 | 
				
			||||||
        // since cards are always local for now, don't cache them
 | 
					    public Task<byte[]?> GetHeadsImageAsync()
 | 
				
			||||||
        => File.ReadAllBytes(Path.Join(CARDS_PATH, key + ".jpg"));
 | 
					        => GetRandomImageDataAsync(_ic.Data.Coins.Heads);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task OnReadyAsync()
 | 
					    public Task<byte[]?> GetTailsImageAsync()
 | 
				
			||||||
    {
 | 
					        => GetRandomImageDataAsync(_ic.Data.Coins.Tails);
 | 
				
			||||||
        if (await AllKeysExist())
 | 
					
 | 
				
			||||||
            return;
 | 
					    public Task<byte[]?> GetCurrencyImageAsync()
 | 
				
			||||||
 | 
					        => GetRandomImageDataAsync(_ic.Data.Currency);
 | 
				
			||||||
        await Reload();
 | 
					
 | 
				
			||||||
    }
 | 
					    public Task<byte[]?> GetXpBackgroundImageAsync()
 | 
				
			||||||
 | 
					        => GetImageDataAsync(_ic.Data.Xp.Bg);
 | 
				
			||||||
    private void Migrate()
 | 
					
 | 
				
			||||||
    {
 | 
					    public Task<byte[]?> GetRategirlBgAsync()
 | 
				
			||||||
        // migrate to yml
 | 
					        => GetImageDataAsync(_ic.Data.Rategirl.Matrix);
 | 
				
			||||||
        if (File.Exists(Path.Combine(BASE_PATH, "images.json")))
 | 
					
 | 
				
			||||||
        {
 | 
					    public Task<byte[]?> GetRategirlDotAsync()
 | 
				
			||||||
            var oldFilePath = Path.Combine(BASE_PATH, "images.json");
 | 
					        => GetImageDataAsync(_ic.Data.Rategirl.Dot);
 | 
				
			||||||
            var backupFilePath = Path.Combine(BASE_PATH, "images.json.backup");
 | 
					
 | 
				
			||||||
 | 
					    public Task<byte[]?> GetDiceAsync(int num)
 | 
				
			||||||
            var oldData = JsonConvert.DeserializeObject<OldImageUrls>(File.ReadAllText(oldFilePath));
 | 
					        => GetImageDataAsync(_ic.Data.Dice[num]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if (oldData is not null)
 | 
					    public Task<byte[]?> GetSlotEmojiAsync(int number)
 | 
				
			||||||
            {
 | 
					        => GetImageDataAsync(_ic.Data.Slots.Emojis[number]);
 | 
				
			||||||
                var newData = new ImageUrls
 | 
					
 | 
				
			||||||
                {
 | 
					    public Task<byte[]?> GetSlotBgAsync()
 | 
				
			||||||
                    Coins =
 | 
					        => GetImageDataAsync(_ic.Data.Slots.Bg);
 | 
				
			||||||
                        new()
 | 
					
 | 
				
			||||||
                        {
 | 
					    public Task<byte[]?> GetRipBgAsync()
 | 
				
			||||||
                            Heads =
 | 
					        => GetImageDataAsync(_ic.Data.Rip.Bg);
 | 
				
			||||||
                                oldData.Coins.Heads.Length == 1
 | 
					
 | 
				
			||||||
                                && oldData.Coins.Heads[0].ToString()
 | 
					    public Task<byte[]?> GetRipOverlayAsync()
 | 
				
			||||||
                                == "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/coins/heads.png"
 | 
					        => GetImageDataAsync(_ic.Data.Rip.Overlay);
 | 
				
			||||||
                                    ? new[] { new Uri("https://cdn.nadeko.bot/coins/heads3.png") }
 | 
					 | 
				
			||||||
                                    : oldData.Coins.Heads,
 | 
					 | 
				
			||||||
                            Tails = oldData.Coins.Tails.Length == 1
 | 
					 | 
				
			||||||
                                    && oldData.Coins.Tails[0].ToString()
 | 
					 | 
				
			||||||
                                    == "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/coins/tails.png"
 | 
					 | 
				
			||||||
                                ? new[] { new Uri("https://cdn.nadeko.bot/coins/tails3.png") }
 | 
					 | 
				
			||||||
                                : oldData.Coins.Tails
 | 
					 | 
				
			||||||
                        },
 | 
					 | 
				
			||||||
                    Dice = oldData.Dice.Map(x => x.ToNewCdn()),
 | 
					 | 
				
			||||||
                    Currency = oldData.Currency.Map(x => x.ToNewCdn()),
 | 
					 | 
				
			||||||
                    Rategirl =
 | 
					 | 
				
			||||||
                        new()
 | 
					 | 
				
			||||||
                        {
 | 
					 | 
				
			||||||
                            Dot = oldData.Rategirl.Dot.ToNewCdn(),
 | 
					 | 
				
			||||||
                            Matrix = oldData.Rategirl.Matrix.ToNewCdn()
 | 
					 | 
				
			||||||
                        },
 | 
					 | 
				
			||||||
                    Rip = new()
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        Bg = oldData.Rip.Bg.ToNewCdn(),
 | 
					 | 
				
			||||||
                        Overlay = oldData.Rip.Overlay.ToNewCdn()
 | 
					 | 
				
			||||||
                    },
 | 
					 | 
				
			||||||
                    Slots = new()
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        Bg = new("https://cdn.nadeko.bot/slots/slots_bg.png"),
 | 
					 | 
				
			||||||
                        Emojis = new[]
 | 
					 | 
				
			||||||
                        {
 | 
					 | 
				
			||||||
                            "https://cdn.nadeko.bot/slots/0.png", "https://cdn.nadeko.bot/slots/1.png",
 | 
					 | 
				
			||||||
                            "https://cdn.nadeko.bot/slots/2.png", "https://cdn.nadeko.bot/slots/3.png",
 | 
					 | 
				
			||||||
                            "https://cdn.nadeko.bot/slots/4.png", "https://cdn.nadeko.bot/slots/5.png"
 | 
					 | 
				
			||||||
                        }.Map(x => new Uri(x))
 | 
					 | 
				
			||||||
                    },
 | 
					 | 
				
			||||||
                    Xp = new()
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        Bg = oldData.Xp.Bg.ToNewCdn()
 | 
					 | 
				
			||||||
                    },
 | 
					 | 
				
			||||||
                    Version = 2
 | 
					 | 
				
			||||||
                };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                File.Move(oldFilePath, backupFilePath, true);
 | 
					 | 
				
			||||||
                File.WriteAllText(_imagesPath, Yaml.Serializer.Serialize(newData));
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // removed numbers from slots
 | 
					 | 
				
			||||||
        var localImageUrls = Yaml.Deserializer.Deserialize<ImageUrls>(File.ReadAllText(_imagesPath));
 | 
					 | 
				
			||||||
        if (localImageUrls.Version == 2)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            localImageUrls.Version = 3;
 | 
					 | 
				
			||||||
            File.WriteAllText(_imagesPath, Yaml.Serializer.Serialize(localImageUrls));
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (localImageUrls.Version == 3)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            localImageUrls.Version = 4;
 | 
					 | 
				
			||||||
            if (localImageUrls.Xp?.Bg.ToString() == "https://cdn.nadeko.bot/other/xp/bg.png")
 | 
					 | 
				
			||||||
                localImageUrls.Xp.Bg = new("https://cdn.nadeko.bot/other/xp/bg_k.png");
 | 
					 | 
				
			||||||
            
 | 
					 | 
				
			||||||
            File.WriteAllText(_imagesPath, Yaml.Serializer.Serialize(localImageUrls));
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public async Task Reload()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        ImageUrls = Yaml.Deserializer.Deserialize<ImageUrls>(await File.ReadAllTextAsync(_imagesPath));
 | 
					 | 
				
			||||||
        foreach (var key in GetAllKeys())
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            switch (key)
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                case ImageKeys.CoinHeads:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Coins.Heads);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                case ImageKeys.CoinTails:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Coins.Tails);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                case ImageKeys.Dice:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Dice);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                case ImageKeys.SlotBg:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Slots.Bg);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                case ImageKeys.SlotEmojis:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Slots.Emojis);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                case ImageKeys.Currency:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Currency);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                case ImageKeys.RategirlMatrix:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Rategirl.Matrix);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                case ImageKeys.RategirlDot:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Rategirl.Dot);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                case ImageKeys.RipOverlay:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Rip.Overlay);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                case ImageKeys.RipBg:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Rip.Bg);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                case ImageKeys.XpBg:
 | 
					 | 
				
			||||||
                    await Load(key, ImageUrls.Xp.Bg);
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                default:
 | 
					 | 
				
			||||||
                    throw new ArgumentOutOfRangeException();
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private async Task Load(ImageKeys key, Uri uri)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var data = await GetImageData(uri);
 | 
					 | 
				
			||||||
        if (data is null)
 | 
					 | 
				
			||||||
            return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        await Db.StringSetAsync(GetRedisKey(key), data);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private async Task Load(ImageKeys key, Uri[] uris)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        await Db.KeyDeleteAsync(GetRedisKey(key));
 | 
					 | 
				
			||||||
        var imageData = await uris.Select(GetImageData).WhenAll();
 | 
					 | 
				
			||||||
        var vals = imageData.Where(x => x is not null).Select(x => (RedisValue)x).ToArray();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        await Db.ListRightPushAsync(GetRedisKey(key), vals);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (uris.Length != vals.Length)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            Log.Information(
 | 
					 | 
				
			||||||
                "{Loaded}/{Max} URIs for the key '{ImageKey}' have been loaded.\n"
 | 
					 | 
				
			||||||
                + "Some of the supplied URIs are either unavailable or invalid",
 | 
					 | 
				
			||||||
                vals.Length,
 | 
					 | 
				
			||||||
                uris.Length,
 | 
					 | 
				
			||||||
                key);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private async Task<byte[]> GetImageData(Uri uri)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        if (uri.IsFile)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            try
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                var bytes = await File.ReadAllBytesAsync(uri.LocalPath);
 | 
					 | 
				
			||||||
                return bytes;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            catch (Exception ex)
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                Log.Warning(ex, "Failed reading image bytes from uri: {Uri}", uri.ToString());
 | 
					 | 
				
			||||||
                return null;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        try
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            return await _http.GetByteArrayAsync(uri);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        catch (Exception ex)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            Log.Warning(ex, "Image url you provided is not a valid image: {Uri}", uri.ToString());
 | 
					 | 
				
			||||||
            return null;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private async Task<bool> AllKeysExist()
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var tasks = await GetAllKeys().Select(x => Db.KeyExistsAsync(GetRedisKey(x))).WhenAll();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return tasks.All(exist => exist);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private IEnumerable<ImageKeys> GetAllKeys()
 | 
					 | 
				
			||||||
        => Enum.GetValues<ImageKeys>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private byte[][] GetByteArrayData(ImageKeys key)
 | 
					 | 
				
			||||||
        => Db.ListRange(GetRedisKey(key)).Map(x => (byte[])x);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private byte[] GetByteData(ImageKeys key)
 | 
					 | 
				
			||||||
        => Db.StringGet(GetRedisKey(key));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private RedisKey GetRedisKey(ImageKeys key)
 | 
					 | 
				
			||||||
        => _creds.RedisKey() + "_image_" + key;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -1,90 +0,0 @@
 | 
				
			|||||||
#nullable disable
 | 
					 | 
				
			||||||
using NadekoBot.Common.Pokemon;
 | 
					 | 
				
			||||||
using NadekoBot.Modules.Games.Common.Trivia;
 | 
					 | 
				
			||||||
using Newtonsoft.Json;
 | 
					 | 
				
			||||||
using StackExchange.Redis;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
namespace NadekoBot.Services;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public class RedisLocalDataCache : ILocalDataCache
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    private const string POKEMON_ABILITIES_FILE = "data/pokemon/pokemon_abilities.json";
 | 
					 | 
				
			||||||
    private const string POKEMON_LIST_FILE = "data/pokemon/pokemon_list.json";
 | 
					 | 
				
			||||||
    private const string POKEMON_MAP_PATH = "data/pokemon/name-id_map.json";
 | 
					 | 
				
			||||||
    private const string QUESTIONS_FILE = "data/trivia_questions.json";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public IReadOnlyDictionary<string, SearchPokemon> Pokemons
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        get => Get<Dictionary<string, SearchPokemon>>("pokemon_list");
 | 
					 | 
				
			||||||
        private init => Set("pokemon_list", value);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public IReadOnlyDictionary<string, SearchPokemonAbility> PokemonAbilities
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        get => Get<Dictionary<string, SearchPokemonAbility>>("pokemon_abilities");
 | 
					 | 
				
			||||||
        private init => Set("pokemon_abilities", value);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public TriviaQuestion[] TriviaQuestions
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        get => Get<TriviaQuestion[]>("trivia_questions");
 | 
					 | 
				
			||||||
        private init => Set("trivia_questions", value);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public IReadOnlyDictionary<int, string> PokemonMap
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        get => Get<Dictionary<int, string>>("pokemon_map");
 | 
					 | 
				
			||||||
        private init => Set("pokemon_map", value);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private readonly ConnectionMultiplexer _con;
 | 
					 | 
				
			||||||
    private readonly IBotCredentials _creds;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public RedisLocalDataCache(ConnectionMultiplexer con, IBotCredentials creds, DiscordSocketClient client)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        _con = con;
 | 
					 | 
				
			||||||
        _creds = creds;
 | 
					 | 
				
			||||||
        var shardId = client.ShardId;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (shardId == 0)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            if (!File.Exists(POKEMON_LIST_FILE))
 | 
					 | 
				
			||||||
                Log.Warning($"{POKEMON_LIST_FILE} is missing. Pokemon abilities not loaded");
 | 
					 | 
				
			||||||
            else
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                Pokemons =
 | 
					 | 
				
			||||||
                    JsonConvert.DeserializeObject<Dictionary<string, SearchPokemon>>(
 | 
					 | 
				
			||||||
                        File.ReadAllText(POKEMON_LIST_FILE));
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if (!File.Exists(POKEMON_ABILITIES_FILE))
 | 
					 | 
				
			||||||
                Log.Warning($"{POKEMON_ABILITIES_FILE} is missing. Pokemon abilities not loaded.");
 | 
					 | 
				
			||||||
            else
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                PokemonAbilities =
 | 
					 | 
				
			||||||
                    JsonConvert.DeserializeObject<Dictionary<string, SearchPokemonAbility>>(
 | 
					 | 
				
			||||||
                        File.ReadAllText(POKEMON_ABILITIES_FILE));
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            try
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                TriviaQuestions = JsonConvert.DeserializeObject<TriviaQuestion[]>(File.ReadAllText(QUESTIONS_FILE));
 | 
					 | 
				
			||||||
                PokemonMap = JsonConvert.DeserializeObject<PokemonNameId[]>(File.ReadAllText(POKEMON_MAP_PATH))
 | 
					 | 
				
			||||||
                                        ?.ToDictionary(x => x.Id, x => x.Name)
 | 
					 | 
				
			||||||
                             ?? new();
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            catch (Exception ex)
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                Log.Error(ex, "Error loading local data");
 | 
					 | 
				
			||||||
                throw;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private T Get<T>(string key)
 | 
					 | 
				
			||||||
        where T : class
 | 
					 | 
				
			||||||
        => JsonConvert.DeserializeObject<T>(_con.GetDatabase().StringGet($"{_creds.RedisKey()}_localdata_{key}"));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private void Set(string key, object obj)
 | 
					 | 
				
			||||||
        => _con.GetDatabase().StringSet($"{_creds.RedisKey()}_localdata_{key}", JsonConvert.SerializeObject(obj));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,13 +1,13 @@
 | 
				
			|||||||
#nullable disable
 | 
					#nullable disable
 | 
				
			||||||
namespace NadekoBot.Services;
 | 
					namespace NadekoBot.Services;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class LocalBotStringsProvider : IBotStringsProvider
 | 
					public class MemoryBotStringsProvider : IBotStringsProvider
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    private readonly IStringsSource _source;
 | 
					    private readonly IStringsSource _source;
 | 
				
			||||||
    private IReadOnlyDictionary<string, Dictionary<string, string>> responseStrings;
 | 
					    private IReadOnlyDictionary<string, Dictionary<string, string>> responseStrings;
 | 
				
			||||||
    private IReadOnlyDictionary<string, Dictionary<string, CommandStrings>> commandStrings;
 | 
					    private IReadOnlyDictionary<string, Dictionary<string, CommandStrings>> commandStrings;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public LocalBotStringsProvider(IStringsSource source)
 | 
					    public MemoryBotStringsProvider(IStringsSource source)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        _source = source;
 | 
					        _source = source;
 | 
				
			||||||
        Reload();
 | 
					        Reload();
 | 
				
			||||||
@@ -32,7 +32,7 @@ public static class ArrayExtensions
 | 
				
			|||||||
    public static TOut[] Map<TIn, TOut>(this TIn[] arr, Func<TIn, TOut> f)
 | 
					    public static TOut[] Map<TIn, TOut>(this TIn[] arr, Func<TIn, TOut> f)
 | 
				
			||||||
        => Array.ConvertAll(arr, x => f(x));
 | 
					        => Array.ConvertAll(arr, x => f(x));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static IReadOnlyCollection<TOut> Map<TIn, TOut>(this IReadOnlyCollection<TIn> col, Func<TIn, TOut> f)
 | 
					    public static TOut[] Map<TIn, TOut>(this IReadOnlyCollection<TIn> col, Func<TIn, TOut> f)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var toReturn = new TOut[col.Count];
 | 
					        var toReturn = new TOut[col.Count];
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -37,8 +37,9 @@ public static class Extensions
 | 
				
			|||||||
            _ => throw new ArgumentOutOfRangeException(nameof(text))
 | 
					            _ => throw new ArgumentOutOfRangeException(nameof(text))
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static List<ulong> GetGuildIds(this DiscordSocketClient client)
 | 
					    public static ulong[] GetGuildIds(this DiscordSocketClient client)
 | 
				
			||||||
        => client.Guilds.Select(x => x.Id).ToList();
 | 
					        => client.Guilds
 | 
				
			||||||
 | 
					                 .Map(x => x.Id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /// <summary>
 | 
					    /// <summary>
 | 
				
			||||||
    ///     Generates a string in the format HHH:mm if timespan is >= 2m.
 | 
					    ///     Generates a string in the format HHH:mm if timespan is >= 2m.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,10 +9,10 @@ namespace NadekoBot.Extensions;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
public static class ServiceCollectionExtensions
 | 
					public static class ServiceCollectionExtensions
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    public static IServiceCollection AddBotStringsServices(this IServiceCollection services, int totalShards)
 | 
					    public static IServiceCollection AddBotStringsServices(this IServiceCollection services, BotCacheImplemenation botCache)
 | 
				
			||||||
        => totalShards <= 1
 | 
					        => botCache == BotCacheImplemenation.Memory
 | 
				
			||||||
            ? services.AddSingleton<IStringsSource, LocalFileStringsSource>()
 | 
					            ? services.AddSingleton<IStringsSource, LocalFileStringsSource>()
 | 
				
			||||||
                      .AddSingleton<IBotStringsProvider, LocalBotStringsProvider>()
 | 
					                      .AddSingleton<IBotStringsProvider, MemoryBotStringsProvider>()
 | 
				
			||||||
                      .AddSingleton<IBotStrings, BotStrings>()
 | 
					                      .AddSingleton<IBotStrings, BotStrings>()
 | 
				
			||||||
            : services.AddSingleton<IStringsSource, LocalFileStringsSource>()
 | 
					            : services.AddSingleton<IStringsSource, LocalFileStringsSource>()
 | 
				
			||||||
                      .AddSingleton<IBotStringsProvider, RedisBotStringsProvider>()
 | 
					                      .AddSingleton<IBotStringsProvider, RedisBotStringsProvider>()
 | 
				
			||||||
@@ -24,17 +24,6 @@ public static class ServiceCollectionExtensions
 | 
				
			|||||||
                            .AddClasses(f => f.AssignableTo(typeof(ConfigServiceBase<>)))
 | 
					                            .AddClasses(f => f.AssignableTo(typeof(ConfigServiceBase<>)))
 | 
				
			||||||
                            .AsSelfWithInterfaces());
 | 
					                            .AsSelfWithInterfaces());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // var baseType = typeof(ConfigServiceBase<>);
 | 
					 | 
				
			||||||
        //
 | 
					 | 
				
			||||||
        // foreach (var type in Assembly.GetCallingAssembly().ExportedTypes.Where(x => x.IsSealed))
 | 
					 | 
				
			||||||
        // {
 | 
					 | 
				
			||||||
        //     if (type.BaseType?.IsGenericType == true && type.BaseType.GetGenericTypeDefinition() == baseType)
 | 
					 | 
				
			||||||
        //     {
 | 
					 | 
				
			||||||
        //         services.AddSingleton(type);
 | 
					 | 
				
			||||||
        //         services.AddSingleton(x => (IConfigService)x.GetRequiredService(type));
 | 
					 | 
				
			||||||
        //     }
 | 
					 | 
				
			||||||
        // }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return services;
 | 
					        return services;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -48,7 +37,7 @@ public static class ServiceCollectionExtensions
 | 
				
			|||||||
                   .AddSingleton<ISoundcloudResolver, SoundcloudResolver>()
 | 
					                   .AddSingleton<ISoundcloudResolver, SoundcloudResolver>()
 | 
				
			||||||
                   .AddSingleton<ILocalTrackResolver, LocalTrackResolver>()
 | 
					                   .AddSingleton<ILocalTrackResolver, LocalTrackResolver>()
 | 
				
			||||||
                   .AddSingleton<IRadioResolver, RadioResolver>()
 | 
					                   .AddSingleton<IRadioResolver, RadioResolver>()
 | 
				
			||||||
                   .AddSingleton<ITrackCacher, RedisTrackCacher>()
 | 
					                   .AddSingleton<ITrackCacher, TrackCacher>()
 | 
				
			||||||
                   .AddSingleton<YtLoader>()
 | 
					                   .AddSingleton<YtLoader>()
 | 
				
			||||||
                   .AddSingleton<IPlaceholderProvider>(svc => svc.GetRequiredService<IMusicService>());
 | 
					                   .AddSingleton<IPlaceholderProvider>(svc => svc.GetRequiredService<IMusicService>());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -65,10 +54,23 @@ public static class ServiceCollectionExtensions
 | 
				
			|||||||
        return services;
 | 
					        return services;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static IServiceCollection AddRedis(this IServiceCollection services, string redisOptions)
 | 
					    public static IServiceCollection AddCache(this IServiceCollection services, IBotCredentials creds)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        var conf = ConfigurationOptions.Parse(redisOptions);
 | 
					        if (creds.BotCache == BotCacheImplemenation.Redis)
 | 
				
			||||||
        services.AddSingleton(ConnectionMultiplexer.Connect(conf));
 | 
					        {
 | 
				
			||||||
        return services;
 | 
					            var conf = ConfigurationOptions.Parse(creds.RedisOptions);
 | 
				
			||||||
 | 
					            services.AddSingleton(ConnectionMultiplexer.Connect(conf))
 | 
				
			||||||
 | 
					                    .AddSingleton<IBotCache, RedisBotCache>()
 | 
				
			||||||
 | 
					                    .AddSingleton<IPubSub, RedisPubSub>();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        else
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            services.AddSingleton<IBotCache, MemoryBotCache>()
 | 
				
			||||||
 | 
					                    .AddSingleton<IPubSub, EventPubSub>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return services
 | 
				
			||||||
 | 
					            .AddBotStringsServices(creds.BotCache);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -30,9 +30,9 @@ public static class UserExtensions
 | 
				
			|||||||
        => usr.AvatarId is null ? new(usr.GetDefaultAvatarUrl()) : new Uri(usr.GetAvatarUrl(ImageFormat.Auto, size));
 | 
					        => usr.AvatarId is null ? new(usr.GetDefaultAvatarUrl()) : new Uri(usr.GetAvatarUrl(ImageFormat.Auto, size));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // This method is only used for the xp card
 | 
					    // This method is only used for the xp card
 | 
				
			||||||
    public static Uri? RealAvatarUrl(this DiscordUser usr)
 | 
					    public static Uri RealAvatarUrl(this DiscordUser usr)
 | 
				
			||||||
        => usr.AvatarId is null
 | 
					        => usr.AvatarId is null
 | 
				
			||||||
            ? null
 | 
					            ? new(CDN.GetDefaultUserAvatarUrl(ushort.Parse(usr.Discriminator)))
 | 
				
			||||||
            : new Uri(usr.AvatarId.StartsWith("a_", StringComparison.InvariantCulture)
 | 
					            : new Uri(usr.AvatarId.StartsWith("a_", StringComparison.InvariantCulture)
 | 
				
			||||||
                ? $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.gif"
 | 
					                ? $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.gif"
 | 
				
			||||||
                : $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.png");
 | 
					                : $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.png");
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
# DO NOT CHANGE
 | 
					# DO NOT CHANGE
 | 
				
			||||||
version: 5
 | 
					version: 6
 | 
				
			||||||
# Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/
 | 
					# Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/
 | 
				
			||||||
token: ''
 | 
					token: ''
 | 
				
			||||||
# List of Ids of the users who have bot owner permissions
 | 
					# List of Ids of the users who have bot owner permissions
 | 
				
			||||||
@@ -9,6 +9,9 @@ ownerIds: []
 | 
				
			|||||||
usePrivilegedIntents: true
 | 
					usePrivilegedIntents: true
 | 
				
			||||||
# The number of shards that the bot will be running on.
 | 
					# The number of shards that the bot will be running on.
 | 
				
			||||||
# Leave at 1 if you don't know what you're doing.
 | 
					# Leave at 1 if you don't know what you're doing.
 | 
				
			||||||
 | 
					# 
 | 
				
			||||||
 | 
					# note: If you are planning to have more than one shard, then you must change botCache to 'redis'.
 | 
				
			||||||
 | 
					# Also, in that case you should be using NadekoBot.Coordinator to start the bot, and it will correctly override this value.
 | 
				
			||||||
totalShards: 1
 | 
					totalShards: 1
 | 
				
			||||||
# Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it.
 | 
					# Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it.
 | 
				
			||||||
# Then, go to APIs and Services -> Credentials and click Create credentials -> API key.
 | 
					# Then, go to APIs and Services -> Credentials and click Create credentials -> API key.
 | 
				
			||||||
@@ -53,7 +56,12 @@ patreon:
 | 
				
			|||||||
botListToken: ''
 | 
					botListToken: ''
 | 
				
			||||||
# Official cleverbot api key.
 | 
					# Official cleverbot api key.
 | 
				
			||||||
cleverbotApiKey: ''
 | 
					cleverbotApiKey: ''
 | 
				
			||||||
 | 
					# Which cache implementation should bot use.
 | 
				
			||||||
 | 
					# 'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset.
 | 
				
			||||||
 | 
					# 'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml
 | 
				
			||||||
 | 
					botCache: Memory
 | 
				
			||||||
# Redis connection string. Don't change if you don't know what you're doing.
 | 
					# Redis connection string. Don't change if you don't know what you're doing.
 | 
				
			||||||
 | 
					# Only used if botCache is set to 'redis'
 | 
				
			||||||
redisOptions: localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password=
 | 
					redisOptions: localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password=
 | 
				
			||||||
# Database options. Don't change if you don't know what you're doing. Leave null for default values
 | 
					# Database options. Don't change if you don't know what you're doing. Leave null for default values
 | 
				
			||||||
db:
 | 
					db:
 | 
				
			||||||
@@ -99,7 +107,7 @@ twitchClientSecret:
 | 
				
			|||||||
# args: "NadekoBot.dll -- {0}"
 | 
					# args: "NadekoBot.dll -- {0}"
 | 
				
			||||||
# Windows default
 | 
					# Windows default
 | 
				
			||||||
# cmd: NadekoBot.exe
 | 
					# cmd: NadekoBot.exe
 | 
				
			||||||
# args: {0}
 | 
					# args: "{0}"
 | 
				
			||||||
restartCommand:
 | 
					restartCommand:
 | 
				
			||||||
  cmd: 
 | 
					  cmd: 
 | 
				
			||||||
  args: 
 | 
					  args: 
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -862,8 +862,6 @@ unsetmusicchannel:
 | 
				
			|||||||
musicquality:
 | 
					musicquality:
 | 
				
			||||||
- musicquality
 | 
					- musicquality
 | 
				
			||||||
- mquality
 | 
					- mquality
 | 
				
			||||||
imagesreload:
 | 
					 | 
				
			||||||
- imagesreload
 | 
					 | 
				
			||||||
stringsreload:
 | 
					stringsreload:
 | 
				
			||||||
- stringsreload
 | 
					- stringsreload
 | 
				
			||||||
shardstats:
 | 
					shardstats:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,7 +24,7 @@ quotas:
 | 
				
			|||||||
  # Dictionary of commands with their respective quota data
 | 
					  # Dictionary of commands with their respective quota data
 | 
				
			||||||
  commands:
 | 
					  commands:
 | 
				
			||||||
    cleverbot:
 | 
					    cleverbot:
 | 
				
			||||||
      V: null
 | 
					      V: 
 | 
				
			||||||
    prune:
 | 
					    prune:
 | 
				
			||||||
      X:
 | 
					      X:
 | 
				
			||||||
        PerHour: 1
 | 
					        PerHour: 1
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1496,10 +1496,6 @@ musicquality:
 | 
				
			|||||||
    - ""
 | 
					    - ""
 | 
				
			||||||
    - "High"
 | 
					    - "High"
 | 
				
			||||||
    - "Low"
 | 
					    - "Low"
 | 
				
			||||||
imagesreload:
 | 
					 | 
				
			||||||
  desc: "Reloads images bot is using. Safe to use even when bot is being used heavily."
 | 
					 | 
				
			||||||
  args:
 | 
					 | 
				
			||||||
    - ""
 | 
					 | 
				
			||||||
stringsreload:
 | 
					stringsreload:
 | 
				
			||||||
  desc: "Reloads localized bot strings."
 | 
					  desc: "Reloads localized bot strings."
 | 
				
			||||||
  args:
 | 
					  args:
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user