Abstract away cache. 2 implementations: redis and memory

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

View File

@@ -3,6 +3,26 @@
Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
## Unreleased
### Changed
- New cache abstraction added
- 2 implemenations: redis and memory
- All current bots will stay on redis cache, all new bots will use in-process memory cache
- This change removes bot's hard dependency on redis
- Configurable in `creds.yml` (please read the comments)
- You **MUST** use 'redis' if your bot runs on more than 1 shard (2000+ servers)
- [dev] Using new non-locking ConcurrentDictionary
### Fixed
- `.xp` will now show default user avatars too
### Removed
- Removed `.imagesreload` as images are now lazily loaded on request and then cached
## [4.2.6] - 22.06.2022 ## [4.2.6] - 22.06.2022
### Fixed ### Fixed

View File

@@ -21,7 +21,7 @@ namespace NadekoBot.Tests
var stringsSource = new LocalFileStringsSource( var stringsSource = new LocalFileStringsSource(
responsesPath, responsesPath,
commandsPath); commandsPath);
var strings = new LocalBotStringsProvider(stringsSource); var strings = new MemoryBotStringsProvider(stringsSource);
var culture = new CultureInfo("en-US"); var culture = new CultureInfo("en-US");

View File

@@ -102,20 +102,20 @@ public sealed class Bot
var svcs = new ServiceCollection().AddTransient(_ => _credsProvider.GetCreds()) // bot creds var svcs = new ServiceCollection().AddTransient(_ => _credsProvider.GetCreds()) // bot creds
.AddSingleton(_credsProvider) .AddSingleton(_credsProvider)
.AddSingleton(_db) // database .AddSingleton(_db) // database
.AddRedis(_creds.RedisOptions) // redis
.AddSingleton(Client) // discord socket client .AddSingleton(Client) // discord socket client
.AddSingleton(_commandService) .AddSingleton(_commandService)
// .AddSingleton(_interactionService) // .AddSingleton(_interactionService)
.AddSingleton(this) .AddSingleton(this)
.AddSingleton<ISeria, JsonSeria>() .AddSingleton<ISeria, JsonSeria>()
.AddSingleton<IPubSub, RedisPubSub>()
.AddSingleton<IConfigSeria, YamlSeria>() .AddSingleton<IConfigSeria, YamlSeria>()
.AddBotStringsServices(_creds.TotalShards)
.AddConfigServices() .AddConfigServices()
.AddConfigMigrators() .AddConfigMigrators()
.AddMemoryCache() .AddMemoryCache()
// music // music
.AddMusic(); .AddMusic()
// cache
.AddCache(_creds);
// admin // admin
#if GLOBAL_NADEKO #if GLOBAL_NADEKO
svcs.AddSingleton<ILogCommandService, DummyLogCommandService>(); svcs.AddSingleton<ILogCommandService, DummyLogCommandService>();
@@ -143,13 +143,6 @@ public sealed class Bot
.AddSingleton<IReadyExecutor>(x => x.GetRequiredService<RemoteGrpcCoordinator>()); .AddSingleton<IReadyExecutor>(x => x.GetRequiredService<RemoteGrpcCoordinator>());
} }
svcs.AddSingleton<RedisLocalDataCache>()
.AddSingleton<ILocalDataCache>(x => x.GetRequiredService<RedisLocalDataCache>())
.AddSingleton<RedisImagesCache>()
.AddSingleton<IImageCache>(x => x.GetRequiredService<RedisImagesCache>())
.AddSingleton<IReadyExecutor>(x => x.GetRequiredService<RedisImagesCache>())
.AddSingleton<IDataCache, RedisCache>();
svcs.Scan(scan => scan.FromAssemblyOf<IReadyExecutor>() svcs.Scan(scan => scan.FromAssemblyOf<IReadyExecutor>()
.AddClasses(classes => classes.AssignableToAny( .AddClasses(classes => classes.AssignableToAny(
// services // services

View File

@@ -15,22 +15,24 @@ public sealed class RatelimitAttribute : PreconditionAttribute
Seconds = seconds; Seconds = seconds;
} }
public override Task<PreconditionResult> CheckPermissionsAsync( public override async Task<PreconditionResult> CheckPermissionsAsync(
ICommandContext context, ICommandContext context,
CommandInfo command, CommandInfo command,
IServiceProvider services) IServiceProvider services)
{ {
if (Seconds == 0) if (Seconds == 0)
return Task.FromResult(PreconditionResult.FromSuccess()); return PreconditionResult.FromSuccess();
var cache = services.GetRequiredService<IDataCache>(); var cache = services.GetRequiredService<IBotCache>();
var rem = cache.TryAddRatelimit(context.User.Id, command.Name, Seconds); var rem = await cache.GetRatelimitAsync(
new($"precondition:{context.User.Id}:{command.Name}"),
Seconds.Seconds());
if (rem is null) if (rem is null)
return Task.FromResult(PreconditionResult.FromSuccess()); return PreconditionResult.FromSuccess();
var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s."; var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s.";
return Task.FromResult(PreconditionResult.FromError(msgContent)); return PreconditionResult.FromError(msgContent);
} }
} }

View 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;
}
}

View 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);
}

View 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);
}
}
}

View 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);
});
}
}

View File

@@ -12,7 +12,7 @@ namespace NadekoBot.Common.Configs;
public sealed partial class BotConfig : ICloneable<BotConfig> public sealed partial class BotConfig : ICloneable<BotConfig>
{ {
[Comment(@"DO NOT CHANGE")] [Comment(@"DO NOT CHANGE")]
public int Version { get; set; } = 2; public int Version { get; set; } = 3;
[Comment(@"Most commands, when executed, have a small colored line [Comment(@"Most commands, when executed, have a small colored line
next to the response. The color depends whether the command next to the response. The color depends whether the command

View File

@@ -19,7 +19,10 @@ public sealed class Creds : IBotCredentials
public bool UsePrivilegedIntents { get; set; } public bool UsePrivilegedIntents { get; set; }
[Comment(@"The number of shards that the bot will be running on. [Comment(@"The number of shards that the bot will be running on.
Leave at 1 if you don't know what you're doing.")] Leave at 1 if you don't know what you're doing.
note: If you are planning to have more than one shard, then you must change botCache to 'redis'.
Also, in that case you should be using NadekoBot.Coordinator to start the bot, and it will correctly override this value.")]
public int TotalShards { get; set; } public int TotalShards { get; set; }
[Comment( [Comment(
@@ -51,7 +54,13 @@ go to https://www.patreon.com/portal -> my clients -> create client")]
[Comment(@"Official cleverbot api key.")] [Comment(@"Official cleverbot api key.")]
public string CleverbotApiKey { get; set; } public string CleverbotApiKey { get; set; }
[Comment(@"Redis connection string. Don't change if you don't know what you're doing.")] [Comment(@"Which cache implementation should bot use.
'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset.
'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml")]
public BotCacheImplemenation BotCache { get; set; }
[Comment(@"Redis connection string. Don't change if you don't know what you're doing.
Only used if botCache is set to 'redis'")]
public string RedisOptions { get; set; } public string RedisOptions { get; set; }
[Comment(@"Database options. Don't change if you don't know what you're doing. Leave null for default values")] [Comment(@"Database options. Don't change if you don't know what you're doing. Leave null for default values")]
@@ -104,12 +113,12 @@ Linux default
args: ""NadekoBot.dll -- {0}"" args: ""NadekoBot.dll -- {0}""
Windows default Windows default
cmd: NadekoBot.exe cmd: NadekoBot.exe
args: {0}")] args: ""{0}""")]
public RestartConfig RestartCommand { get; set; } public RestartConfig RestartCommand { get; set; }
public Creds() public Creds()
{ {
Version = 5; Version = 6;
Token = string.Empty; Token = string.Empty;
UsePrivilegedIntents = true; UsePrivilegedIntents = true;
OwnerIds = new List<ulong>(); OwnerIds = new List<ulong>();
@@ -119,6 +128,7 @@ Windows default
Patreon = new(string.Empty, string.Empty, string.Empty, string.Empty); Patreon = new(string.Empty, string.Empty, string.Empty, string.Empty);
BotListToken = string.Empty; BotListToken = string.Empty;
CleverbotApiKey = string.Empty; CleverbotApiKey = string.Empty;
BotCache = BotCacheImplemenation.Memory;
RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password="; RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password=";
Db = new() Db = new()
{ {
@@ -218,3 +228,9 @@ public class GoogleApiConfig
public string SearchId { get; init; } public string SearchId { get; init; }
public string ImageSearchId { get; init; } public string ImageSearchId { get; init; }
} }
public enum BotCacheImplemenation
{
Memory,
Redis
}

View File

@@ -26,6 +26,7 @@ public interface IBotCredentials
string TwitchClientId { get; set; } string TwitchClientId { get; set; }
string TwitchClientSecret { get; set; } string TwitchClientSecret { get; set; }
GoogleApiConfig Google { get; set; } GoogleApiConfig Google { get; set; }
BotCacheImplemenation BotCache { get; set; }
} }
public class RestartConfig public class RestartConfig

View File

@@ -1,9 +1,11 @@
#nullable disable #nullable disable
using NadekoBot.Common.Yml; using NadekoBot.Common.Yml;
using Cloneable;
namespace NadekoBot.Common; namespace NadekoBot.Common;
public class ImageUrls [Cloneable]
public partial class ImageUrls : ICloneable<ImageUrls>
{ {
[Comment("DO NOT CHANGE")] [Comment("DO NOT CHANGE")]
public int Version { get; set; } = 3; public int Version { get; set; } = 3;

View 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 _);
}

View File

@@ -1,11 +1,12 @@
#nullable disable #nullable disable
using Newtonsoft.Json;
using System.Text.Json.Serialization;
namespace NadekoBot.Common.Pokemon; namespace NadekoBot.Common.Pokemon;
public class SearchPokemon public class SearchPokemon
{ {
[JsonProperty("num")] [JsonPropertyName("num")]
public int Id { get; set; } public int Id { get; set; }
public string Species { get; set; } public string Species { get; set; }

View 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; }
}

View File

@@ -59,6 +59,9 @@ public abstract class NadekoContext : DbContext
public DbSet<PatronQuota> PatronQuotas { get; set; } public DbSet<PatronQuota> PatronQuotas { get; set; }
public DbSet<StreamOnlineMessage> StreamOnlineMessages { get; set; }
#region Mandatory Provider-Specific Values #region Mandatory Provider-Specific Values
protected abstract string CurrencyTransactionOtherIdDefaultValue { get; } protected abstract string CurrencyTransactionOtherIdDefaultValue { get; }

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -16,7 +16,7 @@ namespace NadekoBot.Migrations.Mysql
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "6.0.5") .HasAnnotation("ProductVersion", "6.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 64); .HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("NadekoBot.Db.Models.BankUser", b => modelBuilder.Entity("NadekoBot.Db.Models.BankUser", b =>
@@ -333,6 +333,39 @@ namespace NadekoBot.Migrations.Mysql
b.ToTable("patrons", (string)null); b.ToTable("patrons", (string)null);
}); });
modelBuilder.Entity("NadekoBot.Db.Models.StreamOnlineMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
b.Property<ulong>("ChannelId")
.HasColumnType("bigint unsigned")
.HasColumnName("channelid");
b.Property<DateTime?>("DateAdded")
.HasColumnType("datetime(6)")
.HasColumnName("dateadded");
b.Property<ulong>("MessageId")
.HasColumnType("bigint unsigned")
.HasColumnName("messageid");
b.Property<string>("Name")
.HasColumnType("longtext")
.HasColumnName("name");
b.Property<int>("Type")
.HasColumnType("int")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_streamonlinemessages");
b.ToTable("streamonlinemessages", (string)null);
});
modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b => modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -17,7 +17,7 @@ namespace NadekoBot.Migrations.PostgreSql
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "6.0.5") .HasAnnotation("ProductVersion", "6.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -343,6 +343,41 @@ namespace NadekoBot.Migrations.PostgreSql
b.ToTable("patrons", (string)null); b.ToTable("patrons", (string)null);
}); });
modelBuilder.Entity("NadekoBot.Db.Models.StreamOnlineMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("ChannelId")
.HasColumnType("numeric(20,0)")
.HasColumnName("channelid");
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp with time zone")
.HasColumnName("dateadded");
b.Property<decimal>("MessageId")
.HasColumnType("numeric(20,0)")
.HasColumnName("messageid");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_streamonlinemessages");
b.ToTable("streamonlinemessages", (string)null);
});
modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b => modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace NadekoBot.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.5"); modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
modelBuilder.Entity("NadekoBot.Db.Models.BankUser", b => modelBuilder.Entity("NadekoBot.Db.Models.BankUser", b =>
{ {
@@ -262,6 +262,33 @@ namespace NadekoBot.Migrations
b.ToTable("Patrons"); b.ToTable("Patrons");
}); });
modelBuilder.Entity("NadekoBot.Db.Models.StreamOnlineMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("ChannelId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<ulong>("MessageId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("StreamOnlineMessages");
});
modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b => modelBuilder.Entity("NadekoBot.Services.Database.Models.AntiAltSetting", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,13 +15,13 @@ namespace NadekoBot.Modules.Searches.Services;
public class CryptoService : INService public class CryptoService : INService
{ {
private readonly IDataCache _cache; private readonly IBotCache _cache;
private readonly IHttpClientFactory _httpFactory; private readonly IHttpClientFactory _httpFactory;
private readonly IBotCredentials _creds; private readonly IBotCredentials _creds;
private readonly SemaphoreSlim _getCryptoLock = new(1, 1); private readonly SemaphoreSlim _getCryptoLock = new(1, 1);
public CryptoService(IDataCache cache, IHttpClientFactory httpFactory, IBotCredentials creds) public CryptoService(IBotCache cache, IHttpClientFactory httpFactory, IBotCredentials creds)
{ {
_cache = cache; _cache = cache;
_httpFactory = httpFactory; _httpFactory = httpFactory;
@@ -40,7 +40,8 @@ public class CryptoService : INService
Span<PointF> points = new PointF[gElement.ChildNodes.Count]; Span<PointF> points = new PointF[gElement.ChildNodes.Count];
var cnt = 0; var cnt = 0;
bool GetValuesFromAttributes(XmlAttributeCollection attrs, bool GetValuesFromAttributes(
XmlAttributeCollection attrs,
out float x1, out float x1,
out float y1, out float y1,
out float x2, out float x2,
@@ -130,20 +131,20 @@ public class CryptoService : INService
await _getCryptoLock.WaitAsync(); await _getCryptoLock.WaitAsync();
try try
{ {
var fullStrData = await _cache.GetOrAddCachedDataAsync("nadeko:crypto_data", var data = await _cache.GetOrAddAsync(new("nadeko:crypto_data"),
async _ => async () =>
{ {
try try
{ {
using var http = _httpFactory.CreateClient(); using var http = _httpFactory.CreateClient();
var strData = await http.GetFromJsonAsync<CryptoResponse>( var data = await http.GetFromJsonAsync<CryptoResponse>(
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?" "https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?"
+ $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}" + $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}"
+ "&start=1" + "&start=1"
+ "&limit=5000" + "&limit=5000"
+ "&convert=USD"); + "&convert=USD");
return JsonSerializer.Serialize(strData); return data;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -151,13 +152,12 @@ public class CryptoService : INService
return default; return default;
} }
}, },
"",
TimeSpan.FromHours(2)); TimeSpan.FromHours(2));
if (fullStrData is null) if (data is null)
return default; return default;
return JsonSerializer.Deserialize<CryptoResponse>(fullStrData)?.Data ?? new(); return data.Data;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -170,42 +170,31 @@ public class CryptoService : INService
} }
} }
private TypedKey<byte[]> GetSparklineKey(int id)
=> new($"crypto:sparkline:{id}");
public async Task<Stream?> GetSparklineAsync(int id, bool up) public async Task<Stream?> GetSparklineAsync(int id, bool up)
{ {
var key = $"crypto:sparkline:{id}"; try
{
var bytes = await _cache.GetOrAddAsync(GetSparklineKey(id),
async () =>
{
// if it fails, generate a new one
var points = await DownloadSparklinePointsAsync(id);
var sparkline = GenerateSparklineChart(points, up);
using var stream = await sparkline.ToStreamAsync();
return stream.ToArray();
},
TimeSpan.FromHours(1));
// attempt to get from cache
var db = _cache.Redis.GetDatabase();
byte[] bytes = await db.StringGetAsync(key);
// if it succeeds, return it
if (bytes is { Length: > 0 }) if (bytes is { Length: > 0 })
{ {
return bytes.ToStream(); return bytes.ToStream();
} }
// if it fails, generate a new one
var points = await DownloadSparklinePointsAsync(id);
if (points is null)
return default; return default;
var sparkline = GenerateSparklineChart(points, up);
// add to cache for 1h and return it
var stream = sparkline.ToStream();
await db.StringSetAsync(key, stream.ToArray(), expiry: TimeSpan.FromHours(1));
return stream;
}
private async Task<PointF[]?> DownloadSparklinePointsAsync(int id)
{
try
{
using var http = _httpFactory.CreateClient();
var str = await http.GetStringAsync(
$"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg");
var points = GetSparklinePointsFromSvgText(str);
return points;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -215,4 +204,13 @@ public class CryptoService : INService
return default; return default;
} }
} }
private async Task<PointF[]> DownloadSparklinePointsAsync(int id)
{
using var http = _httpFactory.CreateClient();
var str = await http.GetStringAsync(
$"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg");
var points = GetSparklinePointsFromSvgText(str);
return points;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
<PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" /> <PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="NonBlocking" Version="2.0.0" />
<PackageReference Include="OneOf" Version="3.0.216" /> <PackageReference Include="OneOf" Version="3.0.216" />
<PackageReference Include="Scrutor" Version="4.1.0" /> <PackageReference Include="Scrutor" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -6,8 +6,8 @@ namespace NadekoBot.Services;
public interface ILocalDataCache public interface ILocalDataCache
{ {
IReadOnlyDictionary<string, SearchPokemon> Pokemons { get; } Task<IReadOnlyDictionary<string, SearchPokemon>> GetPokemonsAsync();
IReadOnlyDictionary<string, SearchPokemonAbility> PokemonAbilities { get; } Task<IReadOnlyDictionary<string, SearchPokemonAbility>> GetPokemonAbilitiesAsync();
IReadOnlyDictionary<int, string> PokemonMap { get; } Task<TriviaQuestionModel[]> GetTriviaQuestionsAsync();
TriviaQuestion[] TriviaQuestions { get; } Task<PokemonNameId[]> GetPokemonMapAsync();
} }

View File

@@ -172,9 +172,10 @@ public sealed class BotCredsProvider : IBotCredsProvider
if (File.Exists(CREDS_FILE_NAME)) if (File.Exists(CREDS_FILE_NAME))
{ {
var creds = Yaml.Deserializer.Deserialize<Creds>(File.ReadAllText(CREDS_FILE_NAME)); var creds = Yaml.Deserializer.Deserialize<Creds>(File.ReadAllText(CREDS_FILE_NAME));
if (creds.Version <= 4) if (creds.Version <= 5)
{ {
creds.Version = 5; creds.Version = 6;
creds.BotCache = BotCacheImplemenation.Redis;
File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds)); File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds));
} }
} }

View 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);
}

View 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));
}
}

View File

@@ -1,297 +1,111 @@
#nullable disable using NadekoBot.Common.Configs;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Common.Yml;
using Newtonsoft.Json;
using StackExchange.Redis;
namespace NadekoBot.Services; namespace NadekoBot.Services;
public sealed class RedisImagesCache : IImageCache, IReadyExecutor public sealed class ImagesConfig : ConfigServiceBase<ImageUrls>
{ {
public enum ImageKeys private const string PATH = "data/images.yml";
private static readonly TypedKey<ImageUrls> _changeKey =
new("config.images.updated");
public override string Name
=> "images";
public ImagesConfig(IConfigSeria serializer, IPubSub pubSub)
: base(PATH, serializer, pubSub, _changeKey)
{ {
CoinHeads,
CoinTails,
Dice,
SlotBg,
SlotEmojis,
Currency,
RategirlMatrix,
RategirlDot,
RipOverlay,
RipBg,
XpBg
}
private const string BASE_PATH = "data/";
private const string CARDS_PATH = $"{BASE_PATH}images/cards";
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)
{
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 public interface IImageCache
var localImageUrls = Yaml.Deserializer.Deserialize<ImageUrls>(File.ReadAllText(_imagesPath));
if (localImageUrls.Version == 2)
{ {
localImageUrls.Version = 3; Task<byte[]?> GetHeadsImageAsync();
File.WriteAllText(_imagesPath, Yaml.Serializer.Serialize(localImageUrls)); 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();
} }
if (localImageUrls.Version == 3) public sealed class ImageCache : IImageCache, INService
{ {
localImageUrls.Version = 4; private readonly IBotCache _cache;
if (localImageUrls.Xp?.Bg.ToString() == "https://cdn.nadeko.bot/other/xp/bg.png") private readonly ImagesConfig _ic;
localImageUrls.Xp.Bg = new("https://cdn.nadeko.bot/other/xp/bg_k.png"); private readonly Random _rng;
private readonly IHttpClientFactory _httpFactory;
File.WriteAllText(_imagesPath, Yaml.Serializer.Serialize(localImageUrls)); public ImageCache(
} IBotCache cache,
ImagesConfig ic,
IHttpClientFactory httpFactory)
{
_cache = cache;
_ic = ic;
_httpFactory = httpFactory;
_rng = new NadekoRandom();
} }
public async Task Reload() private static TypedKey<byte[]> GetImageKey(Uri url)
{ => new($"image:{url}");
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) private async Task<byte[]?> GetImageDataAsync(Uri url)
=> await _cache.GetOrAddAsync(
GetImageKey(url),
async () =>
{ {
var data = await GetImageData(uri); using var http = _httpFactory.CreateClient();
if (data is null) var bytes = await http.GetByteArrayAsync(url);
return;
await Db.StringSetAsync(GetRedisKey(key), data);
}
private async Task Load(ImageKeys key, Uri[] uris)
{
await Db.KeyDeleteAsync(GetRedisKey(key));
var imageData = await uris.Select(GetImageData).WhenAll();
var vals = imageData.Where(x => x is not null).Select(x => (RedisValue)x).ToArray();
await Db.ListRightPushAsync(GetRedisKey(key), vals);
if (uris.Length != vals.Length)
{
Log.Information(
"{Loaded}/{Max} URIs for the key '{ImageKey}' have been loaded.\n"
+ "Some of the supplied URIs are either unavailable or invalid",
vals.Length,
uris.Length,
key);
}
}
private async Task<byte[]> GetImageData(Uri uri)
{
if (uri.IsFile)
{
try
{
var bytes = await File.ReadAllBytesAsync(uri.LocalPath);
return bytes; return bytes;
} },
catch (Exception ex) expiry: 48.Hours());
private async Task<byte[]?> GetRandomImageDataAsync(Uri[] urls)
{ {
Log.Warning(ex, "Failed reading image bytes from uri: {Uri}", uri.ToString()); if (urls.Length == 0)
return null; return null;
}
var url = urls[_rng.Next(0, urls.Length)];
var data = await GetImageDataAsync(url);
return data;
} }
try public Task<byte[]?> GetHeadsImageAsync()
{ => GetRandomImageDataAsync(_ic.Data.Coins.Heads);
return await _http.GetByteArrayAsync(uri);
} public Task<byte[]?> GetTailsImageAsync()
catch (Exception ex) => GetRandomImageDataAsync(_ic.Data.Coins.Tails);
{
Log.Warning(ex, "Image url you provided is not a valid image: {Uri}", uri.ToString()); public Task<byte[]?> GetCurrencyImageAsync()
return null; => GetRandomImageDataAsync(_ic.Data.Currency);
}
} public Task<byte[]?> GetXpBackgroundImageAsync()
=> GetImageDataAsync(_ic.Data.Xp.Bg);
private async Task<bool> AllKeysExist()
{ public Task<byte[]?> GetRategirlBgAsync()
var tasks = await GetAllKeys().Select(x => Db.KeyExistsAsync(GetRedisKey(x))).WhenAll(); => GetImageDataAsync(_ic.Data.Rategirl.Matrix);
return tasks.All(exist => exist); public Task<byte[]?> GetRategirlDotAsync()
} => GetImageDataAsync(_ic.Data.Rategirl.Dot);
private IEnumerable<ImageKeys> GetAllKeys() public Task<byte[]?> GetDiceAsync(int num)
=> Enum.GetValues<ImageKeys>(); => GetImageDataAsync(_ic.Data.Dice[num]);
private byte[][] GetByteArrayData(ImageKeys key) public Task<byte[]?> GetSlotEmojiAsync(int number)
=> Db.ListRange(GetRedisKey(key)).Map(x => (byte[])x); => GetImageDataAsync(_ic.Data.Slots.Emojis[number]);
private byte[] GetByteData(ImageKeys key) public Task<byte[]?> GetSlotBgAsync()
=> Db.StringGet(GetRedisKey(key)); => GetImageDataAsync(_ic.Data.Slots.Bg);
private RedisKey GetRedisKey(ImageKeys key) public Task<byte[]?> GetRipBgAsync()
=> _creds.RedisKey() + "_image_" + key; => GetImageDataAsync(_ic.Data.Rip.Bg);
public Task<byte[]?> GetRipOverlayAsync()
=> GetImageDataAsync(_ic.Data.Rip.Overlay);
} }

View File

@@ -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));
}

View File

@@ -1,13 +1,13 @@
#nullable disable #nullable disable
namespace NadekoBot.Services; namespace NadekoBot.Services;
public class LocalBotStringsProvider : IBotStringsProvider public class MemoryBotStringsProvider : IBotStringsProvider
{ {
private readonly IStringsSource _source; private readonly IStringsSource _source;
private IReadOnlyDictionary<string, Dictionary<string, string>> responseStrings; private IReadOnlyDictionary<string, Dictionary<string, string>> responseStrings;
private IReadOnlyDictionary<string, Dictionary<string, CommandStrings>> commandStrings; private IReadOnlyDictionary<string, Dictionary<string, CommandStrings>> commandStrings;
public LocalBotStringsProvider(IStringsSource source) public MemoryBotStringsProvider(IStringsSource source)
{ {
_source = source; _source = source;
Reload(); Reload();

View File

@@ -32,7 +32,7 @@ public static class ArrayExtensions
public static TOut[] Map<TIn, TOut>(this TIn[] arr, Func<TIn, TOut> f) public static TOut[] Map<TIn, TOut>(this TIn[] arr, Func<TIn, TOut> f)
=> Array.ConvertAll(arr, x => f(x)); => Array.ConvertAll(arr, x => f(x));
public static IReadOnlyCollection<TOut> Map<TIn, TOut>(this IReadOnlyCollection<TIn> col, Func<TIn, TOut> f) public static TOut[] Map<TIn, TOut>(this IReadOnlyCollection<TIn> col, Func<TIn, TOut> f)
{ {
var toReturn = new TOut[col.Count]; var toReturn = new TOut[col.Count];

View File

@@ -37,8 +37,9 @@ public static class Extensions
_ => throw new ArgumentOutOfRangeException(nameof(text)) _ => throw new ArgumentOutOfRangeException(nameof(text))
}; };
public static List<ulong> GetGuildIds(this DiscordSocketClient client) public static ulong[] GetGuildIds(this DiscordSocketClient client)
=> client.Guilds.Select(x => x.Id).ToList(); => client.Guilds
.Map(x => x.Id);
/// <summary> /// <summary>
/// Generates a string in the format HHH:mm if timespan is &gt;= 2m. /// Generates a string in the format HHH:mm if timespan is &gt;= 2m.

View File

@@ -9,10 +9,10 @@ namespace NadekoBot.Extensions;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddBotStringsServices(this IServiceCollection services, int totalShards) public static IServiceCollection AddBotStringsServices(this IServiceCollection services, BotCacheImplemenation botCache)
=> totalShards <= 1 => botCache == BotCacheImplemenation.Memory
? services.AddSingleton<IStringsSource, LocalFileStringsSource>() ? services.AddSingleton<IStringsSource, LocalFileStringsSource>()
.AddSingleton<IBotStringsProvider, LocalBotStringsProvider>() .AddSingleton<IBotStringsProvider, MemoryBotStringsProvider>()
.AddSingleton<IBotStrings, BotStrings>() .AddSingleton<IBotStrings, BotStrings>()
: services.AddSingleton<IStringsSource, LocalFileStringsSource>() : services.AddSingleton<IStringsSource, LocalFileStringsSource>()
.AddSingleton<IBotStringsProvider, RedisBotStringsProvider>() .AddSingleton<IBotStringsProvider, RedisBotStringsProvider>()
@@ -24,17 +24,6 @@ public static class ServiceCollectionExtensions
.AddClasses(f => f.AssignableTo(typeof(ConfigServiceBase<>))) .AddClasses(f => f.AssignableTo(typeof(ConfigServiceBase<>)))
.AsSelfWithInterfaces()); .AsSelfWithInterfaces());
// var baseType = typeof(ConfigServiceBase<>);
//
// foreach (var type in Assembly.GetCallingAssembly().ExportedTypes.Where(x => x.IsSealed))
// {
// if (type.BaseType?.IsGenericType == true && type.BaseType.GetGenericTypeDefinition() == baseType)
// {
// services.AddSingleton(type);
// services.AddSingleton(x => (IConfigService)x.GetRequiredService(type));
// }
// }
return services; return services;
} }
@@ -48,7 +37,7 @@ public static class ServiceCollectionExtensions
.AddSingleton<ISoundcloudResolver, SoundcloudResolver>() .AddSingleton<ISoundcloudResolver, SoundcloudResolver>()
.AddSingleton<ILocalTrackResolver, LocalTrackResolver>() .AddSingleton<ILocalTrackResolver, LocalTrackResolver>()
.AddSingleton<IRadioResolver, RadioResolver>() .AddSingleton<IRadioResolver, RadioResolver>()
.AddSingleton<ITrackCacher, RedisTrackCacher>() .AddSingleton<ITrackCacher, TrackCacher>()
.AddSingleton<YtLoader>() .AddSingleton<YtLoader>()
.AddSingleton<IPlaceholderProvider>(svc => svc.GetRequiredService<IMusicService>()); .AddSingleton<IPlaceholderProvider>(svc => svc.GetRequiredService<IMusicService>());
@@ -65,10 +54,23 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddRedis(this IServiceCollection services, string redisOptions) public static IServiceCollection AddCache(this IServiceCollection services, IBotCredentials creds)
{ {
var conf = ConfigurationOptions.Parse(redisOptions); if (creds.BotCache == BotCacheImplemenation.Redis)
services.AddSingleton(ConnectionMultiplexer.Connect(conf)); {
return services; var conf = ConfigurationOptions.Parse(creds.RedisOptions);
services.AddSingleton(ConnectionMultiplexer.Connect(conf))
.AddSingleton<IBotCache, RedisBotCache>()
.AddSingleton<IPubSub, RedisPubSub>();
}
else
{
services.AddSingleton<IBotCache, MemoryBotCache>()
.AddSingleton<IPubSub, EventPubSub>();
}
return services
.AddBotStringsServices(creds.BotCache);
} }
} }

View File

@@ -30,9 +30,9 @@ public static class UserExtensions
=> usr.AvatarId is null ? new(usr.GetDefaultAvatarUrl()) : new Uri(usr.GetAvatarUrl(ImageFormat.Auto, size)); => usr.AvatarId is null ? new(usr.GetDefaultAvatarUrl()) : new Uri(usr.GetAvatarUrl(ImageFormat.Auto, size));
// This method is only used for the xp card // This method is only used for the xp card
public static Uri? RealAvatarUrl(this DiscordUser usr) public static Uri RealAvatarUrl(this DiscordUser usr)
=> usr.AvatarId is null => usr.AvatarId is null
? null ? new(CDN.GetDefaultUserAvatarUrl(ushort.Parse(usr.Discriminator)))
: new Uri(usr.AvatarId.StartsWith("a_", StringComparison.InvariantCulture) : new Uri(usr.AvatarId.StartsWith("a_", StringComparison.InvariantCulture)
? $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.gif" ? $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.gif"
: $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.png"); : $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.png");

View File

@@ -1,5 +1,5 @@
# DO NOT CHANGE # DO NOT CHANGE
version: 5 version: 6
# Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/ # Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/
token: '' token: ''
# List of Ids of the users who have bot owner permissions # List of Ids of the users who have bot owner permissions
@@ -9,6 +9,9 @@ ownerIds: []
usePrivilegedIntents: true usePrivilegedIntents: true
# The number of shards that the bot will be running on. # The number of shards that the bot will be running on.
# Leave at 1 if you don't know what you're doing. # Leave at 1 if you don't know what you're doing.
#
# note: If you are planning to have more than one shard, then you must change botCache to 'redis'.
# Also, in that case you should be using NadekoBot.Coordinator to start the bot, and it will correctly override this value.
totalShards: 1 totalShards: 1
# Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it. # Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it.
# Then, go to APIs and Services -> Credentials and click Create credentials -> API key. # Then, go to APIs and Services -> Credentials and click Create credentials -> API key.
@@ -53,7 +56,12 @@ patreon:
botListToken: '' botListToken: ''
# Official cleverbot api key. # Official cleverbot api key.
cleverbotApiKey: '' cleverbotApiKey: ''
# Which cache implementation should bot use.
# 'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset.
# 'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml
botCache: Memory
# Redis connection string. Don't change if you don't know what you're doing. # Redis connection string. Don't change if you don't know what you're doing.
# Only used if botCache is set to 'redis'
redisOptions: localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password= redisOptions: localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password=
# Database options. Don't change if you don't know what you're doing. Leave null for default values # Database options. Don't change if you don't know what you're doing. Leave null for default values
db: db:
@@ -99,7 +107,7 @@ twitchClientSecret:
# args: "NadekoBot.dll -- {0}" # args: "NadekoBot.dll -- {0}"
# Windows default # Windows default
# cmd: NadekoBot.exe # cmd: NadekoBot.exe
# args: {0} # args: "{0}"
restartCommand: restartCommand:
cmd: cmd:
args: args:

View File

@@ -862,8 +862,6 @@ unsetmusicchannel:
musicquality: musicquality:
- musicquality - musicquality
- mquality - mquality
imagesreload:
- imagesreload
stringsreload: stringsreload:
- stringsreload - stringsreload
shardstats: shardstats:

View File

@@ -24,7 +24,7 @@ quotas:
# Dictionary of commands with their respective quota data # Dictionary of commands with their respective quota data
commands: commands:
cleverbot: cleverbot:
V: null V:
prune: prune:
X: X:
PerHour: 1 PerHour: 1

View File

@@ -1496,10 +1496,6 @@ musicquality:
- "" - ""
- "High" - "High"
- "Low" - "Low"
imagesreload:
desc: "Reloads images bot is using. Safe to use even when bot is being used heavily."
args:
- ""
stringsreload: stringsreload:
desc: "Reloads localized bot strings." desc: "Reloads localized bot strings."
args: args: