mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-10 09:18:27 -04: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
|
||||
|
||||
## 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
|
||||
|
||||
### Fixed
|
||||
|
@@ -21,7 +21,7 @@ namespace NadekoBot.Tests
|
||||
var stringsSource = new LocalFileStringsSource(
|
||||
responsesPath,
|
||||
commandsPath);
|
||||
var strings = new LocalBotStringsProvider(stringsSource);
|
||||
var strings = new MemoryBotStringsProvider(stringsSource);
|
||||
|
||||
var culture = new CultureInfo("en-US");
|
||||
|
||||
|
@@ -102,20 +102,20 @@ public sealed class Bot
|
||||
var svcs = new ServiceCollection().AddTransient(_ => _credsProvider.GetCreds()) // bot creds
|
||||
.AddSingleton(_credsProvider)
|
||||
.AddSingleton(_db) // database
|
||||
.AddRedis(_creds.RedisOptions) // redis
|
||||
.AddSingleton(Client) // discord socket client
|
||||
.AddSingleton(_commandService)
|
||||
// .AddSingleton(_interactionService)
|
||||
.AddSingleton(this)
|
||||
.AddSingleton<ISeria, JsonSeria>()
|
||||
.AddSingleton<IPubSub, RedisPubSub>()
|
||||
.AddSingleton<IConfigSeria, YamlSeria>()
|
||||
.AddBotStringsServices(_creds.TotalShards)
|
||||
.AddConfigServices()
|
||||
.AddConfigMigrators()
|
||||
.AddMemoryCache()
|
||||
// music
|
||||
.AddMusic();
|
||||
.AddMusic()
|
||||
// cache
|
||||
.AddCache(_creds);
|
||||
|
||||
// admin
|
||||
#if GLOBAL_NADEKO
|
||||
svcs.AddSingleton<ILogCommandService, DummyLogCommandService>();
|
||||
@@ -143,13 +143,6 @@ public sealed class Bot
|
||||
.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>()
|
||||
.AddClasses(classes => classes.AssignableToAny(
|
||||
// services
|
||||
|
@@ -15,22 +15,24 @@ public sealed class RatelimitAttribute : PreconditionAttribute
|
||||
Seconds = seconds;
|
||||
}
|
||||
|
||||
public override Task<PreconditionResult> CheckPermissionsAsync(
|
||||
public override async Task<PreconditionResult> CheckPermissionsAsync(
|
||||
ICommandContext context,
|
||||
CommandInfo command,
|
||||
IServiceProvider services)
|
||||
{
|
||||
if (Seconds == 0)
|
||||
return Task.FromResult(PreconditionResult.FromSuccess());
|
||||
return PreconditionResult.FromSuccess();
|
||||
|
||||
var cache = services.GetRequiredService<IDataCache>();
|
||||
var rem = cache.TryAddRatelimit(context.User.Id, command.Name, Seconds);
|
||||
var cache = services.GetRequiredService<IBotCache>();
|
||||
var rem = await cache.GetRatelimitAsync(
|
||||
new($"precondition:{context.User.Id}:{command.Name}"),
|
||||
Seconds.Seconds());
|
||||
|
||||
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.";
|
||||
|
||||
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>
|
||||
{
|
||||
[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
|
||||
next to the response. The color depends whether the command
|
||||
@@ -182,4 +182,4 @@ public enum ConsoleOutputType
|
||||
Normal = 0,
|
||||
Simple = 1,
|
||||
None = 2
|
||||
}
|
||||
}
|
||||
|
@@ -19,7 +19,10 @@ public sealed class Creds : IBotCredentials
|
||||
public bool UsePrivilegedIntents { get; set; }
|
||||
|
||||
[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; }
|
||||
|
||||
[Comment(
|
||||
@@ -50,8 +53,14 @@ go to https://www.patreon.com/portal -> my clients -> create client")]
|
||||
|
||||
[Comment(@"Official cleverbot api key.")]
|
||||
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; }
|
||||
|
||||
[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}""
|
||||
Windows default
|
||||
cmd: NadekoBot.exe
|
||||
args: {0}")]
|
||||
args: ""{0}""")]
|
||||
public RestartConfig RestartCommand { get; set; }
|
||||
|
||||
public Creds()
|
||||
{
|
||||
Version = 5;
|
||||
Version = 6;
|
||||
Token = string.Empty;
|
||||
UsePrivilegedIntents = true;
|
||||
OwnerIds = new List<ulong>();
|
||||
@@ -119,6 +128,7 @@ Windows default
|
||||
Patreon = new(string.Empty, string.Empty, string.Empty, string.Empty);
|
||||
BotListToken = string.Empty;
|
||||
CleverbotApiKey = string.Empty;
|
||||
BotCache = BotCacheImplemenation.Memory;
|
||||
RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password=";
|
||||
Db = new()
|
||||
{
|
||||
@@ -217,4 +227,10 @@ public class GoogleApiConfig
|
||||
{
|
||||
public string SearchId { get; init; }
|
||||
public string ImageSearchId { get; init; }
|
||||
}
|
||||
|
||||
public enum BotCacheImplemenation
|
||||
{
|
||||
Memory,
|
||||
Redis
|
||||
}
|
@@ -26,6 +26,7 @@ public interface IBotCredentials
|
||||
string TwitchClientId { get; set; }
|
||||
string TwitchClientSecret { get; set; }
|
||||
GoogleApiConfig Google { get; set; }
|
||||
BotCacheImplemenation BotCache { get; set; }
|
||||
}
|
||||
|
||||
public class RestartConfig
|
||||
|
@@ -1,9 +1,11 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.Yml;
|
||||
using Cloneable;
|
||||
|
||||
namespace NadekoBot.Common;
|
||||
|
||||
public class ImageUrls
|
||||
[Cloneable]
|
||||
public partial class ImageUrls : ICloneable<ImageUrls>
|
||||
{
|
||||
[Comment("DO NOT CHANGE")]
|
||||
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
|
||||
using Newtonsoft.Json;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Common.Pokemon;
|
||||
|
||||
public class SearchPokemon
|
||||
{
|
||||
[JsonProperty("num")]
|
||||
[JsonPropertyName("num")]
|
||||
public int Id { 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; }
|
||||
}
|
@@ -58,6 +58,9 @@ public abstract class NadekoContext : DbContext
|
||||
public DbSet<PatronUser> Patrons { get; set; }
|
||||
|
||||
public DbSet<PatronQuota> PatronQuotas { get; set; }
|
||||
|
||||
public DbSet<StreamOnlineMessage> StreamOnlineMessages { get; set; }
|
||||
|
||||
|
||||
#region Mandatory Provider-Specific Values
|
||||
|
||||
|
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
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "6.0.5")
|
||||
.HasAnnotation("ProductVersion", "6.0.6")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||
|
||||
modelBuilder.Entity("NadekoBot.Db.Models.BankUser", b =>
|
||||
@@ -333,6 +333,39 @@ namespace NadekoBot.Migrations.Mysql
|
||||
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 =>
|
||||
{
|
||||
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
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "6.0.5")
|
||||
.HasAnnotation("ProductVersion", "6.0.6")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
@@ -343,6 +343,41 @@ namespace NadekoBot.Migrations.PostgreSql
|
||||
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 =>
|
||||
{
|
||||
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)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.5");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
|
||||
|
||||
modelBuilder.Entity("NadekoBot.Db.Models.BankUser", b =>
|
||||
{
|
||||
@@ -262,6 +262,33 @@ namespace NadekoBot.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
@@ -500,14 +500,6 @@ public partial class Administration
|
||||
await ReplyConfirmLocalizedAsync(strs.message_sent);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async partial Task ImagesReload()
|
||||
{
|
||||
await _service.ReloadImagesAsync();
|
||||
await ReplyConfirmLocalizedAsync(strs.images_loading);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async partial Task StringsReload()
|
||||
|
@@ -20,7 +20,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
|
||||
|
||||
private ConcurrentDictionary<ulong?, ConcurrentDictionary<int, Timer>> autoCommands = new();
|
||||
|
||||
private readonly IImageCache _imgs;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly BotConfigService _bss;
|
||||
private readonly IPubSub _pubSub;
|
||||
@@ -28,7 +27,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
|
||||
|
||||
//keys
|
||||
private readonly TypedKey<ActivityPubData> _activitySetKey;
|
||||
private readonly TypedKey<bool> _imagesReloadKey;
|
||||
private readonly TypedKey<string> _guildLeaveKey;
|
||||
|
||||
public SelfService(
|
||||
@@ -37,7 +35,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
|
||||
DbService db,
|
||||
IBotStrings strings,
|
||||
IBotCredentials creds,
|
||||
IDataCache cache,
|
||||
IHttpClientFactory factory,
|
||||
BotConfigService bss,
|
||||
IPubSub pubSub,
|
||||
@@ -48,20 +45,15 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
|
||||
_strings = strings;
|
||||
_client = client;
|
||||
_creds = creds;
|
||||
_imgs = cache.LocalImages;
|
||||
_httpFactory = factory;
|
||||
_bss = bss;
|
||||
_pubSub = pubSub;
|
||||
_eb = eb;
|
||||
_activitySetKey = new("activity.set");
|
||||
_imagesReloadKey = new("images.reload");
|
||||
_guildLeaveKey = new("guild.leave");
|
||||
|
||||
HandleStatusChanges();
|
||||
|
||||
if (_client.ShardId == 0)
|
||||
_pubSub.Sub(_imagesReloadKey, async _ => await _imgs.Reload());
|
||||
|
||||
_pubSub.Sub(_guildLeaveKey,
|
||||
async input =>
|
||||
{
|
||||
@@ -325,9 +317,6 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
public Task ReloadImagesAsync()
|
||||
=> _pubSub.Pub(_imagesReloadKey, true);
|
||||
|
||||
public bool ForwardMessages()
|
||||
{
|
||||
var isForwarding = false;
|
||||
|
@@ -19,8 +19,8 @@ public partial class Gambling
|
||||
private static readonly char[] _fateRolls = { '-', ' ', '+' };
|
||||
private readonly IImageCache _images;
|
||||
|
||||
public DiceRollCommands(IDataCache data)
|
||||
=> _images = data.LocalImages;
|
||||
public DiceRollCommands(ImageCache images)
|
||||
=> _images = images;
|
||||
|
||||
[Cmd]
|
||||
public async partial Task Roll()
|
||||
@@ -31,10 +31,10 @@ public partial class Gambling
|
||||
var num1 = gen / 10;
|
||||
var num2 = gen % 10;
|
||||
|
||||
using var img1 = GetDice(num1);
|
||||
using var img2 = GetDice(num2);
|
||||
using var img1 = await GetDiceAsync(num1);
|
||||
using var img2 = await GetDiceAsync(num2);
|
||||
using var img = new[] { img1, img2 }.Merge(out var format);
|
||||
await using var ms = img.ToStream(format);
|
||||
await using var ms = await img.ToStreamAsync(format);
|
||||
await ctx.Channel.SendFileAsync(ms,
|
||||
$"dice.{format.FileExtensions.First()}",
|
||||
Format.Bold(ctx.User.ToString()) + " " + GetText(strs.dice_rolled(Format.Code(gen.ToString()))));
|
||||
@@ -96,7 +96,7 @@ public partial class Gambling
|
||||
else
|
||||
toInsert = dice.Count;
|
||||
|
||||
dice.Insert(toInsert, GetDice(randomNumber));
|
||||
dice.Insert(toInsert, await GetDiceAsync(randomNumber));
|
||||
values.Insert(toInsert, randomNumber);
|
||||
}
|
||||
|
||||
@@ -195,20 +195,19 @@ public partial class Gambling
|
||||
await ReplyConfirmLocalizedAsync(strs.dice_rolled(Format.Bold(rolled.ToString())));
|
||||
}
|
||||
|
||||
private Image<Rgba32> GetDice(int num)
|
||||
private async Task<Image<Rgba32>> GetDiceAsync(int num)
|
||||
{
|
||||
if (num is < 0 or > 10)
|
||||
throw new ArgumentOutOfRangeException(nameof(num));
|
||||
|
||||
if (num == 10)
|
||||
{
|
||||
var images = _images.Dice;
|
||||
using var imgOne = Image.Load(images[1]);
|
||||
using var imgZero = Image.Load(images[0]);
|
||||
using var imgOne = Image.Load(await _images.GetDiceAsync(1));
|
||||
using var imgZero = Image.Load(await _images.GetDiceAsync(0));
|
||||
return new[] { imgOne, imgZero }.Merge();
|
||||
}
|
||||
|
||||
return Image.Load(_images.Dice[num]);
|
||||
return Image.Load(await _images.GetDiceAsync(num));
|
||||
}
|
||||
}
|
||||
}
|
@@ -14,8 +14,8 @@ public partial class Gambling
|
||||
private static readonly ConcurrentDictionary<IGuild, Deck> _allDecks = new();
|
||||
private readonly IImageCache _images;
|
||||
|
||||
public DrawCommands(IDataCache data)
|
||||
=> _images = data.LocalImages;
|
||||
public DrawCommands(IImageCache images)
|
||||
=> _images = images;
|
||||
|
||||
private async Task<(Stream ImageStream, string ToSend)> InternalDraw(int num, ulong? guildId = null)
|
||||
{
|
||||
@@ -43,7 +43,8 @@ public partial class Gambling
|
||||
|
||||
var currentCard = cards.Draw();
|
||||
cardObjects.Add(currentCard);
|
||||
images.Add(Image.Load(_images.GetCard(currentCard.ToString().ToLowerInvariant().Replace(' ', '_'))));
|
||||
var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_');
|
||||
images.Add(Image.Load(await File.ReadAllBytesAsync($"data/images/cards/{cardName}.png")));
|
||||
}
|
||||
|
||||
using var img = images.Merge();
|
||||
|
@@ -25,11 +25,17 @@ public partial class Gambling
|
||||
private static readonly NadekoRandom _rng = new();
|
||||
private readonly IImageCache _images;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly ImagesConfig _ic;
|
||||
|
||||
public FlipCoinCommands(IDataCache data, ICurrencyService cs, GamblingConfigService gss)
|
||||
public FlipCoinCommands(
|
||||
IImageCache images,
|
||||
ImagesConfig ic,
|
||||
ICurrencyService cs,
|
||||
GamblingConfigService gss)
|
||||
: base(gss)
|
||||
{
|
||||
_images = data.LocalImages;
|
||||
_ic = ic;
|
||||
_images = images;
|
||||
_cs = cs;
|
||||
}
|
||||
|
||||
@@ -47,8 +53,8 @@ public partial class Gambling
|
||||
var imgs = new Image<Rgba32>[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var headsArr = _images.Heads[_rng.Next(0, _images.Heads.Count)];
|
||||
var tailsArr = _images.Tails[_rng.Next(0, _images.Tails.Count)];
|
||||
var headsArr = await _images.GetHeadsImageAsync();
|
||||
var tailsArr = await _images.GetTailsImageAsync();
|
||||
if (_rng.Next(0, 10) < 5)
|
||||
{
|
||||
imgs[i] = Image.Load(headsArr);
|
||||
@@ -94,7 +100,7 @@ public partial class Gambling
|
||||
|
||||
BetFlipGuess result;
|
||||
Uri imageToSend;
|
||||
var coins = _images.ImageUrls.Coins;
|
||||
var coins = _ic.Data.Coins;
|
||||
if (_rng.Next(0, 1000) <= 499)
|
||||
{
|
||||
imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)];
|
||||
|
@@ -38,7 +38,6 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
|
||||
private readonly DbService _db;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly IDataCache _cache;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly NumberFormatInfo _enUsCulture;
|
||||
private readonly DownloadTracker _tracker;
|
||||
@@ -51,7 +50,6 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
public Gambling(
|
||||
DbService db,
|
||||
ICurrencyService currency,
|
||||
IDataCache cache,
|
||||
DiscordSocketClient client,
|
||||
DownloadTracker tracker,
|
||||
GamblingConfigService configService,
|
||||
@@ -61,7 +59,6 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
{
|
||||
_db = db;
|
||||
_cs = currency;
|
||||
_cache = cache;
|
||||
_client = client;
|
||||
_bank = bank;
|
||||
_ps = ps;
|
||||
@@ -124,7 +121,7 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
return;
|
||||
}
|
||||
|
||||
if (_cache.AddTimelyClaim(ctx.User.Id, period) is { } rem)
|
||||
if (await _service.ClaimTimelyAsync(ctx.User.Id, period) is { } rem)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var relativeTag = TimestampTag.FromDateTime(now.Add(rem), TimestampTagStyles.Relative);
|
||||
@@ -145,7 +142,7 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
[OwnerOnly]
|
||||
public async partial Task TimelyReset()
|
||||
{
|
||||
_cache.RemoveAllTimelyClaims();
|
||||
await _service.RemoveAllTimelyClaimsAsync();
|
||||
await ReplyConfirmLocalizedAsync(strs.timely_reset);
|
||||
}
|
||||
|
||||
|
@@ -1,16 +1,13 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db;
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Migrations;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Common.Connect4;
|
||||
using NadekoBot.Modules.Gambling.Common.Slot;
|
||||
using NadekoBot.Modules.Gambling.Common.WheelOfFortune;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
@@ -22,7 +19,7 @@ public class GamblingService : INService, IReadyExecutor
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly Bot _bot;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IDataCache _cache;
|
||||
private readonly IBotCache _cache;
|
||||
private readonly GamblingConfigService _gss;
|
||||
|
||||
public GamblingService(
|
||||
@@ -30,7 +27,7 @@ public class GamblingService : INService, IReadyExecutor
|
||||
Bot bot,
|
||||
ICurrencyService cs,
|
||||
DiscordSocketClient client,
|
||||
IDataCache cache,
|
||||
IBotCache cache,
|
||||
GamblingConfigService gss)
|
||||
{
|
||||
_db = db;
|
||||
@@ -73,6 +70,7 @@ public class GamblingService : INService, IReadyExecutor
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly TypedKey<long> _curDecayKey = new("currency:last_decay");
|
||||
private async Task CurrencyDecayLoopAsync()
|
||||
{
|
||||
if (_bot.Client.ShardId != 0)
|
||||
@@ -88,11 +86,16 @@ public class GamblingService : INService, IReadyExecutor
|
||||
if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0)
|
||||
continue;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var lastCurrencyDecay = _cache.GetLastCurrencyDecay();
|
||||
var result = await _cache.GetAsync(_curDecayKey);
|
||||
|
||||
if (DateTime.UtcNow - lastCurrencyDecay < TimeSpan.FromHours(config.Decay.HourInterval))
|
||||
if (result.TryPickT0(out var bin, out _)
|
||||
&& (now - DateTime.FromBinary(bin) < TimeSpan.FromHours(config.Decay.HourInterval)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.Information(@"Decaying users' currency - decay: {ConfigDecayPercent}%
|
||||
| max: {MaxDecay}
|
||||
@@ -115,8 +118,9 @@ public class GamblingService : INService, IReadyExecutor
|
||||
: old.CurrencyAmount - maxDecay
|
||||
});
|
||||
|
||||
_cache.SetLastCurrencyDecay();
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
await _cache.AddAsync(_curDecayKey, now.ToBinary());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -161,60 +165,100 @@ public class GamblingService : INService, IReadyExecutor
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
private static readonly TypedKey<EconomyResult> _ecoKey = new("nadeko:economy");
|
||||
|
||||
public async Task<EconomyResult> GetEconomyAsync()
|
||||
{
|
||||
if (_cache.TryGetEconomy(out var data))
|
||||
{
|
||||
try
|
||||
var data = await _cache.GetOrAddAsync(_ecoKey,
|
||||
async () =>
|
||||
{
|
||||
return JsonConvert.DeserializeObject<EconomyResult>(data);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
await using var uow = _db.GetDbContext();
|
||||
var cash = uow.DiscordUser.GetTotalCurrency();
|
||||
var onePercent = uow.DiscordUser.GetTopOnePercentCurrency(_client.CurrentUser.Id);
|
||||
decimal planted = uow.PlantedCurrency.AsQueryable().Sum(x => x.Amount);
|
||||
var waifus = uow.WaifuInfo.GetTotalValue();
|
||||
var bot = uow.DiscordUser.GetUserCurrency(_client.CurrentUser.Id);
|
||||
decimal bank = await uow.GetTable<BankUser>()
|
||||
.SumAsyncLinqToDB(x => x.Balance);
|
||||
|
||||
decimal cash;
|
||||
decimal onePercent;
|
||||
decimal planted;
|
||||
decimal waifus;
|
||||
decimal bank;
|
||||
long bot;
|
||||
var result = new EconomyResult
|
||||
{
|
||||
Cash = cash,
|
||||
Planted = planted,
|
||||
Bot = bot,
|
||||
Waifus = waifus,
|
||||
OnePercent = onePercent,
|
||||
Bank = bank
|
||||
};
|
||||
|
||||
using (var uow = _db.GetDbContext())
|
||||
{
|
||||
cash = uow.DiscordUser.GetTotalCurrency();
|
||||
onePercent = uow.DiscordUser.GetTopOnePercentCurrency(_client.CurrentUser.Id);
|
||||
planted = uow.PlantedCurrency.AsQueryable().Sum(x => x.Amount);
|
||||
waifus = uow.WaifuInfo.GetTotalValue();
|
||||
bot = uow.DiscordUser.GetUserCurrency(_client.CurrentUser.Id);
|
||||
bank = await uow.GetTable<BankUser>()
|
||||
.SumAsyncLinqToDB(x => x.Balance);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
TimeSpan.FromMinutes(3));
|
||||
|
||||
var result = new EconomyResult
|
||||
{
|
||||
Cash = cash,
|
||||
Planted = planted,
|
||||
Bot = bot,
|
||||
Waifus = waifus,
|
||||
OnePercent = onePercent,
|
||||
Bank = bank
|
||||
};
|
||||
|
||||
_cache.SetEconomy(JsonConvert.SerializeObject(result));
|
||||
return result;
|
||||
return data;
|
||||
}
|
||||
|
||||
public Task<WheelOfFortuneGame.Result> WheelOfFortuneSpinAsync(ulong userId, long bet)
|
||||
=> new WheelOfFortuneGame(userId, bet, _gss.Data, _cs).SpinAsync();
|
||||
|
||||
|
||||
public struct EconomyResult
|
||||
private static readonly SemaphoreSlim _timelyLock = new (1, 1);
|
||||
|
||||
private static TypedKey<Dictionary<ulong, long>> _timelyKey
|
||||
= new("timely:claims");
|
||||
public async Task<TimeSpan?> ClaimTimelyAsync(ulong userId, int period)
|
||||
{
|
||||
public decimal Cash { get; set; }
|
||||
public decimal Planted { get; set; }
|
||||
public decimal Waifus { get; set; }
|
||||
public decimal OnePercent { get; set; }
|
||||
public decimal Bank { get; set; }
|
||||
public long Bot { get; set; }
|
||||
if (period == 0)
|
||||
return null;
|
||||
|
||||
await _timelyLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// get the dictionary from the cache or get a new one
|
||||
var dict = (await _cache.GetOrAddAsync(_timelyKey,
|
||||
() => Task.FromResult(new Dictionary<ulong, long>())))!;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var nowB = now.ToBinary();
|
||||
|
||||
// try to get users last claim
|
||||
if (!dict.TryGetValue(userId, out var lastB))
|
||||
lastB = dict[userId] = now.ToBinary();
|
||||
|
||||
var diff = now - DateTime.FromBinary(lastB);
|
||||
|
||||
// if its now, or too long ago => success
|
||||
if (lastB == nowB || diff > period.Hours())
|
||||
{
|
||||
// update the cache
|
||||
dict[userId] = nowB;
|
||||
await _cache.AddAsync(_timelyKey, dict);
|
||||
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// otherwise return the remaining time
|
||||
return period.Hours() - diff;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _timelyLock.WaitAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveAllTimelyClaimsAsync()
|
||||
=> await _cache.RemoveAsync(_timelyKey);
|
||||
|
||||
|
||||
public readonly struct EconomyResult
|
||||
{
|
||||
public decimal Cash { get; init; }
|
||||
public decimal Planted { get; init; }
|
||||
public decimal Waifus { get; init; }
|
||||
public decimal OnePercent { get; init; }
|
||||
public decimal Bank { get; init; }
|
||||
public long Bot { get; init; }
|
||||
}
|
||||
}
|
@@ -34,7 +34,7 @@ public class PlantPickService : INService, IExecNoCommand
|
||||
DbService db,
|
||||
CommandHandler cmd,
|
||||
IBotStrings strings,
|
||||
IDataCache cache,
|
||||
IImageCache images,
|
||||
FontProvider fonts,
|
||||
ICurrencyService cs,
|
||||
CommandHandler cmdHandler,
|
||||
@@ -43,7 +43,7 @@ public class PlantPickService : INService, IExecNoCommand
|
||||
{
|
||||
_db = db;
|
||||
_strings = strings;
|
||||
_images = cache.LocalImages;
|
||||
_images = images;
|
||||
_fonts = fonts;
|
||||
_cs = cs;
|
||||
_cmdHandler = cmdHandler;
|
||||
@@ -110,30 +110,21 @@ public class PlantPickService : INService, IExecNoCommand
|
||||
/// <param name="pass">Optional password to add to top left corner.</param>
|
||||
/// <param name="extension">Extension of the file, defaults to png</param>
|
||||
/// <returns>Stream of the currency image</returns>
|
||||
public Stream GetRandomCurrencyImage(string pass, out string extension)
|
||||
public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass)
|
||||
{
|
||||
// get a random currency image bytes
|
||||
var rng = new NadekoRandom();
|
||||
var curImg = _images.Currency[rng.Next(0, _images.Currency.Count)];
|
||||
var curImg = await _images.GetCurrencyImageAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pass))
|
||||
{
|
||||
// determine the extension
|
||||
using (_ = Image.Load(curImg, out var format))
|
||||
{
|
||||
extension = format.FileExtensions.FirstOrDefault() ?? "png";
|
||||
}
|
||||
using var load = _ = Image.Load(curImg, out var format);
|
||||
|
||||
// return the image
|
||||
return curImg.ToStream();
|
||||
return (curImg.ToStream(), format.FileExtensions.FirstOrDefault() ?? "png");
|
||||
}
|
||||
|
||||
// get the image stream and extension
|
||||
var (s, ext) = AddPassword(curImg, pass);
|
||||
// set the out extension parameter to the extension we've got
|
||||
extension = ext;
|
||||
// return the image
|
||||
return s;
|
||||
return AddPassword(curImg, pass);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -214,10 +205,10 @@ public class PlantPickService : INService, IExecNoCommand
|
||||
var pw = config.Generation.HasPassword ? GenerateCurrencyPassword().ToUpperInvariant() : null;
|
||||
|
||||
IUserMessage sent;
|
||||
await using (var stream = GetRandomCurrencyImage(pw, out var ext))
|
||||
{
|
||||
var (stream, ext) = await GetRandomCurrencyImageAsync(pw);
|
||||
|
||||
await using (stream)
|
||||
sent = await channel.SendFileAsync(stream, $"currency_image.{ext}", toSend);
|
||||
}
|
||||
|
||||
await AddPlantToDatabase(channel.GuildId,
|
||||
channel.Id,
|
||||
@@ -278,7 +269,7 @@ public class PlantPickService : INService, IExecNoCommand
|
||||
if (amount > 0)
|
||||
// give the picked currency to the user
|
||||
await _cs.AddAsync(uid, amount, new("currency", "collect"));
|
||||
uow.SaveChanges();
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
try
|
||||
@@ -316,11 +307,14 @@ public class PlantPickService : INService, IExecNoCommand
|
||||
msgToSend += " " + GetText(gid, strs.pick_sn(prefix));
|
||||
|
||||
//get the image
|
||||
await using var stream = GetRandomCurrencyImage(pass, out var ext);
|
||||
var (stream, ext) = await GetRandomCurrencyImageAsync(pass);
|
||||
// send it
|
||||
var msg = await ch.SendFileAsync(stream, $"img.{ext}", msgToSend);
|
||||
// return sent message's id (in order to be able to delete it when it's picked)
|
||||
return msg.Id;
|
||||
await using (stream)
|
||||
{
|
||||
var msg = await ch.SendFileAsync(stream, $"img.{ext}", msgToSend);
|
||||
// return sent message's id (in order to be able to delete it when it's picked)
|
||||
return msg.Id;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
@@ -32,13 +32,13 @@ public partial class Gambling
|
||||
private readonly DbService _db;
|
||||
|
||||
public SlotCommands(
|
||||
IDataCache data,
|
||||
ImageCache images,
|
||||
FontProvider fonts,
|
||||
DbService db,
|
||||
GamblingConfigService gamb)
|
||||
: base(gamb)
|
||||
{
|
||||
_images = data.LocalImages;
|
||||
_images = images;
|
||||
_fonts = fonts;
|
||||
_db = db;
|
||||
}
|
||||
@@ -130,7 +130,8 @@ public partial class Gambling
|
||||
?? 0;
|
||||
}
|
||||
|
||||
using (var bgImage = Image.Load<Rgba32>(_images.SlotBackground, out _))
|
||||
var slotBg = await _images.GetSlotBgAsync();
|
||||
using (var bgImage = Image.Load<Rgba32>(slotBg, out _))
|
||||
{
|
||||
var numbers = new int[3];
|
||||
result.Rolls.CopyTo(numbers, 0);
|
||||
@@ -184,7 +185,7 @@ public partial class Gambling
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var img = Image.Load(_images.SlotEmojis[numbers[i]]);
|
||||
using var img = Image.Load(await _images.GetSlotEmojiAsync(numbers[i]));
|
||||
bgImage.Mutate(x => x.DrawImage(img, new Point(148 + (105 * i), 217), 1f));
|
||||
}
|
||||
|
||||
@@ -201,7 +202,7 @@ public partial class Gambling
|
||||
msg = GetText(strs.slot_jackpot(30));
|
||||
}
|
||||
|
||||
await using (var imgStream = bgImage.ToStream())
|
||||
await using (var imgStream = await bgImage.ToStreamAsync())
|
||||
{
|
||||
await ctx.Channel.SendFileAsync(imgStream,
|
||||
"result.png",
|
||||
|
@@ -1,5 +1,6 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db;
|
||||
@@ -14,7 +15,7 @@ public class WaifuService : INService, IReadyExecutor
|
||||
{
|
||||
private readonly DbService _db;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly IDataCache _cache;
|
||||
private readonly IBotCache _cache;
|
||||
private readonly GamblingConfigService _gss;
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly DiscordSocketClient _client;
|
||||
@@ -22,7 +23,7 @@ public class WaifuService : INService, IReadyExecutor
|
||||
public WaifuService(
|
||||
DbService db,
|
||||
ICurrencyService cs,
|
||||
IDataCache cache,
|
||||
IBotCache cache,
|
||||
GamblingConfigService gss,
|
||||
IBotCredentials creds,
|
||||
DiscordSocketClient client)
|
||||
@@ -236,8 +237,13 @@ public class WaifuService : INService, IReadyExecutor
|
||||
var newAff = target is null ? null : uow.GetOrCreateUser(target);
|
||||
if (w?.Affinity?.UserId == target?.Id)
|
||||
{
|
||||
return (null, false, null);
|
||||
}
|
||||
else if (!_cache.TryAddAffinityCooldown(user.Id, out remaining))
|
||||
|
||||
remaining = await _cache.GetRatelimitAsync(GetAffinityKey(user.Id),
|
||||
30.Minutes());
|
||||
|
||||
if (remaining is not null)
|
||||
{
|
||||
}
|
||||
else if (w is null)
|
||||
@@ -294,6 +300,12 @@ public class WaifuService : INService, IReadyExecutor
|
||||
return uow.WaifuInfo.GetWaifuUserId(ownerId, name);
|
||||
}
|
||||
|
||||
private static TypedKey<long> GetDivorceKey(ulong userId)
|
||||
=> new($"waifu:divorce_cd:{userId}");
|
||||
|
||||
private static TypedKey<long> GetAffinityKey(ulong userId)
|
||||
=> new($"waifu:affinity:{userId}");
|
||||
|
||||
public async Task<(WaifuInfo, DivorceResult, long, TimeSpan?)> DivorceWaifuAsync(IUser user, ulong targetId)
|
||||
{
|
||||
DivorceResult result;
|
||||
@@ -305,10 +317,15 @@ public class WaifuService : INService, IReadyExecutor
|
||||
w = uow.WaifuInfo.ByWaifuUserId(targetId);
|
||||
if (w?.Claimer is null || w.Claimer.UserId != user.Id)
|
||||
result = DivorceResult.NotYourWife;
|
||||
else if (!_cache.TryAddDivorceCooldown(user.Id, out remaining))
|
||||
result = DivorceResult.Cooldown;
|
||||
else
|
||||
{
|
||||
remaining = await _cache.GetRatelimitAsync(GetDivorceKey(user.Id), 6.Hours());
|
||||
if (remaining is TimeSpan rem)
|
||||
{
|
||||
result = DivorceResult.Cooldown;
|
||||
return (w, result, amount, rem);
|
||||
}
|
||||
|
||||
amount = w.Price / 2;
|
||||
|
||||
if (w.Affinity?.UserId == user.Id)
|
||||
@@ -486,13 +503,13 @@ public class WaifuService : INService, IReadyExecutor
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static readonly TypedKey<long> _waifuDecayKey = $"waifu:last_decay";
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
// only decay waifu values from shard 0
|
||||
if (_client.ShardId != 0)
|
||||
return;
|
||||
|
||||
var redisKey = $"{_creds.RedisKey()}_last_waifu_decay";
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
@@ -504,28 +521,31 @@ public class WaifuService : INService, IReadyExecutor
|
||||
if (multi is < 0f or > 1f || decayInterval < 0)
|
||||
continue;
|
||||
|
||||
var val = await _cache.Redis.GetDatabase().StringGetAsync(redisKey);
|
||||
if (val != default)
|
||||
var now = DateTime.UtcNow;
|
||||
var nowB = now.ToBinary();
|
||||
|
||||
var result = await _cache.GetAsync(_waifuDecayKey);
|
||||
|
||||
if (result.TryGetValue(out var val))
|
||||
{
|
||||
var lastDecay = DateTime.FromBinary((long)val);
|
||||
var lastDecay = DateTime.FromBinary(val);
|
||||
var toWait = decayInterval.Hours() - (DateTime.UtcNow - lastDecay);
|
||||
|
||||
if (toWait > 0.Hours())
|
||||
continue;
|
||||
}
|
||||
|
||||
await _cache.Redis.GetDatabase().StringSetAsync(redisKey, DateTime.UtcNow.ToBinary());
|
||||
await _cache.AddAsync(_waifuDecayKey, nowB);
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
|
||||
await uow.WaifuInfo
|
||||
await uow.GetTable<WaifuInfo>()
|
||||
.Where(x => x.Price > minPrice && x.ClaimerId == null)
|
||||
.UpdateAsync(old => new()
|
||||
{
|
||||
Price = (long)(old.Price * multi)
|
||||
});
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
@@ -14,9 +14,9 @@ public partial class Games : NadekoModule<GamesService>
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly Random _rng = new();
|
||||
|
||||
public Games(IDataCache data, IHttpClientFactory factory)
|
||||
public Games(IImageCache images, IHttpClientFactory factory)
|
||||
{
|
||||
_images = data.LocalImages;
|
||||
_images = images;
|
||||
_httpFactory = factory;
|
||||
}
|
||||
|
||||
|
@@ -28,11 +28,12 @@ public class GirlRating
|
||||
Roll = roll;
|
||||
Advice = advice; // convenient to have it here, even though atm there are only few different ones.
|
||||
|
||||
Stream = new(() =>
|
||||
Stream = new(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var img = Image.Load(_images.RategirlMatrix);
|
||||
var bgBytes = await _images.GetRategirlBgAsync();
|
||||
using var img = Image.Load(bgBytes);
|
||||
const int minx = 35;
|
||||
const int miny = 385;
|
||||
const int length = 345;
|
||||
@@ -40,7 +41,8 @@ public class GirlRating
|
||||
var pointx = (int)(minx + (length * (Hot / 10)));
|
||||
var pointy = (int)(miny - (length * ((Crazy - 4) / 6)));
|
||||
|
||||
using (var pointImg = Image.Load(_images.RategirlDot))
|
||||
var dotBytes = await _images.GetRategirlDotAsync();
|
||||
using (var pointImg = Image.Load(dotBytes))
|
||||
{
|
||||
img.Mutate(x => x.DrawImage(pointImg, new(pointx - 10, pointy - 10), new GraphicsOptions()));
|
||||
}
|
||||
|
@@ -9,14 +9,14 @@ public partial class Games
|
||||
[Group]
|
||||
public partial class TriviaCommands : NadekoModule<GamesService>
|
||||
{
|
||||
private readonly IDataCache _cache;
|
||||
private readonly ILocalDataCache _cache;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly GamesConfigService _gamesConfig;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public TriviaCommands(
|
||||
DiscordSocketClient client,
|
||||
IDataCache cache,
|
||||
ILocalDataCache cache,
|
||||
ICurrencyService cs,
|
||||
GamesConfigService gamesConfig)
|
||||
{
|
||||
|
@@ -17,7 +17,7 @@ public class TriviaGame
|
||||
public bool GameActive { get; private set; }
|
||||
public bool ShouldStopGame { get; private set; }
|
||||
private readonly SemaphoreSlim _guessLock = new(1, 1);
|
||||
private readonly IDataCache _cache;
|
||||
private readonly ILocalDataCache _cache;
|
||||
private readonly IBotStrings _strings;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly GamesConfig _config;
|
||||
@@ -35,7 +35,7 @@ public class TriviaGame
|
||||
IBotStrings strings,
|
||||
DiscordSocketClient client,
|
||||
GamesConfig config,
|
||||
IDataCache cache,
|
||||
ILocalDataCache cache,
|
||||
ICurrencyService cs,
|
||||
IGuild guild,
|
||||
ITextChannel channel,
|
||||
@@ -70,7 +70,7 @@ public class TriviaGame
|
||||
showHowToQuit = !showHowToQuit;
|
||||
|
||||
// load question
|
||||
CurrentQuestion = _questionPool.GetRandomQuestion(OldQuestions, _options.IsPokemon);
|
||||
CurrentQuestion = await _questionPool.GetRandomQuestionAsync(OldQuestions, _options.IsPokemon);
|
||||
if (string.IsNullOrWhiteSpace(CurrentQuestion?.Answer)
|
||||
|| string.IsNullOrWhiteSpace(CurrentQuestion.Question))
|
||||
{
|
||||
|
@@ -4,6 +4,15 @@ using System.Text.RegularExpressions;
|
||||
// THANKS @ShoMinamimoto for suggestions and coding help
|
||||
namespace NadekoBot.Modules.Games.Common.Trivia;
|
||||
|
||||
public sealed class TriviaQuestionModel
|
||||
{
|
||||
public string Category { get; init; }
|
||||
public string Question { get; init; }
|
||||
public string ImageUrl { get; init; }
|
||||
public string AnswerImageUrl { get; init; }
|
||||
public string Answer { get; init; }
|
||||
}
|
||||
|
||||
public class TriviaQuestion
|
||||
{
|
||||
public const int MAX_STRING_LENGTH = 22;
|
||||
@@ -17,29 +26,30 @@ public class TriviaQuestion
|
||||
new(22, 3)
|
||||
};
|
||||
|
||||
public string Category { get; set; }
|
||||
public string Question { get; set; }
|
||||
public string ImageUrl { get; set; }
|
||||
public string AnswerImageUrl { get; set; }
|
||||
public string Answer { get; set; }
|
||||
public string Category
|
||||
=> _qModel.Category;
|
||||
|
||||
public string Question
|
||||
=> _qModel.Question;
|
||||
|
||||
public string ImageUrl
|
||||
=> _qModel.ImageUrl;
|
||||
|
||||
public string AnswerImageUrl
|
||||
=> _qModel.AnswerImageUrl ?? ImageUrl;
|
||||
|
||||
public string Answer
|
||||
=> _qModel.Answer;
|
||||
|
||||
public string CleanAnswer
|
||||
=> cleanAnswer ?? (cleanAnswer = Clean(Answer));
|
||||
|
||||
private string cleanAnswer;
|
||||
private readonly TriviaQuestionModel _qModel;
|
||||
|
||||
public TriviaQuestion(
|
||||
string q,
|
||||
string a,
|
||||
string c,
|
||||
string img = null,
|
||||
string answerImage = null)
|
||||
public TriviaQuestion(TriviaQuestionModel qModel)
|
||||
{
|
||||
Question = q;
|
||||
Answer = a;
|
||||
Category = c;
|
||||
ImageUrl = img;
|
||||
AnswerImageUrl = answerImage ?? img;
|
||||
_qModel = qModel;
|
||||
}
|
||||
|
||||
public string GetHint()
|
||||
|
@@ -1,45 +1,48 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Games.Common.Trivia;
|
||||
|
||||
public class TriviaQuestionPool
|
||||
{
|
||||
private TriviaQuestion[] Pool
|
||||
=> _cache.LocalData.TriviaQuestions;
|
||||
|
||||
private IReadOnlyDictionary<int, string> Map
|
||||
=> _cache.LocalData.PokemonMap;
|
||||
|
||||
private readonly IDataCache _cache;
|
||||
private readonly ILocalDataCache _cache;
|
||||
private readonly int _maxPokemonId;
|
||||
|
||||
private readonly NadekoRandom _rng = new();
|
||||
|
||||
public TriviaQuestionPool(IDataCache cache)
|
||||
public TriviaQuestionPool(ILocalDataCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
_maxPokemonId = 721; //xd
|
||||
}
|
||||
|
||||
public TriviaQuestion GetRandomQuestion(HashSet<TriviaQuestion> exclude, bool isPokemon)
|
||||
public async Task<TriviaQuestion?> GetRandomQuestionAsync(HashSet<TriviaQuestion> exclude, bool isPokemon)
|
||||
{
|
||||
if (Pool.Length == 0)
|
||||
return null;
|
||||
|
||||
if (isPokemon)
|
||||
{
|
||||
var pokes = await _cache.GetPokemonMapAsync();
|
||||
|
||||
if (pokes is null or { Length: 0 })
|
||||
return default;
|
||||
|
||||
var num = _rng.Next(1, _maxPokemonId + 1);
|
||||
return new("Who's That Pokémon?",
|
||||
Map[num].ToTitleCase(),
|
||||
"Pokemon",
|
||||
$@"https://nadeko.bot/images/pokemon/shadows/{num}.png",
|
||||
$@"https://nadeko.bot/images/pokemon/real/{num}.png");
|
||||
return new(new()
|
||||
{
|
||||
Question = "Who's That Pokémon?",
|
||||
Answer = pokes[num].Name.ToTitleCase(),
|
||||
Category = "Pokemon",
|
||||
ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png",
|
||||
AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png"
|
||||
});
|
||||
}
|
||||
|
||||
TriviaQuestion randomQuestion;
|
||||
while (exclude.Contains(randomQuestion = Pool[_rng.Next(0, Pool.Length)]))
|
||||
var pool = await _cache.GetTriviaQuestionsAsync();
|
||||
|
||||
if(pool is null)
|
||||
return default;
|
||||
|
||||
while (exclude.Contains(randomQuestion = new(pool[_rng.Next(0, pool.Length)])))
|
||||
{
|
||||
// if too many questions are excluded, clear the exclusion list and start over
|
||||
if (exclude.Count > Pool.Length / 10 * 9)
|
||||
if (exclude.Count > pool.Length / 10 * 9)
|
||||
{
|
||||
exclude.Clear();
|
||||
break;
|
||||
|
@@ -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
|
||||
using Newtonsoft.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches.Common;
|
||||
|
||||
public class AnimeResult
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
public string AiringStatus
|
||||
=> AiringStatusParsed.ToTitleCase();
|
||||
|
||||
[JsonProperty("airing_status")]
|
||||
[JsonPropertyName("airing_status")]
|
||||
public string AiringStatusParsed { get; set; }
|
||||
|
||||
[JsonProperty("title_english")]
|
||||
[JsonPropertyName("title_english")]
|
||||
public string TitleEnglish { get; set; }
|
||||
|
||||
[JsonProperty("total_episodes")]
|
||||
[JsonPropertyName("total_episodes")]
|
||||
public int TotalEpisodes { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonProperty("image_url_lge")]
|
||||
[JsonPropertyName("image_url_lge")]
|
||||
public string ImageUrlLarge { get; set; }
|
||||
|
||||
[JsonPropertyName("genres")]
|
||||
public string[] Genres { get; set; }
|
||||
|
||||
[JsonProperty("average_score")]
|
||||
public string AverageScore { get; set; }
|
||||
[JsonPropertyName("average_score")]
|
||||
public float AverageScore { get; set; }
|
||||
|
||||
|
||||
public string AiringStatus
|
||||
=> AiringStatusParsed.ToTitleCase();
|
||||
|
||||
public string Link
|
||||
=> "http://anilist.co/anime/" + Id;
|
||||
|
||||
|
@@ -1,17 +1,15 @@
|
||||
#nullable disable
|
||||
using AngleSharp;
|
||||
using AngleSharp.Html.Dom;
|
||||
using NadekoBot.Modules.Searches.Common;
|
||||
using Newtonsoft.Json;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace NadekoBot.Modules.Searches.Services;
|
||||
|
||||
public class AnimeSearchService : INService
|
||||
{
|
||||
private readonly IDataCache _cache;
|
||||
private readonly IBotCache _cache;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public AnimeSearchService(IDataCache cache, IHttpClientFactory httpFactory)
|
||||
public AnimeSearchService(IBotCache cache, IHttpClientFactory httpFactory)
|
||||
{
|
||||
_cache = cache;
|
||||
_httpFactory = httpFactory;
|
||||
@@ -21,24 +19,25 @@ public class AnimeSearchService : INService
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
throw new ArgumentNullException(nameof(query));
|
||||
|
||||
TypedKey<AnimeResult> GetKey(string link)
|
||||
=> new TypedKey<AnimeResult>($"anime2:{link}");
|
||||
|
||||
try
|
||||
{
|
||||
var link = "https://aniapi.nadeko.bot/anime/"
|
||||
+ Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
|
||||
var suffix = Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
|
||||
var link = $"https://aniapi.nadeko.bot/anime/{suffix}";
|
||||
link = link.ToLowerInvariant();
|
||||
var (ok, data) = await _cache.TryGetAnimeDataAsync(link);
|
||||
if (!ok)
|
||||
var result = await _cache.GetAsync(GetKey(link));
|
||||
if (!result.TryPickT0(out var data, out _))
|
||||
{
|
||||
using (var http = _httpFactory.CreateClient())
|
||||
{
|
||||
data = await http.GetStringAsync(link);
|
||||
}
|
||||
using var http = _httpFactory.CreateClient();
|
||||
data = await http.GetFromJsonAsync<AnimeResult>(link);
|
||||
|
||||
await _cache.SetAnimeDataAsync(link, data);
|
||||
await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(12));
|
||||
}
|
||||
|
||||
|
||||
return JsonConvert.DeserializeObject<AnimeResult>(data);
|
||||
return data;
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -46,95 +45,31 @@ public class AnimeSearchService : INService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NovelResult> GetNovelData(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
throw new ArgumentNullException(nameof(query));
|
||||
|
||||
query = query.Replace(" ", "-", StringComparison.InvariantCulture);
|
||||
try
|
||||
{
|
||||
var link = "https://www.novelupdates.com/series/"
|
||||
+ Uri.EscapeDataString(query.Replace(" ", "-").Replace("/", " "));
|
||||
link = link.ToLowerInvariant();
|
||||
var (ok, data) = await _cache.TryGetNovelDataAsync(link);
|
||||
if (!ok)
|
||||
{
|
||||
var config = Configuration.Default.WithDefaultLoader();
|
||||
using var document = await BrowsingContext.New(config).OpenAsync(link);
|
||||
var imageElem = document.QuerySelector("div.seriesimg > img");
|
||||
if (imageElem is null)
|
||||
return null;
|
||||
var imageUrl = ((IHtmlImageElement)imageElem).Source;
|
||||
|
||||
var descElem = document.QuerySelector("div#editdescription > p");
|
||||
var desc = descElem.InnerHtml;
|
||||
|
||||
var genres = document.QuerySelector("div#seriesgenre")
|
||||
.Children.Select(x => x as IHtmlAnchorElement)
|
||||
.Where(x => x is not null)
|
||||
.Select(x => $"[{x.InnerHtml}]({x.Href})")
|
||||
.ToArray();
|
||||
|
||||
var authors = document.QuerySelector("div#showauthors")
|
||||
.Children.Select(x => x as IHtmlAnchorElement)
|
||||
.Where(x => x is not null)
|
||||
.Select(x => $"[{x.InnerHtml}]({x.Href})")
|
||||
.ToArray();
|
||||
|
||||
var score = ((IHtmlSpanElement)document.QuerySelector("h5.seriesother > span.uvotes")).InnerHtml;
|
||||
|
||||
var status = document.QuerySelector("div#editstatus").InnerHtml;
|
||||
var title = document.QuerySelector("div.w-blog-content > div.seriestitlenu").InnerHtml;
|
||||
|
||||
var obj = new NovelResult
|
||||
{
|
||||
Description = desc,
|
||||
Authors = authors,
|
||||
Genres = genres,
|
||||
ImageUrl = imageUrl,
|
||||
Link = link,
|
||||
Score = score,
|
||||
Status = status,
|
||||
Title = title
|
||||
};
|
||||
|
||||
await _cache.SetNovelDataAsync(link, JsonConvert.SerializeObject(obj));
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
return JsonConvert.DeserializeObject<NovelResult>(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error getting novel data");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MangaResult> GetMangaData(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
throw new ArgumentNullException(nameof(query));
|
||||
|
||||
TypedKey<MangaResult> GetKey(string link)
|
||||
=> new TypedKey<MangaResult>($"manga2:{link}");
|
||||
|
||||
try
|
||||
{
|
||||
var link = "https://aniapi.nadeko.bot/manga/"
|
||||
+ Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
|
||||
link = link.ToLowerInvariant();
|
||||
var (ok, data) = await _cache.TryGetAnimeDataAsync(link);
|
||||
if (!ok)
|
||||
|
||||
var result = await _cache.GetAsync(GetKey(link));
|
||||
if (!result.TryPickT0(out var data, out _))
|
||||
{
|
||||
using (var http = _httpFactory.CreateClient())
|
||||
{
|
||||
data = await http.GetStringAsync(link);
|
||||
}
|
||||
using var http = _httpFactory.CreateClient();
|
||||
data = await http.GetFromJsonAsync<MangaResult>(link);
|
||||
|
||||
await _cache.SetAnimeDataAsync(link, data);
|
||||
await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(3));
|
||||
}
|
||||
|
||||
|
||||
return JsonConvert.DeserializeObject<MangaResult>(data);
|
||||
return data;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
@@ -1,32 +1,36 @@
|
||||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches.Common;
|
||||
|
||||
public class MangaResult
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonProperty("publishing_status")]
|
||||
[JsonPropertyName("publishing_status")]
|
||||
public string PublishingStatus { get; set; }
|
||||
|
||||
[JsonProperty("image_url_lge")]
|
||||
[JsonPropertyName("image_url_lge")]
|
||||
public string ImageUrlLge { get; set; }
|
||||
|
||||
[JsonProperty("title_english")]
|
||||
[JsonPropertyName("title_english")]
|
||||
public string TitleEnglish { get; set; }
|
||||
|
||||
[JsonProperty("total_chapters")]
|
||||
[JsonPropertyName("total_chapters")]
|
||||
public int TotalChapters { get; set; }
|
||||
|
||||
[JsonProperty("total_volumes")]
|
||||
[JsonPropertyName("total_volumes")]
|
||||
public int TotalVolumes { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonPropertyName("genres")]
|
||||
public string[] Genres { get; set; }
|
||||
|
||||
[JsonProperty("average_score")]
|
||||
public string AverageScore { get; set; }
|
||||
[JsonPropertyName("average_score")]
|
||||
public float AverageScore { get; set; }
|
||||
|
||||
public string Link
|
||||
=> "http://anilist.co/manga/" + Id;
|
||||
|
@@ -15,13 +15,13 @@ namespace NadekoBot.Modules.Searches.Services;
|
||||
|
||||
public class CryptoService : INService
|
||||
{
|
||||
private readonly IDataCache _cache;
|
||||
private readonly IBotCache _cache;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly IBotCredentials _creds;
|
||||
|
||||
private readonly SemaphoreSlim _getCryptoLock = new(1, 1);
|
||||
|
||||
public CryptoService(IDataCache cache, IHttpClientFactory httpFactory, IBotCredentials creds)
|
||||
public CryptoService(IBotCache cache, IHttpClientFactory httpFactory, IBotCredentials creds)
|
||||
{
|
||||
_cache = cache;
|
||||
_httpFactory = httpFactory;
|
||||
@@ -40,7 +40,8 @@ public class CryptoService : INService
|
||||
Span<PointF> points = new PointF[gElement.ChildNodes.Count];
|
||||
var cnt = 0;
|
||||
|
||||
bool GetValuesFromAttributes(XmlAttributeCollection attrs,
|
||||
bool GetValuesFromAttributes(
|
||||
XmlAttributeCollection attrs,
|
||||
out float x1,
|
||||
out float y1,
|
||||
out float x2,
|
||||
@@ -56,7 +57,7 @@ public class CryptoService : INService
|
||||
&& attrs["y2"]?.Value is string y2Str
|
||||
&& float.TryParse(y2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y2);
|
||||
}
|
||||
|
||||
|
||||
foreach (XmlElement x in gElement.ChildNodes)
|
||||
{
|
||||
if (x.Name != "line")
|
||||
@@ -67,22 +68,22 @@ public class CryptoService : INService
|
||||
points[cnt++] = new(x1, y1);
|
||||
// this point will be set twice to the same value
|
||||
// on all points except the last one
|
||||
if(cnt + 1 < points.Length)
|
||||
if (cnt + 1 < points.Length)
|
||||
points[cnt + 1] = new(x2, y2);
|
||||
}
|
||||
}
|
||||
|
||||
if (cnt == 0)
|
||||
return Array.Empty<PointF>();
|
||||
|
||||
|
||||
return points.Slice(0, cnt).ToArray();
|
||||
}
|
||||
|
||||
|
||||
private SixLabors.ImageSharp.Image<Rgba32> GenerateSparklineChart(PointF[] points, bool up)
|
||||
{
|
||||
const int width = 164;
|
||||
const int height = 48;
|
||||
|
||||
|
||||
var img = new Image<Rgba32>(width, height, Color.Transparent);
|
||||
var color = up
|
||||
? Color.Green
|
||||
@@ -92,10 +93,10 @@ public class CryptoService : INService
|
||||
{
|
||||
x.DrawLines(color, 2, points);
|
||||
});
|
||||
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
|
||||
public async Task<(CmcResponseData? Data, CmcResponseData? Nearest)> GetCryptoData(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
@@ -130,20 +131,20 @@ public class CryptoService : INService
|
||||
await _getCryptoLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var fullStrData = await _cache.GetOrAddCachedDataAsync("nadeko:crypto_data",
|
||||
async _ =>
|
||||
var data = await _cache.GetOrAddAsync(new("nadeko:crypto_data"),
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var strData = await http.GetFromJsonAsync<CryptoResponse>(
|
||||
var data = await http.GetFromJsonAsync<CryptoResponse>(
|
||||
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?"
|
||||
+ $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}"
|
||||
+ "&start=1"
|
||||
+ "&limit=5000"
|
||||
+ "&convert=USD");
|
||||
|
||||
return JsonSerializer.Serialize(strData);
|
||||
return data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -151,13 +152,12 @@ public class CryptoService : INService
|
||||
return default;
|
||||
}
|
||||
},
|
||||
"",
|
||||
TimeSpan.FromHours(2));
|
||||
|
||||
if (fullStrData is null)
|
||||
if (data is null)
|
||||
return default;
|
||||
|
||||
return JsonSerializer.Deserialize<CryptoResponse>(fullStrData)?.Data ?? new();
|
||||
|
||||
return data.Data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -170,44 +170,33 @@ public class CryptoService : INService
|
||||
}
|
||||
}
|
||||
|
||||
private TypedKey<byte[]> GetSparklineKey(int id)
|
||||
=> new($"crypto:sparkline:{id}");
|
||||
|
||||
public async Task<Stream?> GetSparklineAsync(int id, bool up)
|
||||
{
|
||||
var key = $"crypto:sparkline:{id}";
|
||||
|
||||
// attempt to get from cache
|
||||
var db = _cache.Redis.GetDatabase();
|
||||
byte[] bytes = await db.StringGetAsync(key);
|
||||
// if it succeeds, return it
|
||||
if (bytes is { Length: > 0 })
|
||||
{
|
||||
return bytes.ToStream();
|
||||
}
|
||||
|
||||
// if it fails, generate a new one
|
||||
var points = await DownloadSparklinePointsAsync(id);
|
||||
if (points is null)
|
||||
return default;
|
||||
|
||||
var sparkline = GenerateSparklineChart(points, up);
|
||||
|
||||
// add to cache for 1h and return it
|
||||
|
||||
var stream = sparkline.ToStream();
|
||||
await db.StringSetAsync(key, stream.ToArray(), expiry: TimeSpan.FromHours(1));
|
||||
return stream;
|
||||
}
|
||||
|
||||
private async Task<PointF[]?> DownloadSparklinePointsAsync(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var str = await http.GetStringAsync(
|
||||
$"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg");
|
||||
var points = GetSparklinePointsFromSvgText(str);
|
||||
return points;
|
||||
var bytes = await _cache.GetOrAddAsync(GetSparklineKey(id),
|
||||
async () =>
|
||||
{
|
||||
// if it fails, generate a new one
|
||||
var points = await DownloadSparklinePointsAsync(id);
|
||||
var sparkline = GenerateSparklineChart(points, up);
|
||||
|
||||
using var stream = await sparkline.ToStreamAsync();
|
||||
return stream.ToArray();
|
||||
},
|
||||
TimeSpan.FromHours(1));
|
||||
|
||||
if (bytes is { Length: > 0 })
|
||||
{
|
||||
return bytes.ToStream();
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
catch(Exception ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Exception occurred while downloading sparkline points: {ErrorMessage}",
|
||||
@@ -215,4 +204,13 @@ public class CryptoService : INService
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PointF[]> DownloadSparklinePointsAsync(int id)
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var str = await http.GetStringAsync(
|
||||
$"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg");
|
||||
var points = GetSparklinePointsFromSvgText(str);
|
||||
return points;
|
||||
}
|
||||
}
|
@@ -9,15 +9,9 @@ public partial class Searches
|
||||
[Group]
|
||||
public partial class PokemonSearchCommands : NadekoModule<SearchesService>
|
||||
{
|
||||
public IReadOnlyDictionary<string, SearchPokemon> Pokemons
|
||||
=> _cache.LocalData.Pokemons;
|
||||
private readonly ILocalDataCache _cache;
|
||||
|
||||
public IReadOnlyDictionary<string, SearchPokemonAbility> PokemonAbilities
|
||||
=> _cache.LocalData.PokemonAbilities;
|
||||
|
||||
private readonly IDataCache _cache;
|
||||
|
||||
public PokemonSearchCommands(IDataCache cache)
|
||||
public PokemonSearchCommands(ILocalDataCache cache)
|
||||
=> _cache = cache;
|
||||
|
||||
[Cmd]
|
||||
@@ -27,7 +21,7 @@ public partial class Searches
|
||||
if (string.IsNullOrWhiteSpace(pokemon))
|
||||
return;
|
||||
|
||||
foreach (var kvp in Pokemons)
|
||||
foreach (var kvp in await _cache.GetPokemonsAsync())
|
||||
{
|
||||
if (kvp.Key.ToUpperInvariant() == pokemon.ToUpperInvariant())
|
||||
{
|
||||
@@ -58,7 +52,7 @@ public partial class Searches
|
||||
ability = ability?.Trim().ToUpperInvariant().Replace(" ", "", StringComparison.InvariantCulture);
|
||||
if (string.IsNullOrWhiteSpace(ability))
|
||||
return;
|
||||
foreach (var kvp in PokemonAbilities)
|
||||
foreach (var kvp in await _cache.GetPokemonAbilitiesAsync())
|
||||
{
|
||||
if (kvp.Key.ToUpperInvariant() == ability)
|
||||
{
|
||||
|
@@ -10,14 +10,14 @@ public partial class Searches
|
||||
public partial class SearchCommands : NadekoModule
|
||||
{
|
||||
private readonly ISearchServiceFactory _searchFactory;
|
||||
private readonly ConnectionMultiplexer _redis;
|
||||
private readonly IBotCache _cache;
|
||||
|
||||
public SearchCommands(
|
||||
ISearchServiceFactory searchFactory,
|
||||
ConnectionMultiplexer redis)
|
||||
IBotCache cache)
|
||||
{
|
||||
_searchFactory = searchFactory;
|
||||
_redis = redis;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
@@ -127,18 +127,17 @@ public partial class Searches
|
||||
await ctx.Channel.EmbedAsync(null, embeds: embeds);
|
||||
}
|
||||
|
||||
private TypedKey<string> GetYtCacheKey(string query)
|
||||
=> new($"search:youtube:{query}");
|
||||
|
||||
private async Task AddYoutubeUrlToCacheAsync(string query, string url)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
await db.StringSetAsync($"search:youtube:{query}", url, expiry: 1.Hours());
|
||||
}
|
||||
=> await _cache.AddAsync(GetYtCacheKey(query), url, expiry: 1.Hours());
|
||||
|
||||
private async Task<VideoInfo?> GetYoutubeUrlFromCacheAsync(string query)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var url = await db.StringGetAsync($"search:youtube:{query}");
|
||||
var result = await _cache.GetAsync(GetYtCacheKey(query));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
if (!result.TryGetValue(out var url) || string.IsNullOrWhiteSpace(url))
|
||||
return null;
|
||||
|
||||
return new VideoInfo()
|
||||
|
@@ -1,6 +1,4 @@
|
||||
#nullable disable
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
using Html2Markdown;
|
||||
using NadekoBot.Modules.Searches.Common;
|
||||
using Newtonsoft.Json;
|
||||
@@ -10,7 +8,6 @@ using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using System.Net;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
@@ -31,9 +28,9 @@ public class SearchesService : INService
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly IGoogleApiService _google;
|
||||
private readonly IImageCache _imgs;
|
||||
private readonly IDataCache _cache;
|
||||
private readonly IBotCache _c;
|
||||
private readonly FontProvider _fonts;
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly IBotCredsProvider _creds;
|
||||
private readonly NadekoRandom _rng;
|
||||
private readonly List<string> _yomamaJokes;
|
||||
|
||||
@@ -42,15 +39,16 @@ public class SearchesService : INService
|
||||
|
||||
public SearchesService(
|
||||
IGoogleApiService google,
|
||||
IDataCache cache,
|
||||
IImageCache images,
|
||||
IBotCache c,
|
||||
IHttpClientFactory factory,
|
||||
FontProvider fonts,
|
||||
IBotCredentials creds)
|
||||
IBotCredsProvider creds)
|
||||
{
|
||||
_httpFactory = factory;
|
||||
_google = google;
|
||||
_imgs = cache.LocalImages;
|
||||
_cache = cache;
|
||||
_imgs = images;
|
||||
_c = c;
|
||||
_fonts = fonts;
|
||||
_creds = creds;
|
||||
_rng = new();
|
||||
@@ -76,36 +74,28 @@ public class SearchesService : INService
|
||||
}
|
||||
|
||||
public async Task<Stream> GetRipPictureAsync(string text, Uri imgUrl)
|
||||
{
|
||||
var data = await _cache.GetOrAddCachedDataAsync($"nadeko_rip_{text}_{imgUrl}",
|
||||
GetRipPictureFactory,
|
||||
(text, imgUrl),
|
||||
TimeSpan.FromDays(1));
|
||||
|
||||
return data.ToStream();
|
||||
}
|
||||
=> (await GetRipPictureFactory(text, imgUrl)).ToStream();
|
||||
|
||||
private void DrawAvatar(Image bg, Image avatarImage)
|
||||
=> bg.Mutate(x => x.Grayscale().DrawImage(avatarImage, new(83, 139), new GraphicsOptions()));
|
||||
|
||||
public async Task<byte[]> GetRipPictureFactory((string text, Uri avatarUrl) arg)
|
||||
public async Task<byte[]> GetRipPictureFactory(string text, Uri avatarUrl)
|
||||
{
|
||||
var (text, avatarUrl) = arg;
|
||||
using var bg = Image.Load<Rgba32>(_imgs.Rip.ToArray());
|
||||
var (succ, data) = (false, (byte[])null); //await _cache.TryGetImageDataAsync(avatarUrl);
|
||||
if (!succ)
|
||||
using var bg = Image.Load<Rgba32>(await _imgs.GetRipBgAsync());
|
||||
var result = await _c.GetImageDataAsync(avatarUrl);
|
||||
if (!result.TryPickT0(out var data, out _))
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
data = await http.GetByteArrayAsync(avatarUrl);
|
||||
using (var avatarImg = Image.Load<Rgba32>(data))
|
||||
{
|
||||
avatarImg.Mutate(x => x.Resize(85, 85).ApplyRoundedCorners(42));
|
||||
await using var avStream = avatarImg.ToStream();
|
||||
await using var avStream = await avatarImg.ToStreamAsync();
|
||||
data = avStream.ToArray();
|
||||
DrawAvatar(bg, avatarImg);
|
||||
}
|
||||
|
||||
await _cache.SetImageDataAsync(avatarUrl, data);
|
||||
await _c.SetImageDataAsync(avatarUrl, data);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -128,7 +118,7 @@ public class SearchesService : INService
|
||||
new(25, 225)));
|
||||
|
||||
//flowa
|
||||
using (var flowers = Image.Load(_imgs.RipOverlay.ToArray()))
|
||||
using (var flowers = Image.Load(await _imgs.GetRipOverlayAsync()))
|
||||
{
|
||||
bg.Mutate(x => x.DrawImage(flowers, new(0, 0), new GraphicsOptions()));
|
||||
}
|
||||
@@ -137,13 +127,12 @@ public class SearchesService : INService
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public Task<WeatherData> GetWeatherDataAsync(string query)
|
||||
public async Task<WeatherData> GetWeatherDataAsync(string query)
|
||||
{
|
||||
query = query.Trim().ToLowerInvariant();
|
||||
|
||||
return _cache.GetOrAddCachedDataAsync($"nadeko_weather_{query}",
|
||||
GetWeatherDataFactory,
|
||||
query,
|
||||
return await _c.GetOrAddAsync(new($"nadeko_weather_{query}"),
|
||||
async () => await GetWeatherDataFactory(query),
|
||||
TimeSpan.FromHours(3));
|
||||
}
|
||||
|
||||
@@ -184,26 +173,28 @@ public class SearchesService : INService
|
||||
if (string.IsNullOrEmpty(query))
|
||||
return (default, TimeErrors.InvalidInput);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_creds.LocationIqApiKey) || string.IsNullOrWhiteSpace(_creds.TimezoneDbApiKey))
|
||||
|
||||
var locIqKey = _creds.GetCreds().LocationIqApiKey;
|
||||
var tzDbKey = _creds.GetCreds().TimezoneDbApiKey;
|
||||
if (string.IsNullOrWhiteSpace(locIqKey) || string.IsNullOrWhiteSpace(tzDbKey))
|
||||
return (default, TimeErrors.ApiKeyMissing);
|
||||
|
||||
try
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var res = await _cache.GetOrAddCachedDataAsync($"geo_{query}",
|
||||
_ =>
|
||||
var res = await _c.GetOrAddAsync(new($"searches:geo:{query}"),
|
||||
async () =>
|
||||
{
|
||||
var url = "https://eu1.locationiq.com/v1/search.php?"
|
||||
+ (string.IsNullOrWhiteSpace(_creds.LocationIqApiKey)
|
||||
+ (string.IsNullOrWhiteSpace(locIqKey)
|
||||
? "key="
|
||||
: $"key={_creds.LocationIqApiKey}&")
|
||||
: $"key={locIqKey}&")
|
||||
+ $"q={Uri.EscapeDataString(query)}&"
|
||||
+ "format=json";
|
||||
|
||||
var res = http.GetStringAsync(url);
|
||||
var res = await http.GetStringAsync(url);
|
||||
return res;
|
||||
},
|
||||
"",
|
||||
TimeSpan.FromHours(1));
|
||||
|
||||
var responses = JsonConvert.DeserializeObject<LocationIqResponse[]>(res);
|
||||
@@ -217,7 +208,7 @@ public class SearchesService : INService
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get,
|
||||
"http://api.timezonedb.com/v2.1/get-time-zone?"
|
||||
+ $"key={_creds.TimezoneDbApiKey}"
|
||||
+ $"key={tzDbKey}"
|
||||
+ $"&format=json"
|
||||
+ $"&by=position"
|
||||
+ $"&lat={geoData.Lat}"
|
||||
@@ -315,9 +306,8 @@ public class SearchesService : INService
|
||||
public async Task<MtgData> GetMtgCardAsync(string search)
|
||||
{
|
||||
search = search.Trim().ToLowerInvariant();
|
||||
var data = await _cache.GetOrAddCachedDataAsync($"nadeko_mtg_{search}",
|
||||
GetMtgCardFactory,
|
||||
search,
|
||||
var data = await _c.GetOrAddAsync(new($"mtg:{search}"),
|
||||
async () => await GetMtgCardFactory(search),
|
||||
TimeSpan.FromDays(1));
|
||||
|
||||
if (data is null || data.Length == 0)
|
||||
@@ -368,12 +358,11 @@ public class SearchesService : INService
|
||||
return await cards.Select(GetMtgDataAsync).WhenAll();
|
||||
}
|
||||
|
||||
public Task<HearthstoneCardData> GetHearthstoneCardDataAsync(string name)
|
||||
public async Task<HearthstoneCardData> GetHearthstoneCardDataAsync(string name)
|
||||
{
|
||||
name = name.ToLowerInvariant();
|
||||
return _cache.GetOrAddCachedDataAsync($"nadeko_hearthstone_{name}",
|
||||
HearthstoneCardDataFactory,
|
||||
name,
|
||||
return await _c.GetOrAddAsync($"hearthstone:{name}",
|
||||
() => HearthstoneCardDataFactory(name),
|
||||
TimeSpan.FromDays(1));
|
||||
}
|
||||
|
||||
@@ -381,7 +370,7 @@ public class SearchesService : INService
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
http.DefaultRequestHeaders.Clear();
|
||||
http.DefaultRequestHeaders.Add("x-rapidapi-key", _creds.RapidApiKey);
|
||||
http.DefaultRequestHeaders.Add("x-rapidapi-key", _creds.GetCreds().RapidApiKey);
|
||||
try
|
||||
{
|
||||
var response = await http.GetStringAsync("https://omgvamp-hearthstone-v1.p.rapidapi.com/"
|
||||
@@ -410,16 +399,22 @@ public class SearchesService : INService
|
||||
}
|
||||
}
|
||||
|
||||
public Task<OmdbMovie> GetMovieDataAsync(string name)
|
||||
public async Task<OmdbMovie> GetMovieDataAsync(string name)
|
||||
{
|
||||
name = name.Trim().ToLowerInvariant();
|
||||
return _cache.GetOrAddCachedDataAsync($"nadeko_movie_{name}", GetMovieDataFactory, name, TimeSpan.FromDays(1));
|
||||
return await _c.GetOrAddAsync(new($"movie:{name}"),
|
||||
() => GetMovieDataFactory(name),
|
||||
TimeSpan.FromDays(1));
|
||||
}
|
||||
|
||||
private async Task<OmdbMovie> GetMovieDataFactory(string name)
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var res = await http.GetStringAsync(string.Format("https://omdbapi.nadeko.bot/?t={0}&y=&plot=full&r=json",
|
||||
var res = await http.GetStringAsync(string.Format("https://omdbapi.nadeko.bot/"
|
||||
+ "?t={0}"
|
||||
+ "&y="
|
||||
+ "&plot=full"
|
||||
+ "&r=json",
|
||||
name.Trim().Replace(' ', '+')));
|
||||
var movie = JsonConvert.DeserializeObject<OmdbMovie>(res);
|
||||
if (movie?.Title is null)
|
||||
@@ -432,10 +427,11 @@ public class SearchesService : INService
|
||||
{
|
||||
const string steamGameIdsKey = "steam_names_to_appid";
|
||||
|
||||
var gamesMap = await _cache.GetOrAddCachedDataAsync(steamGameIdsKey,
|
||||
async _ =>
|
||||
var gamesMap = await _c.GetOrAddAsync(new(steamGameIdsKey),
|
||||
async () =>
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
|
||||
// https://api.steampowered.com/ISteamApps/GetAppList/v2/
|
||||
var gamesStr = await http.GetStringAsync("https://api.steampowered.com/ISteamApps/GetAppList/v2/");
|
||||
var apps = JsonConvert
|
||||
@@ -446,23 +442,18 @@ public class SearchesService : INService
|
||||
{
|
||||
apps = new List<SteamGameId>()
|
||||
}
|
||||
})
|
||||
})!
|
||||
.applist.apps;
|
||||
|
||||
return apps.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.GroupBy(x => x.Name)
|
||||
.ToDictionary(x => x.Key, x => x.First().AppId);
|
||||
//await db.HashSetAsync("steam_game_ids", apps.Select(app => new HashEntry(app.Name.Trim().ToLowerInvariant(), app.AppId)).ToArray());
|
||||
//await db.StringSetAsync("steam_game_ids", gamesStr, TimeSpan.FromHours(24));
|
||||
//await db.KeyExpireAsync("steam_game_ids", TimeSpan.FromHours(24), CommandFlags.FireAndForget);
|
||||
},
|
||||
default(string),
|
||||
TimeSpan.FromHours(24));
|
||||
|
||||
if (gamesMap is null)
|
||||
return -1;
|
||||
|
||||
|
||||
|
||||
query = query.Trim();
|
||||
|
||||
var keyList = gamesMap.Keys.ToList();
|
||||
|
@@ -1,4 +1,6 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db;
|
||||
@@ -6,10 +8,98 @@ using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Searches.Common;
|
||||
using NadekoBot.Modules.Searches.Common.StreamNotifications;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace NadekoBot.Modules.Searches.Services;
|
||||
|
||||
public sealed class StreamOnlineMessageDeleterService : INService, IReadyExecutor
|
||||
{
|
||||
private readonly StreamNotificationService _notifService;
|
||||
private readonly DbService _db;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IPubSub _pubSub;
|
||||
|
||||
public StreamOnlineMessageDeleterService(
|
||||
StreamNotificationService notifService,
|
||||
DbService db,
|
||||
IPubSub pubSub,
|
||||
DiscordSocketClient client)
|
||||
{
|
||||
_notifService = notifService;
|
||||
_db = db;
|
||||
_client = client;
|
||||
_pubSub = pubSub;
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
_notifService.OnlineMessagesSent += OnOnlineMessagesSent;
|
||||
|
||||
if(_client.ShardId == 0)
|
||||
await _pubSub.Sub(_notifService.StreamsOfflineKey, OnStreamsOffline);
|
||||
}
|
||||
|
||||
private async Task OnOnlineMessagesSent(FollowedStream.FType type, string name, IReadOnlyCollection<(ulong, ulong)> pairs)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
foreach (var (channelId, messageId) in pairs)
|
||||
{
|
||||
await ctx.GetTable<StreamOnlineMessage>()
|
||||
.InsertAsync(() => new()
|
||||
{
|
||||
Name = name,
|
||||
Type = type,
|
||||
MessageId = messageId,
|
||||
ChannelId = channelId,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask OnStreamsOffline(List<StreamData> streamDatas)
|
||||
{
|
||||
if (_client.ShardId != 0)
|
||||
return;
|
||||
|
||||
var pairs = await GetMessagesToDelete(streamDatas);
|
||||
|
||||
foreach (var (channelId, messageId) in pairs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var textChannel = await _client.GetChannelAsync(channelId) as ITextChannel;
|
||||
if (textChannel is null)
|
||||
continue;
|
||||
|
||||
await textChannel.DeleteMessageAsync(messageId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<(ulong, ulong)>> GetMessagesToDelete(List<StreamData> streamDatas)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var toReturn = new List<(ulong, ulong)>();
|
||||
foreach (var sd in streamDatas)
|
||||
{
|
||||
var key = sd.CreateKey();
|
||||
var toDelete = await ctx.GetTable<StreamOnlineMessage>()
|
||||
.Where(x => (x.Type == key.Type && x.Name == key.Name)
|
||||
|| Sql.DateDiff(Sql.DateParts.Day, x.DateAdded, DateTime.UtcNow) > 1)
|
||||
.DeleteWithOutputAsync();
|
||||
|
||||
toReturn.AddRange(toDelete.Select(x => (x.ChannelId, x.MessageId)));
|
||||
}
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public sealed class StreamNotificationService : INService, IReadyExecutor
|
||||
{
|
||||
private readonly DbService _db;
|
||||
@@ -29,18 +119,22 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
|
||||
private readonly IPubSub _pubSub;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
|
||||
private readonly TypedKey<List<StreamData>> _streamsOnlineKey;
|
||||
private readonly TypedKey<List<StreamData>> _streamsOfflineKey;
|
||||
public TypedKey<List<StreamData>> StreamsOnlineKey { get; }
|
||||
public TypedKey<List<StreamData>> StreamsOfflineKey { get; }
|
||||
|
||||
private readonly TypedKey<FollowStreamPubData> _streamFollowKey;
|
||||
private readonly TypedKey<FollowStreamPubData> _streamUnfollowKey;
|
||||
private readonly ConnectionMultiplexer _redis;
|
||||
|
||||
public event Func<
|
||||
FollowedStream.FType,
|
||||
string,
|
||||
IReadOnlyCollection<(ulong, ulong)>,
|
||||
Task> OnlineMessagesSent = static delegate { return Task.CompletedTask; };
|
||||
|
||||
public StreamNotificationService(
|
||||
DbService db,
|
||||
DiscordSocketClient client,
|
||||
IBotStrings strings,
|
||||
ConnectionMultiplexer redis,
|
||||
IBotCredsProvider creds,
|
||||
IHttpClientFactory httpFactory,
|
||||
Bot bot,
|
||||
@@ -52,11 +146,11 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
|
||||
_strings = strings;
|
||||
_pubSub = pubSub;
|
||||
_eb = eb;
|
||||
_redis = redis;
|
||||
_streamTracker = new(httpFactory, creds, redis, creds.GetCreds().RedisKey(), client.ShardId == 0);
|
||||
|
||||
_streamTracker = new(httpFactory, creds);
|
||||
|
||||
_streamsOnlineKey = new("streams.online");
|
||||
_streamsOfflineKey = new("streams.offline");
|
||||
StreamsOnlineKey = new("streams.online");
|
||||
StreamsOfflineKey = new("streams.offline");
|
||||
|
||||
_streamFollowKey = new("stream.follow");
|
||||
_streamUnfollowKey = new("stream.unfollow");
|
||||
@@ -100,7 +194,7 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
|
||||
var allFollowedStreams = uow.Set<FollowedStream>().AsQueryable().ToList();
|
||||
|
||||
foreach (var fs in allFollowedStreams)
|
||||
_streamTracker.CacheAddData(fs.CreateKey(), null, false);
|
||||
_streamTracker.AddLastData(fs.CreateKey(), null, false);
|
||||
|
||||
_trackCounter = allFollowedStreams.GroupBy(x => new
|
||||
{
|
||||
@@ -112,8 +206,8 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
|
||||
}
|
||||
}
|
||||
|
||||
_pubSub.Sub(_streamsOfflineKey, HandleStreamsOffline);
|
||||
_pubSub.Sub(_streamsOnlineKey, HandleStreamsOnline);
|
||||
_pubSub.Sub(StreamsOfflineKey, HandleStreamsOffline);
|
||||
_pubSub.Sub(StreamsOnlineKey, HandleStreamsOnline);
|
||||
|
||||
if (client.ShardId == 0)
|
||||
{
|
||||
@@ -186,7 +280,7 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
|
||||
/// </summary>
|
||||
private ValueTask HandleFollowStream(FollowStreamPubData info)
|
||||
{
|
||||
_streamTracker.CacheAddData(info.Key, null, false);
|
||||
_streamTracker.AddLastData(info.Key, null, false);
|
||||
lock (_shardLock)
|
||||
{
|
||||
var key = info.Key;
|
||||
@@ -251,45 +345,8 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
|
||||
.WhenAll();
|
||||
}
|
||||
}
|
||||
|
||||
if (_client.ShardId == 0)
|
||||
{
|
||||
foreach (var stream in offlineStreams)
|
||||
{
|
||||
await DeleteOnlineMessages(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteOnlineMessages(StreamData stream)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var data = await db.ListRangeAsync($"streams_online_del:{stream.CreateKey()}");
|
||||
await db.KeyDeleteAsync($"streams_online_del:{stream.CreateKey()}");
|
||||
|
||||
foreach (string pair in data)
|
||||
{
|
||||
var pairArr = pair.Split(',');
|
||||
if (pairArr.Length != 2)
|
||||
continue;
|
||||
|
||||
if (!ulong.TryParse(pairArr[0], out var chId) || !ulong.TryParse(pairArr[1], out var msgId))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var textChannel = await _client.GetChannelAsync(chId) as ITextChannel;
|
||||
if (textChannel is null)
|
||||
continue;
|
||||
|
||||
await textChannel.DeleteMessageAsync(msgId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async ValueTask HandleStreamsOnline(List<StreamData> onlineStreams)
|
||||
{
|
||||
@@ -331,14 +388,11 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
|
||||
{
|
||||
var pairs = messages
|
||||
.Where(x => x != default)
|
||||
.Select(x => (RedisValue)$"{x.Item1},{x.Item2}")
|
||||
.ToArray();
|
||||
.Select(x => (x.Item1, x.Item2))
|
||||
.ToList();
|
||||
|
||||
if (pairs.Length > 0)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
await db.ListRightPushAsync($"streams_online_del:{key}", pairs);
|
||||
}
|
||||
if (pairs.Count > 0)
|
||||
await OnlineMessagesSent(key.Type, key.Name, pairs);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -349,10 +403,10 @@ public sealed class StreamNotificationService : INService, IReadyExecutor
|
||||
}
|
||||
|
||||
private Task OnStreamsOnline(List<StreamData> data)
|
||||
=> _pubSub.Pub(_streamsOnlineKey, data);
|
||||
=> _pubSub.Pub(StreamsOnlineKey, data);
|
||||
|
||||
private Task OnStreamsOffline(List<StreamData> data)
|
||||
=> _pubSub.Pub(_streamsOfflineKey, data);
|
||||
=> _pubSub.Pub(StreamsOfflineKey, data);
|
||||
|
||||
private Task ClientOnJoinedGuild(GuildConfig guildConfig)
|
||||
{
|
||||
|
@@ -6,10 +6,9 @@ namespace NadekoBot.Modules.Searches.Common;
|
||||
|
||||
public readonly struct StreamDataKey
|
||||
{
|
||||
public FollowedStream.FType Type { get; }
|
||||
public string Name { get; }
|
||||
public FollowedStream.FType Type { get; init; }
|
||||
public string Name { get; init; }
|
||||
|
||||
[JsonConstructor]
|
||||
public StreamDataKey(FollowedStream.FType type, string name)
|
||||
{
|
||||
Type = type;
|
||||
|
@@ -1,7 +1,5 @@
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
using Newtonsoft.Json;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace NadekoBot.Modules.Searches.Common.StreamNotifications;
|
||||
|
||||
@@ -9,30 +7,22 @@ public class NotifChecker
|
||||
{
|
||||
public event Func<List<StreamData>, Task> OnStreamsOffline = _ => Task.CompletedTask;
|
||||
public event Func<List<StreamData>, Task> OnStreamsOnline = _ => Task.CompletedTask;
|
||||
private readonly ConnectionMultiplexer _multi;
|
||||
private readonly string _key;
|
||||
|
||||
private readonly Dictionary<FollowedStream.FType, Provider> _streamProviders;
|
||||
private readonly IReadOnlyDictionary<FollowedStream.FType, Provider> _streamProviders;
|
||||
private readonly HashSet<(FollowedStream.FType, string)> _offlineBuffer;
|
||||
private readonly ConcurrentDictionary<StreamDataKey, StreamData?> _cache = new();
|
||||
|
||||
public NotifChecker(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IBotCredsProvider credsProvider,
|
||||
ConnectionMultiplexer multi,
|
||||
string uniqueCacheKey,
|
||||
bool isMaster)
|
||||
IBotCredsProvider credsProvider)
|
||||
{
|
||||
_multi = multi;
|
||||
_key = $"{uniqueCacheKey}_followed_streams_data";
|
||||
_streamProviders = new()
|
||||
_streamProviders = new Dictionary<FollowedStream.FType, Provider>()
|
||||
{
|
||||
{ FollowedStream.FType.Twitch, new TwitchHelixProvider(httpClientFactory, credsProvider) },
|
||||
{ FollowedStream.FType.Picarto, new PicartoProvider(httpClientFactory) },
|
||||
{ FollowedStream.FType.Trovo, new TrovoProvider(httpClientFactory, credsProvider) }
|
||||
};
|
||||
_offlineBuffer = new();
|
||||
if (isMaster)
|
||||
CacheClearAllData();
|
||||
}
|
||||
|
||||
// gets all streams which have been failing for more than the provided timespan
|
||||
@@ -61,7 +51,7 @@ public class NotifChecker
|
||||
{
|
||||
try
|
||||
{
|
||||
var allStreamData = CacheGetAllData();
|
||||
var allStreamData = GetAllData();
|
||||
|
||||
var oldStreamDataDict = allStreamData
|
||||
// group by type
|
||||
@@ -101,7 +91,7 @@ public class NotifChecker
|
||||
|| !typeDict.TryGetValue(key.Name, out var oldData)
|
||||
|| oldData is null)
|
||||
{
|
||||
CacheAddData(key, newData, true);
|
||||
AddLastData(key, newData, true);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -109,7 +99,7 @@ public class NotifChecker
|
||||
if (string.IsNullOrWhiteSpace(newData.Game))
|
||||
newData.Game = oldData.Game;
|
||||
|
||||
CacheAddData(key, newData, true);
|
||||
AddLastData(key, newData, true);
|
||||
|
||||
// if the stream is offline, we need to check if it was
|
||||
// marked as offline once previously
|
||||
@@ -158,39 +148,22 @@ public class NotifChecker
|
||||
}
|
||||
});
|
||||
|
||||
public bool CacheAddData(StreamDataKey key, StreamData? data, bool replace)
|
||||
public bool AddLastData(StreamDataKey key, StreamData? data, bool replace)
|
||||
{
|
||||
var db = _multi.GetDatabase();
|
||||
return db.HashSet(_key,
|
||||
JsonConvert.SerializeObject(key),
|
||||
JsonConvert.SerializeObject(data),
|
||||
replace ? When.Always : When.NotExists);
|
||||
if (replace)
|
||||
{
|
||||
_cache[key] = data;
|
||||
return true;
|
||||
}
|
||||
|
||||
return _cache.TryAdd(key, data);
|
||||
}
|
||||
|
||||
public void CacheDeleteData(StreamDataKey key)
|
||||
{
|
||||
var db = _multi.GetDatabase();
|
||||
db.HashDelete(_key, JsonConvert.SerializeObject(key));
|
||||
}
|
||||
public void DeleteLastData(StreamDataKey key)
|
||||
=> _cache.TryRemove(key, out _);
|
||||
|
||||
public void CacheClearAllData()
|
||||
{
|
||||
var db = _multi.GetDatabase();
|
||||
db.KeyDelete(_key);
|
||||
}
|
||||
|
||||
public Dictionary<StreamDataKey, StreamData?> CacheGetAllData()
|
||||
{
|
||||
var db = _multi.GetDatabase();
|
||||
if (!db.KeyExists(_key))
|
||||
return new();
|
||||
|
||||
return db.HashGetAll(_key)
|
||||
.ToDictionary(entry => JsonConvert.DeserializeObject<StreamDataKey>(entry.Name),
|
||||
entry => entry.Value.IsNullOrEmpty
|
||||
? default
|
||||
: JsonConvert.DeserializeObject<StreamData>(entry.Value));
|
||||
}
|
||||
public Dictionary<StreamDataKey, StreamData?> GetAllData()
|
||||
=> _cache.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
public async Task<StreamData?> GetStreamDataByUrlAsync(string url)
|
||||
{
|
||||
@@ -234,9 +207,9 @@ public class NotifChecker
|
||||
|
||||
// if stream is found, add it to the cache for tracking only if it doesn't already exist
|
||||
// because stream will be checked and events will fire in a loop. We don't want to override old state
|
||||
return CacheAddData(data.CreateKey(), data, false);
|
||||
return AddLastData(data.CreateKey(), data, false);
|
||||
}
|
||||
|
||||
public void UntrackStreamByKey(in StreamDataKey key)
|
||||
=> CacheDeleteData(key);
|
||||
=> DeleteLastData(key);
|
||||
}
|
@@ -31,9 +31,10 @@ public sealed class PatronageService
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly ISubscriptionHandler _subsHandler;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
private readonly ConnectionMultiplexer _redis;
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly TypedKey<bool> _quotaKey;
|
||||
private static readonly TypedKey<long> _quotaKey
|
||||
= new($"quota:last_hourly_reset");
|
||||
|
||||
private readonly IBotCache _cache;
|
||||
|
||||
public PatronageService(
|
||||
PatronageConfig pConf,
|
||||
@@ -41,18 +42,14 @@ public sealed class PatronageService
|
||||
DiscordSocketClient client,
|
||||
ISubscriptionHandler subsHandler,
|
||||
IEmbedBuilderService eb,
|
||||
ConnectionMultiplexer redis,
|
||||
IBotCredentials creds)
|
||||
IBotCache cache)
|
||||
{
|
||||
_pConf = pConf;
|
||||
_db = db;
|
||||
_client = client;
|
||||
_subsHandler = subsHandler;
|
||||
_eb = eb;
|
||||
_redis = redis;
|
||||
_creds = creds;
|
||||
|
||||
_quotaKey = new TypedKey<bool>($"{_creds.RedisKey()}:quota:last_hourly_reset");
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public Task OnReadyAsync()
|
||||
@@ -101,11 +98,10 @@ public sealed class PatronageService
|
||||
var now = DateTime.UtcNow;
|
||||
var lastRun = DateTime.MinValue;
|
||||
|
||||
var rdb = _redis.GetDatabase();
|
||||
var lastVal = await rdb.StringGetAsync(_quotaKey.Key);
|
||||
if (lastVal != default)
|
||||
var result = await _cache.GetAsync(_quotaKey);
|
||||
if (result.TryGetValue(out var lastVal) && lastVal != default)
|
||||
{
|
||||
lastRun = DateTime.FromBinary((long)lastVal);
|
||||
lastRun = DateTime.FromBinary(lastVal);
|
||||
}
|
||||
|
||||
var nowDate = now.ToDateOnly();
|
||||
@@ -130,8 +126,6 @@ public sealed class PatronageService
|
||||
HourlyCount = 0,
|
||||
DailyCount = 0,
|
||||
});
|
||||
|
||||
await rdb.StringSetAsync(_quotaKey.Key, true);
|
||||
}
|
||||
else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas
|
||||
{
|
||||
@@ -143,7 +137,7 @@ public sealed class PatronageService
|
||||
}
|
||||
|
||||
// assumes that the code above runs in less than an hour
|
||||
await rdb.StringSetAsync(_quotaKey.Key, now.ToBinary());
|
||||
await _cache.AddAsync(_quotaKey, now.ToBinary());
|
||||
await tran.CommitAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
@@ -1,23 +1,24 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Modules.Utility.Common;
|
||||
using Newtonsoft.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Utility.Services;
|
||||
|
||||
public class ConverterService : INService, IReadyExecutor
|
||||
{
|
||||
public ConvertUnit[] Units
|
||||
=> _cache.Redis.GetDatabase().StringGet("converter_units").ToString().MapJson<ConvertUnit[]>();
|
||||
private static readonly TypedKey<List<ConvertUnit>> _convertKey =
|
||||
new("convert:units");
|
||||
|
||||
private readonly TimeSpan _updateInterval = new(12, 0, 0);
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IDataCache _cache;
|
||||
private readonly IBotCache _cache;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public ConverterService(
|
||||
DiscordSocketClient client,
|
||||
IDataCache cache,
|
||||
IBotCache cache,
|
||||
IHttpClientFactory factory)
|
||||
{
|
||||
_client = client;
|
||||
@@ -48,7 +49,7 @@ public class ConverterService : INService, IReadyExecutor
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var res = await http.GetStringAsync("https://convertapi.nadeko.bot/latest");
|
||||
return JsonConvert.DeserializeObject<Rates>(res);
|
||||
return JsonSerializer.Deserialize<Rates>(res);
|
||||
}
|
||||
|
||||
private async Task UpdateCurrency()
|
||||
@@ -61,29 +62,38 @@ public class ConverterService : INService, IReadyExecutor
|
||||
Modifier = decimal.One,
|
||||
UnitType = unitTypeString
|
||||
};
|
||||
var range = currencyRates.ConversionRates.Select(u => new ConvertUnit
|
||||
var units = currencyRates.ConversionRates.Select(u => new ConvertUnit
|
||||
{
|
||||
Triggers = new[] { u.Key },
|
||||
Modifier = u.Value,
|
||||
UnitType = unitTypeString
|
||||
})
|
||||
.ToArray();
|
||||
.ToList();
|
||||
|
||||
var fileData = JsonConvert.DeserializeObject<ConvertUnit[]>(File.ReadAllText("data/units.json"))
|
||||
?.Where(x => x.UnitType != "currency");
|
||||
if (fileData is null)
|
||||
return;
|
||||
|
||||
var data = JsonConvert.SerializeObject(range.Append(baseType).Concat(fileData).ToList());
|
||||
_cache.Redis.GetDatabase().StringSet("converter_units", data);
|
||||
var stream = File.OpenRead("data/units.json");
|
||||
var defaultUnits = await JsonSerializer.DeserializeAsync<ConvertUnit[]>(stream);
|
||||
if(defaultUnits is not null)
|
||||
units.AddRange(defaultUnits);
|
||||
|
||||
units.Add(baseType);
|
||||
|
||||
await _cache.AddAsync(_convertKey, units);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ConvertUnit>> GetUnitsAsync()
|
||||
=> (await _cache.GetAsync(_convertKey)).TryGetValue(out var list)
|
||||
? list
|
||||
: Array.Empty<ConvertUnit>();
|
||||
}
|
||||
|
||||
public class Rates
|
||||
{
|
||||
[JsonPropertyName("base")]
|
||||
public string Base { get; set; }
|
||||
|
||||
[JsonPropertyName("date")]
|
||||
public DateTime Date { get; set; }
|
||||
|
||||
[JsonProperty("rates")]
|
||||
[JsonPropertyName("rates")]
|
||||
public Dictionary<string, decimal> ConversionRates { get; set; }
|
||||
}
|
@@ -11,7 +11,7 @@ public partial class Utility
|
||||
[Cmd]
|
||||
public async partial Task ConvertList()
|
||||
{
|
||||
var units = _service.Units;
|
||||
var units = await _service.GetUnitsAsync();
|
||||
|
||||
var embed = _eb.Create().WithTitle(GetText(strs.convertlist)).WithOkColor();
|
||||
|
||||
@@ -29,9 +29,10 @@ public partial class Utility
|
||||
[Priority(0)]
|
||||
public async partial Task Convert(string origin, string target, decimal value)
|
||||
{
|
||||
var originUnit = _service.Units.FirstOrDefault(x
|
||||
var units = await _service.GetUnitsAsync();
|
||||
var originUnit = units.FirstOrDefault(x
|
||||
=> x.Triggers.Select(y => y.ToUpperInvariant()).Contains(origin.ToUpperInvariant()));
|
||||
var targetUnit = _service.Units.FirstOrDefault(x
|
||||
var targetUnit = units.FirstOrDefault(x
|
||||
=> x.Triggers.Select(y => y.ToUpperInvariant()).Contains(target.ToUpperInvariant()));
|
||||
if (originUnit is null || targetUnit is null)
|
||||
{
|
||||
|
@@ -23,10 +23,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
public const int XP_REQUIRED_LVL_1 = 36;
|
||||
|
||||
private readonly DbService _db;
|
||||
private readonly CommandHandler _cmd;
|
||||
private readonly IImageCache _images;
|
||||
private readonly IBotStrings _strings;
|
||||
private readonly IDataCache _cache;
|
||||
private readonly FontProvider _fonts;
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly ICurrencyService _cs;
|
||||
@@ -45,14 +43,15 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
|
||||
private readonly TypedKey<bool> _xpTemplateReloadKey;
|
||||
private readonly IPatronageService _ps;
|
||||
private readonly IBotCache _c;
|
||||
|
||||
public XpService(
|
||||
DiscordSocketClient client,
|
||||
CommandHandler cmd,
|
||||
Bot bot,
|
||||
DbService db,
|
||||
IBotStrings strings,
|
||||
IDataCache cache,
|
||||
IImageCache images,
|
||||
IBotCache c,
|
||||
FontProvider fonts,
|
||||
IBotCredentials creds,
|
||||
ICurrencyService cs,
|
||||
@@ -63,10 +62,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
IPatronageService ps)
|
||||
{
|
||||
_db = db;
|
||||
_cmd = cmd;
|
||||
_images = cache.LocalImages;
|
||||
_images = images;
|
||||
_strings = strings;
|
||||
_cache = cache;
|
||||
_fonts = fonts;
|
||||
_creds = creds;
|
||||
_cs = cs;
|
||||
@@ -79,6 +76,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
_client = client;
|
||||
_xpTemplateReloadKey = new("xp.template.reload");
|
||||
_ps = ps;
|
||||
_c = c;
|
||||
|
||||
InternalReloadXpTemplate();
|
||||
|
||||
@@ -453,10 +451,10 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
|
||||
private Task Client_OnGuildAvailable(SocketGuild guild)
|
||||
{
|
||||
Task.Run(() =>
|
||||
Task.Run(async () =>
|
||||
{
|
||||
foreach (var channel in guild.VoiceChannels)
|
||||
ScanChannelForVoiceXp(channel);
|
||||
await ScanChannelForVoiceXp(channel);
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
@@ -467,33 +465,33 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
if (socketUser is not SocketGuildUser user || user.IsBot)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(() =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (before.VoiceChannel is not null)
|
||||
ScanChannelForVoiceXp(before.VoiceChannel);
|
||||
await ScanChannelForVoiceXp(before.VoiceChannel);
|
||||
|
||||
if (after.VoiceChannel is not null && after.VoiceChannel != before.VoiceChannel)
|
||||
ScanChannelForVoiceXp(after.VoiceChannel);
|
||||
await ScanChannelForVoiceXp(after.VoiceChannel);
|
||||
else if (after.VoiceChannel is null)
|
||||
// In this case, the user left the channel and the previous for loops didn't catch
|
||||
// it because it wasn't in any new channel. So we need to get rid of it.
|
||||
UserLeftVoiceChannel(user, before.VoiceChannel);
|
||||
await UserLeftVoiceChannel(user, before.VoiceChannel);
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ScanChannelForVoiceXp(SocketVoiceChannel channel)
|
||||
private async Task ScanChannelForVoiceXp(SocketVoiceChannel channel)
|
||||
{
|
||||
if (ShouldTrackVoiceChannel(channel))
|
||||
{
|
||||
foreach (var user in channel.Users)
|
||||
ScanUserForVoiceXp(user, channel);
|
||||
await ScanUserForVoiceXp(user, channel);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var user in channel.Users)
|
||||
UserLeftVoiceChannel(user, channel);
|
||||
await UserLeftVoiceChannel(user, channel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,12 +500,12 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="channel"></param>
|
||||
private void ScanUserForVoiceXp(SocketGuildUser user, SocketVoiceChannel channel)
|
||||
private async Task ScanUserForVoiceXp(SocketGuildUser user, SocketVoiceChannel channel)
|
||||
{
|
||||
if (UserParticipatingInVoiceChannel(user) && ShouldTrackXp(user, channel.Id))
|
||||
UserJoinedVoiceChannel(user);
|
||||
await UserJoinedVoiceChannel(user);
|
||||
else
|
||||
UserLeftVoiceChannel(user, channel);
|
||||
await UserLeftVoiceChannel(user, channel);
|
||||
}
|
||||
|
||||
private bool ShouldTrackVoiceChannel(SocketVoiceChannel channel)
|
||||
@@ -516,32 +514,31 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
private bool UserParticipatingInVoiceChannel(SocketGuildUser user)
|
||||
=> !user.IsDeafened && !user.IsMuted && !user.IsSelfDeafened && !user.IsSelfMuted;
|
||||
|
||||
private void UserJoinedVoiceChannel(SocketGuildUser user)
|
||||
private TypedKey<long> GetVoiceXpKey(ulong userId)
|
||||
=> new($"xp:vc_join:{userId}");
|
||||
|
||||
private async Task UserJoinedVoiceChannel(SocketGuildUser user)
|
||||
{
|
||||
var key = $"{_creds.RedisKey()}_user_xp_vc_join_{user.Id}";
|
||||
var value = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
_cache.Redis.GetDatabase()
|
||||
.StringSet(key,
|
||||
value,
|
||||
TimeSpan.FromMinutes(_xpConfig.Data.VoiceMaxMinutes),
|
||||
when: When.NotExists);
|
||||
await _c.AddAsync(GetVoiceXpKey(user.Id),
|
||||
value,
|
||||
TimeSpan.FromMinutes(_xpConfig.Data.VoiceMaxMinutes),
|
||||
overwrite: false);
|
||||
}
|
||||
|
||||
private void UserLeftVoiceChannel(SocketGuildUser user, SocketVoiceChannel channel)
|
||||
private async Task UserLeftVoiceChannel(SocketGuildUser user, SocketVoiceChannel channel)
|
||||
{
|
||||
var key = $"{_creds.RedisKey()}_user_xp_vc_join_{user.Id}";
|
||||
var value = _cache.Redis.GetDatabase().StringGet(key);
|
||||
_cache.Redis.GetDatabase().KeyDelete(key);
|
||||
var key = GetVoiceXpKey(user.Id);
|
||||
var result = await _c.GetAsync(key);
|
||||
if (!await _c.RemoveAsync(key))
|
||||
return;
|
||||
|
||||
// Allow for if this function gets called multiple times when a user leaves a channel.
|
||||
if (value.IsNull)
|
||||
if (!result.TryGetValue(out var unixTime))
|
||||
return;
|
||||
|
||||
if (!value.TryParse(out long startUnixTime))
|
||||
return;
|
||||
|
||||
var dateStart = DateTimeOffset.FromUnixTimeSeconds(startUnixTime);
|
||||
var dateStart = DateTimeOffset.FromUnixTimeSeconds(unixTime);
|
||||
var dateEnd = DateTimeOffset.UtcNow;
|
||||
var minutes = (dateEnd - dateStart).TotalMinutes;
|
||||
var xp = _xpConfig.Data.VoiceXpPerMinute * minutes;
|
||||
@@ -577,7 +574,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
if (arg.Author is not SocketGuildUser user || user.IsBot)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(() =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (!ShouldTrackXp(user, arg.Channel.Id))
|
||||
return;
|
||||
@@ -593,7 +590,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
if (xp <= 0)
|
||||
return;
|
||||
|
||||
if (!SetUserRewarded(user.Id))
|
||||
if (!await SetUserRewardedAsync(user.Id))
|
||||
return;
|
||||
|
||||
_addMessageXp.Enqueue(new()
|
||||
@@ -650,16 +647,14 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
return Enumerable.Empty<ulong>();
|
||||
}
|
||||
|
||||
private bool SetUserRewarded(ulong userId)
|
||||
{
|
||||
var r = _cache.Redis.GetDatabase();
|
||||
var key = $"{_creds.RedisKey()}_user_xp_gain_{userId}";
|
||||
private static TypedKey<bool> GetUserRewKey(ulong userId)
|
||||
=> new($"xp:user_gain:{userId}");
|
||||
|
||||
return r.StringSet(key,
|
||||
private async Task<bool> SetUserRewardedAsync(ulong userId)
|
||||
=> await _c.AddAsync(GetUserRewKey(userId),
|
||||
true,
|
||||
TimeSpan.FromMinutes(_xpConfig.Data.MessageXpCooldown),
|
||||
when: When.NotExists);
|
||||
}
|
||||
expiry: TimeSpan.FromMinutes(_xpConfig.Data.MessageXpCooldown),
|
||||
overwrite: false);
|
||||
|
||||
public async Task<FullUserStats> GetUserStatsAsync(IGuildUser user)
|
||||
{
|
||||
@@ -782,7 +777,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
}
|
||||
}.WithFallbackFonts(_fonts.FallBackFonts);
|
||||
|
||||
using var img = Image.Load<Rgba32>(_images.XpBackground, out var imageFormat);
|
||||
using var img = Image.Load<Rgba32>(await GetXpBackgroundAsync(stats.User.UserId), out var imageFormat);
|
||||
if (template.User.Name.Show)
|
||||
{
|
||||
var fontSize = (int)(template.User.Name.FontSize * 0.9);
|
||||
@@ -979,8 +974,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
{
|
||||
var avatarUrl = stats.User.RealAvatarUrl();
|
||||
|
||||
var (succ, data) = await _cache.TryGetImageDataAsync(avatarUrl);
|
||||
if (!succ)
|
||||
var result = await _c.GetImageDataAsync(avatarUrl);
|
||||
if (!result.TryPickT0(out var data, out _))
|
||||
{
|
||||
using (var http = _httpFactory.CreateClient())
|
||||
{
|
||||
@@ -999,7 +994,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
}
|
||||
}
|
||||
|
||||
await _cache.SetImageDataAsync(avatarUrl, data);
|
||||
await _c.SetImageDataAsync(avatarUrl, data);
|
||||
}
|
||||
|
||||
using var toDraw = Image.Load(data);
|
||||
@@ -1033,7 +1028,13 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
return output;
|
||||
});
|
||||
|
||||
// #if GLOBAL_NADEKO
|
||||
private async Task<byte[]> GetXpBackgroundAsync(ulong userId)
|
||||
{
|
||||
var img = await _images.GetXpBackgroundImageAsync();
|
||||
return img;
|
||||
}
|
||||
|
||||
// #if GLOBAL_NADEKO
|
||||
private async Task DrawFrame(Image<Rgba32> img, ulong userId)
|
||||
{
|
||||
var patron = await _ps.GetPatronAsync(userId);
|
||||
@@ -1103,8 +1104,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
try
|
||||
{
|
||||
var imgUrl = new Uri(stats.User.Club.ImageUrl);
|
||||
var (succ, data) = await _cache.TryGetImageDataAsync(imgUrl);
|
||||
if (!succ)
|
||||
var result = await _c.GetImageDataAsync(imgUrl);
|
||||
if (!result.TryPickT0(out var data, out _))
|
||||
{
|
||||
using (var http = _httpFactory.CreateClient())
|
||||
using (var temp = await http.GetAsync(imgUrl, HttpCompletionOption.ResponseHeadersRead))
|
||||
@@ -1127,7 +1128,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
|
||||
}
|
||||
}
|
||||
|
||||
await _cache.SetImageDataAsync(imgUrl, data);
|
||||
await _c.SetImageDataAsync(imgUrl, data);
|
||||
}
|
||||
|
||||
using var toDraw = Image.Load(data);
|
||||
|
@@ -52,6 +52,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="NonBlocking" Version="2.0.0" />
|
||||
<PackageReference Include="OneOf" Version="3.0.216" />
|
||||
<PackageReference Include="Scrutor" Version="4.1.0" />
|
||||
<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
|
||||
{
|
||||
IReadOnlyDictionary<string, SearchPokemon> Pokemons { get; }
|
||||
IReadOnlyDictionary<string, SearchPokemonAbility> PokemonAbilities { get; }
|
||||
IReadOnlyDictionary<int, string> PokemonMap { get; }
|
||||
TriviaQuestion[] TriviaQuestions { get; }
|
||||
Task<IReadOnlyDictionary<string, SearchPokemon>> GetPokemonsAsync();
|
||||
Task<IReadOnlyDictionary<string, SearchPokemonAbility>> GetPokemonAbilitiesAsync();
|
||||
Task<TriviaQuestionModel[]> GetTriviaQuestionsAsync();
|
||||
Task<PokemonNameId[]> GetPokemonMapAsync();
|
||||
}
|
@@ -172,9 +172,10 @@ public sealed class BotCredsProvider : IBotCredsProvider
|
||||
if (File.Exists(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));
|
||||
}
|
||||
}
|
||||
|
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.ModuleBehaviors;
|
||||
using NadekoBot.Common.Yml;
|
||||
using Newtonsoft.Json;
|
||||
using StackExchange.Redis;
|
||||
using NadekoBot.Common.Configs;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
public interface IImageCache
|
||||
{
|
||||
Task<byte[]?> GetHeadsImageAsync();
|
||||
Task<byte[]?> GetTailsImageAsync();
|
||||
Task<byte[]?> GetCurrencyImageAsync();
|
||||
Task<byte[]?> GetXpBackgroundImageAsync();
|
||||
Task<byte[]?> GetRategirlBgAsync();
|
||||
Task<byte[]?> GetRategirlDotAsync();
|
||||
Task<byte[]?> GetDiceAsync(int num);
|
||||
Task<byte[]?> GetSlotEmojiAsync(int number);
|
||||
Task<byte[]?> GetSlotBgAsync();
|
||||
Task<byte[]?> GetRipBgAsync();
|
||||
Task<byte[]?> GetRipOverlayAsync();
|
||||
}
|
||||
|
||||
public sealed class ImageCache : IImageCache, INService
|
||||
{
|
||||
private readonly IBotCache _cache;
|
||||
private readonly ImagesConfig _ic;
|
||||
private readonly Random _rng;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public ImageCache(
|
||||
IBotCache cache,
|
||||
ImagesConfig ic,
|
||||
IHttpClientFactory httpFactory)
|
||||
{
|
||||
_cache = cache;
|
||||
_ic = ic;
|
||||
_httpFactory = httpFactory;
|
||||
_rng = new NadekoRandom();
|
||||
}
|
||||
|
||||
private const string BASE_PATH = "data/";
|
||||
private const string CARDS_PATH = $"{BASE_PATH}images/cards";
|
||||
private static TypedKey<byte[]> GetImageKey(Uri url)
|
||||
=> new($"image:{url}");
|
||||
|
||||
private IDatabase Db
|
||||
=> _con.GetDatabase();
|
||||
|
||||
public ImageUrls ImageUrls { get; private set; }
|
||||
|
||||
public IReadOnlyList<byte[]> Heads
|
||||
=> GetByteArrayData(ImageKeys.CoinHeads);
|
||||
|
||||
public IReadOnlyList<byte[]> Tails
|
||||
=> GetByteArrayData(ImageKeys.CoinTails);
|
||||
|
||||
public IReadOnlyList<byte[]> Dice
|
||||
=> GetByteArrayData(ImageKeys.Dice);
|
||||
|
||||
public IReadOnlyList<byte[]> SlotEmojis
|
||||
=> GetByteArrayData(ImageKeys.SlotEmojis);
|
||||
|
||||
public IReadOnlyList<byte[]> Currency
|
||||
=> GetByteArrayData(ImageKeys.Currency);
|
||||
|
||||
public byte[] SlotBackground
|
||||
=> GetByteData(ImageKeys.SlotBg);
|
||||
|
||||
public byte[] RategirlMatrix
|
||||
=> GetByteData(ImageKeys.RategirlMatrix);
|
||||
|
||||
public byte[] RategirlDot
|
||||
=> GetByteData(ImageKeys.RategirlDot);
|
||||
|
||||
public byte[] XpBackground
|
||||
=> GetByteData(ImageKeys.XpBg);
|
||||
|
||||
public byte[] Rip
|
||||
=> GetByteData(ImageKeys.RipBg);
|
||||
|
||||
public byte[] RipOverlay
|
||||
=> GetByteData(ImageKeys.RipOverlay);
|
||||
|
||||
private readonly ConnectionMultiplexer _con;
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _imagesPath;
|
||||
|
||||
public RedisImagesCache(ConnectionMultiplexer con, IBotCredentials creds)
|
||||
{
|
||||
_con = con;
|
||||
_creds = creds;
|
||||
_http = new();
|
||||
_imagesPath = Path.Combine(BASE_PATH, "images.yml");
|
||||
|
||||
Migrate();
|
||||
|
||||
ImageUrls = Yaml.Deserializer.Deserialize<ImageUrls>(File.ReadAllText(_imagesPath));
|
||||
}
|
||||
|
||||
public byte[] GetCard(string key)
|
||||
// since cards are always local for now, don't cache them
|
||||
=> File.ReadAllBytes(Path.Join(CARDS_PATH, key + ".jpg"));
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
if (await AllKeysExist())
|
||||
return;
|
||||
|
||||
await Reload();
|
||||
}
|
||||
|
||||
private void Migrate()
|
||||
{
|
||||
// migrate to yml
|
||||
if (File.Exists(Path.Combine(BASE_PATH, "images.json")))
|
||||
{
|
||||
var oldFilePath = Path.Combine(BASE_PATH, "images.json");
|
||||
var backupFilePath = Path.Combine(BASE_PATH, "images.json.backup");
|
||||
|
||||
var oldData = JsonConvert.DeserializeObject<OldImageUrls>(File.ReadAllText(oldFilePath));
|
||||
|
||||
if (oldData is not null)
|
||||
private async Task<byte[]?> GetImageDataAsync(Uri url)
|
||||
=> await _cache.GetOrAddAsync(
|
||||
GetImageKey(url),
|
||||
async () =>
|
||||
{
|
||||
var newData = new ImageUrls
|
||||
{
|
||||
Coins =
|
||||
new()
|
||||
{
|
||||
Heads =
|
||||
oldData.Coins.Heads.Length == 1
|
||||
&& oldData.Coins.Heads[0].ToString()
|
||||
== "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/coins/heads.png"
|
||||
? 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);
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var bytes = await http.GetByteArrayAsync(url);
|
||||
return bytes;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed reading image bytes from uri: {Uri}", uri.ToString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
expiry: 48.Hours());
|
||||
|
||||
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()
|
||||
private async Task<byte[]?> GetRandomImageDataAsync(Uri[] urls)
|
||||
{
|
||||
var tasks = await GetAllKeys().Select(x => Db.KeyExistsAsync(GetRedisKey(x))).WhenAll();
|
||||
if (urls.Length == 0)
|
||||
return null;
|
||||
|
||||
return tasks.All(exist => exist);
|
||||
var url = urls[_rng.Next(0, urls.Length)];
|
||||
|
||||
var data = await GetImageDataAsync(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
private IEnumerable<ImageKeys> GetAllKeys()
|
||||
=> Enum.GetValues<ImageKeys>();
|
||||
public Task<byte[]?> GetHeadsImageAsync()
|
||||
=> GetRandomImageDataAsync(_ic.Data.Coins.Heads);
|
||||
|
||||
private byte[][] GetByteArrayData(ImageKeys key)
|
||||
=> Db.ListRange(GetRedisKey(key)).Map(x => (byte[])x);
|
||||
public Task<byte[]?> GetTailsImageAsync()
|
||||
=> GetRandomImageDataAsync(_ic.Data.Coins.Tails);
|
||||
|
||||
private byte[] GetByteData(ImageKeys key)
|
||||
=> Db.StringGet(GetRedisKey(key));
|
||||
public Task<byte[]?> GetCurrencyImageAsync()
|
||||
=> GetRandomImageDataAsync(_ic.Data.Currency);
|
||||
|
||||
private RedisKey GetRedisKey(ImageKeys key)
|
||||
=> _creds.RedisKey() + "_image_" + key;
|
||||
}
|
||||
public Task<byte[]?> GetXpBackgroundImageAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Xp.Bg);
|
||||
|
||||
public Task<byte[]?> GetRategirlBgAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rategirl.Matrix);
|
||||
|
||||
public Task<byte[]?> GetRategirlDotAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rategirl.Dot);
|
||||
|
||||
public Task<byte[]?> GetDiceAsync(int num)
|
||||
=> GetImageDataAsync(_ic.Data.Dice[num]);
|
||||
|
||||
public Task<byte[]?> GetSlotEmojiAsync(int number)
|
||||
=> GetImageDataAsync(_ic.Data.Slots.Emojis[number]);
|
||||
|
||||
public Task<byte[]?> GetSlotBgAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Slots.Bg);
|
||||
|
||||
public Task<byte[]?> GetRipBgAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rip.Bg);
|
||||
|
||||
public Task<byte[]?> GetRipOverlayAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rip.Overlay);
|
||||
}
|
||||
|
@@ -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
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public class LocalBotStringsProvider : IBotStringsProvider
|
||||
public class MemoryBotStringsProvider : IBotStringsProvider
|
||||
{
|
||||
private readonly IStringsSource _source;
|
||||
private IReadOnlyDictionary<string, Dictionary<string, string>> responseStrings;
|
||||
private IReadOnlyDictionary<string, Dictionary<string, CommandStrings>> commandStrings;
|
||||
|
||||
public LocalBotStringsProvider(IStringsSource source)
|
||||
public MemoryBotStringsProvider(IStringsSource source)
|
||||
{
|
||||
_source = source;
|
||||
Reload();
|
@@ -32,7 +32,7 @@ public static class ArrayExtensions
|
||||
public static TOut[] Map<TIn, TOut>(this TIn[] arr, Func<TIn, TOut> f)
|
||||
=> 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];
|
||||
|
||||
|
@@ -37,8 +37,9 @@ public static class Extensions
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(text))
|
||||
};
|
||||
|
||||
public static List<ulong> GetGuildIds(this DiscordSocketClient client)
|
||||
=> client.Guilds.Select(x => x.Id).ToList();
|
||||
public static ulong[] GetGuildIds(this DiscordSocketClient client)
|
||||
=> client.Guilds
|
||||
.Map(x => x.Id);
|
||||
|
||||
/// <summary>
|
||||
/// 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 IServiceCollection AddBotStringsServices(this IServiceCollection services, int totalShards)
|
||||
=> totalShards <= 1
|
||||
public static IServiceCollection AddBotStringsServices(this IServiceCollection services, BotCacheImplemenation botCache)
|
||||
=> botCache == BotCacheImplemenation.Memory
|
||||
? services.AddSingleton<IStringsSource, LocalFileStringsSource>()
|
||||
.AddSingleton<IBotStringsProvider, LocalBotStringsProvider>()
|
||||
.AddSingleton<IBotStringsProvider, MemoryBotStringsProvider>()
|
||||
.AddSingleton<IBotStrings, BotStrings>()
|
||||
: services.AddSingleton<IStringsSource, LocalFileStringsSource>()
|
||||
.AddSingleton<IBotStringsProvider, RedisBotStringsProvider>()
|
||||
@@ -23,17 +23,6 @@ public static class ServiceCollectionExtensions
|
||||
services.Scan(x => x.FromCallingAssembly()
|
||||
.AddClasses(f => f.AssignableTo(typeof(ConfigServiceBase<>)))
|
||||
.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;
|
||||
}
|
||||
@@ -48,7 +37,7 @@ public static class ServiceCollectionExtensions
|
||||
.AddSingleton<ISoundcloudResolver, SoundcloudResolver>()
|
||||
.AddSingleton<ILocalTrackResolver, LocalTrackResolver>()
|
||||
.AddSingleton<IRadioResolver, RadioResolver>()
|
||||
.AddSingleton<ITrackCacher, RedisTrackCacher>()
|
||||
.AddSingleton<ITrackCacher, TrackCacher>()
|
||||
.AddSingleton<YtLoader>()
|
||||
.AddSingleton<IPlaceholderProvider>(svc => svc.GetRequiredService<IMusicService>());
|
||||
|
||||
@@ -65,10 +54,23 @@ public static class ServiceCollectionExtensions
|
||||
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);
|
||||
services.AddSingleton(ConnectionMultiplexer.Connect(conf));
|
||||
return services;
|
||||
if (creds.BotCache == BotCacheImplemenation.Redis)
|
||||
{
|
||||
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));
|
||||
|
||||
// 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
|
||||
? null
|
||||
? new(CDN.GetDefaultUserAvatarUrl(ushort.Parse(usr.Discriminator)))
|
||||
: new Uri(usr.AvatarId.StartsWith("a_", StringComparison.InvariantCulture)
|
||||
? $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.gif"
|
||||
: $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.png");
|
||||
|
@@ -1,5 +1,5 @@
|
||||
# DO NOT CHANGE
|
||||
version: 5
|
||||
version: 6
|
||||
# Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/
|
||||
token: ''
|
||||
# List of Ids of the users who have bot owner permissions
|
||||
@@ -9,6 +9,9 @@ ownerIds: []
|
||||
usePrivilegedIntents: true
|
||||
# The number of shards that the bot will be running on.
|
||||
# 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
|
||||
# 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.
|
||||
@@ -53,7 +56,12 @@ patreon:
|
||||
botListToken: ''
|
||||
# Official cleverbot api key.
|
||||
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.
|
||||
# Only used if botCache is set to 'redis'
|
||||
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
|
||||
db:
|
||||
@@ -99,7 +107,7 @@ twitchClientSecret:
|
||||
# args: "NadekoBot.dll -- {0}"
|
||||
# Windows default
|
||||
# cmd: NadekoBot.exe
|
||||
# args: {0}
|
||||
# args: "{0}"
|
||||
restartCommand:
|
||||
cmd:
|
||||
args:
|
||||
|
@@ -862,8 +862,6 @@ unsetmusicchannel:
|
||||
musicquality:
|
||||
- musicquality
|
||||
- mquality
|
||||
imagesreload:
|
||||
- imagesreload
|
||||
stringsreload:
|
||||
- stringsreload
|
||||
shardstats:
|
||||
|
@@ -24,7 +24,7 @@ quotas:
|
||||
# Dictionary of commands with their respective quota data
|
||||
commands:
|
||||
cleverbot:
|
||||
V: null
|
||||
V:
|
||||
prune:
|
||||
X:
|
||||
PerHour: 1
|
||||
|
@@ -1496,10 +1496,6 @@ musicquality:
|
||||
- ""
|
||||
- "High"
|
||||
- "Low"
|
||||
imagesreload:
|
||||
desc: "Reloads images bot is using. Safe to use even when bot is being used heavily."
|
||||
args:
|
||||
- ""
|
||||
stringsreload:
|
||||
desc: "Reloads localized bot strings."
|
||||
args:
|
||||
|
Reference in New Issue
Block a user