forked from EllieBotDevs/elliebot
Added initial version of the grpc api. Added relevant dummy settings to creds (they have no effect rn)
Yt searches now INTERNALLY return multiple results but there is no way right now to paginate plain text results moved some stuff around
This commit is contained in:
parent
564ae52291
commit
c0cd161c90
45 changed files with 1060 additions and 283 deletions
src/EllieBot/Services
73
src/EllieBot/Services/GrpcApi/ExprsSvc.cs
Normal file
73
src/EllieBot/Services/GrpcApi/ExprsSvc.cs
Normal file
|
@ -0,0 +1,73 @@
|
|||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using EllieBot.Db.Models;
|
||||
using EllieBot.Modules.EllieExpressions;
|
||||
|
||||
namespace EllieBot.GrpcApi;
|
||||
|
||||
public class ExprsSvc : GrpcExprs.GrpcExprsBase, IEService
|
||||
{
|
||||
private readonly EllieExpressionsService _svc;
|
||||
|
||||
public ExprsSvc(EllieExpressionsService svc)
|
||||
{
|
||||
_svc = svc;
|
||||
}
|
||||
|
||||
public override async Task<AddExprReply> AddExpr(AddExprRequest request, ServerCallContext context)
|
||||
{
|
||||
EllieExpression expr;
|
||||
if (!string.IsNullOrWhiteSpace(request.Expr.Id))
|
||||
{
|
||||
expr = await _svc.EditAsync(request.GuildId,
|
||||
new kwum(request.Expr.Id),
|
||||
request.Expr.Response,
|
||||
request.Expr.Ca,
|
||||
request.Expr.Ad,
|
||||
request.Expr.Dm);
|
||||
}
|
||||
else
|
||||
{
|
||||
expr = await _svc.AddAsync(request.GuildId,
|
||||
request.Expr.Trigger,
|
||||
request.Expr.Response,
|
||||
request.Expr.Ca,
|
||||
request.Expr.Ad,
|
||||
request.Expr.Dm);
|
||||
}
|
||||
|
||||
|
||||
return new AddExprReply()
|
||||
{
|
||||
Id = new kwum(expr.Id).ToString(),
|
||||
Success = true,
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<GetExprsReply> GetExprs(GetExprsRequest request, ServerCallContext context)
|
||||
{
|
||||
var (exprs, totalCount) = await _svc.FindExpressionsAsync(request.GuildId, request.Query, request.Page);
|
||||
|
||||
var reply = new GetExprsReply();
|
||||
reply.TotalCount = totalCount;
|
||||
reply.Expressions.AddRange(exprs.Select(x => new ExprDto()
|
||||
{
|
||||
Ad = x.AutoDeleteTrigger,
|
||||
At = x.AllowTarget,
|
||||
Ca = x.ContainsAnywhere,
|
||||
Dm = x.DmResponse,
|
||||
Response = x.Response,
|
||||
Id = new kwum(x.Id).ToString(),
|
||||
Trigger = x.Trigger,
|
||||
}));
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
public override async Task<Empty> DeleteExpr(DeleteExprRequest request, ServerCallContext context)
|
||||
{
|
||||
await _svc.DeleteAsync(request.GuildId, new kwum(request.Id));
|
||||
|
||||
return new Empty();
|
||||
}
|
||||
}
|
121
src/EllieBot/Services/GrpcApi/GreetByeSvc.cs
Normal file
121
src/EllieBot/Services/GrpcApi/GreetByeSvc.cs
Normal file
|
@ -0,0 +1,121 @@
|
|||
using Grpc.Core;
|
||||
using GreetType = EllieBot.Services.GreetType;
|
||||
|
||||
namespace EllieBot.GrpcApi;
|
||||
|
||||
public sealed class GreetByeSvc : GrpcGreet.GrpcGreetBase, IEService
|
||||
{
|
||||
private readonly GreetService _gs;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public GreetByeSvc(GreetService gs, DiscordSocketClient client)
|
||||
{
|
||||
_gs = gs;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public GreetSettings GetDefaultGreet(GreetType type)
|
||||
=> new GreetSettings()
|
||||
{
|
||||
GreetType = type
|
||||
};
|
||||
|
||||
private static GrpcGreetSettings ToConf(GreetSettings? conf)
|
||||
{
|
||||
if (conf is null)
|
||||
return new GrpcGreetSettings();
|
||||
|
||||
return new GrpcGreetSettings()
|
||||
{
|
||||
Message = conf.MessageText,
|
||||
Type = (GrpcGreetType)conf.GreetType,
|
||||
ChannelId = conf.ChannelId ?? 0,
|
||||
IsEnabled = conf.IsEnabled
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<GetGreetReply> GetGreetSettings(GetGreetRequest request, ServerCallContext context)
|
||||
{
|
||||
var guildId = request.GuildId;
|
||||
|
||||
var greetConf = await _gs.GetGreetSettingsAsync(guildId, GreetType.Greet);
|
||||
var byeConf = await _gs.GetGreetSettingsAsync(guildId, GreetType.Bye);
|
||||
var boostConf = await _gs.GetGreetSettingsAsync(guildId, GreetType.Boost);
|
||||
var greetDmConf = await _gs.GetGreetSettingsAsync(guildId, GreetType.GreetDm);
|
||||
// todo timer
|
||||
|
||||
return new GetGreetReply()
|
||||
{
|
||||
Greet = ToConf(greetConf),
|
||||
Bye = ToConf(byeConf),
|
||||
Boost = ToConf(boostConf),
|
||||
GreetDm = ToConf(greetDmConf)
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<UpdateGreetReply> UpdateGreet(UpdateGreetRequest request, ServerCallContext context)
|
||||
{
|
||||
var gid = request.GuildId;
|
||||
var s = request.Settings;
|
||||
var msg = s.Message;
|
||||
|
||||
await _gs.SetMessage(gid, GetGreetType(s.Type), msg);
|
||||
await _gs.SetGreet(gid, s.ChannelId, GetGreetType(s.Type), s.IsEnabled);
|
||||
|
||||
return new()
|
||||
{
|
||||
Success = true
|
||||
};
|
||||
}
|
||||
|
||||
public override Task<TestGreetReply> TestGreet(TestGreetRequest request, ServerCallContext context)
|
||||
=> TestGreet(request.GuildId, request.ChannelId, request.UserId, request.Type);
|
||||
|
||||
private async Task<TestGreetReply> TestGreet(
|
||||
ulong guildId,
|
||||
ulong channelId,
|
||||
ulong userId,
|
||||
GrpcGreetType gtDto)
|
||||
{
|
||||
var g = _client.GetGuild(guildId) as IGuild;
|
||||
if (g is null)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Error = "Guild doesn't exist",
|
||||
Success = false,
|
||||
};
|
||||
}
|
||||
|
||||
var gu = await g.GetUserAsync(userId);
|
||||
var ch = await g.GetTextChannelAsync(channelId);
|
||||
|
||||
if (gu is null || ch is null)
|
||||
return new TestGreetReply()
|
||||
{
|
||||
Error = "Guild or channel doesn't exist",
|
||||
Success = false,
|
||||
};
|
||||
|
||||
|
||||
var gt = GetGreetType(gtDto);
|
||||
|
||||
await _gs.Test(guildId, gt, ch, gu);
|
||||
return new TestGreetReply()
|
||||
{
|
||||
Success = true
|
||||
};
|
||||
}
|
||||
|
||||
private static GreetType GetGreetType(GrpcGreetType gtDto)
|
||||
{
|
||||
return gtDto switch
|
||||
{
|
||||
GrpcGreetType.Greet => GreetType.Greet,
|
||||
GrpcGreetType.GreetDm => GreetType.GreetDm,
|
||||
GrpcGreetType.Bye => GreetType.Bye,
|
||||
GrpcGreetType.Boost => GreetType.Boost,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(gtDto), gtDto, null)
|
||||
};
|
||||
}
|
||||
}
|
123
src/EllieBot/Services/GrpcApi/OtherSvc.cs
Normal file
123
src/EllieBot/Services/GrpcApi/OtherSvc.cs
Normal file
|
@ -0,0 +1,123 @@
|
|||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using EllieBot.Modules.Gambling.Services;
|
||||
using EllieBot.Modules.Xp.Services;
|
||||
|
||||
namespace EllieBot.GrpcApi;
|
||||
|
||||
public sealed class OtherSvc : GrpcOther.GrpcOtherBase, IEService
|
||||
{
|
||||
private readonly IDiscordClient _client;
|
||||
private readonly XpService _xp;
|
||||
private readonly ICurrencyService _cur;
|
||||
private readonly WaifuService _waifus;
|
||||
private readonly ICoordinator _coord;
|
||||
|
||||
public OtherSvc(
|
||||
DiscordSocketClient client,
|
||||
XpService xp,
|
||||
ICurrencyService cur,
|
||||
WaifuService waifus,
|
||||
ICoordinator coord)
|
||||
{
|
||||
_client = client;
|
||||
_xp = xp;
|
||||
_cur = cur;
|
||||
_waifus = waifus;
|
||||
_coord = coord;
|
||||
}
|
||||
|
||||
public override async Task<GetTextChannelsReply> GetTextChannels(GetTextChannelsRequest request, ServerCallContext context)
|
||||
{
|
||||
var g = await _client.GetGuildAsync(request.GuildId);
|
||||
var reply = new GetTextChannelsReply();
|
||||
|
||||
var chs = await g.GetTextChannelsAsync();
|
||||
|
||||
reply.TextChannels.AddRange(chs.Select(x => new TextChannelReply()
|
||||
{
|
||||
Id = x.Id,
|
||||
Name = x.Name,
|
||||
}));
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
public override async Task<CurrencyLbReply> GetCurrencyLb(GetLbRequest request, ServerCallContext context)
|
||||
{
|
||||
var users = await _cur.GetTopRichest(_client.CurrentUser.Id, request.Page, request.PerPage);
|
||||
|
||||
var reply = new CurrencyLbReply();
|
||||
var entries = users.Select(async x =>
|
||||
{
|
||||
var user = await _client.GetUserAsync(x.UserId, CacheMode.CacheOnly);
|
||||
return new CurrencyLbEntryReply()
|
||||
{
|
||||
Amount = x.CurrencyAmount,
|
||||
User = user.ToString(),
|
||||
UserId = x.UserId,
|
||||
Avatar = user.RealAvatarUrl().ToString()
|
||||
};
|
||||
});
|
||||
|
||||
reply.Entries.AddRange(await entries.WhenAll());
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
public override async Task<XpLbReply> GetXpLb(GetLbRequest request, ServerCallContext context)
|
||||
{
|
||||
var users = await _xp.GetUserXps(request.Page, request.PerPage);
|
||||
|
||||
var reply = new XpLbReply();
|
||||
|
||||
var entries = users.Select(x =>
|
||||
{
|
||||
var lvl = new LevelStats(x.TotalXp);
|
||||
|
||||
return new XpLbEntryReply()
|
||||
{
|
||||
Level = lvl.Level,
|
||||
TotalXp = x.TotalXp,
|
||||
User = x.Username,
|
||||
UserId = x.UserId
|
||||
};
|
||||
});
|
||||
|
||||
reply.Entries.AddRange(entries);
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
public override async Task<WaifuLbReply> GetWaifuLb(GetLbRequest request, ServerCallContext context)
|
||||
{
|
||||
var waifus = await _waifus.GetTopWaifusAtPage(request.Page, request.PerPage);
|
||||
|
||||
var reply = new WaifuLbReply();
|
||||
reply.Entries.AddRange(waifus.Select(x => new WaifuLbEntry()
|
||||
{
|
||||
ClaimedBy = x.Claimer,
|
||||
IsMutual = x.Claimer == x.Affinity,
|
||||
Value = x.Price,
|
||||
User = x.Username,
|
||||
}));
|
||||
return reply;
|
||||
}
|
||||
|
||||
public override Task<GetShardStatusesReply> GetShardStatuses(Empty request, ServerCallContext context)
|
||||
{
|
||||
var reply = new GetShardStatusesReply();
|
||||
|
||||
var shards = _coord.GetAllShardStatuses();
|
||||
|
||||
reply.Shards.AddRange(shards.Select(x => new ShardStatusReply()
|
||||
{
|
||||
Id = x.ShardId,
|
||||
Status = x.ConnectionState.ToString(),
|
||||
GuildCount = x.GuildCount,
|
||||
LastUpdate = Timestamp.FromDateTime(x.LastUpdate),
|
||||
}));
|
||||
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
}
|
50
src/EllieBot/Services/GrpcApi/ServerInfoSvc.cs
Normal file
50
src/EllieBot/Services/GrpcApi/ServerInfoSvc.cs
Normal file
|
@ -0,0 +1,50 @@
|
|||
using EllieBot.GrpcApi;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace EllieBot.GrpcApi;
|
||||
|
||||
public sealed class ServerInfoSvc : GrpcInfo.GrpcInfoBase, IEService
|
||||
{
|
||||
private readonly IStatsService _stats;
|
||||
|
||||
public ServerInfoSvc(IStatsService stats)
|
||||
{
|
||||
_stats = stats;
|
||||
}
|
||||
|
||||
public override Task<GetServerInfoReply> GetServerInfo(ServerInfoRequest request, ServerCallContext context)
|
||||
{
|
||||
var info = _stats.GetGuildInfo(request.GuildId);
|
||||
|
||||
var reply = new GetServerInfoReply()
|
||||
{
|
||||
Id = info.Id,
|
||||
Name = info.Name,
|
||||
IconUrl = info.IconUrl,
|
||||
OwnerId = info.OwnerId,
|
||||
OwnerName = info.Owner,
|
||||
TextChannels = info.TextChannels,
|
||||
VoiceChannels = info.VoiceChannels,
|
||||
MemberCount = info.MemberCount,
|
||||
CreatedAt = info.CreatedAt.Ticks,
|
||||
};
|
||||
|
||||
reply.Features.AddRange(info.Features);
|
||||
reply.Emojis.AddRange(info.Emojis.Select(x => new EmojiReply()
|
||||
{
|
||||
Name = x.Name,
|
||||
Url = x.Url,
|
||||
Code = x.ToString()
|
||||
}));
|
||||
|
||||
reply.Roles.AddRange(info.Roles.Select(x => new RoleReply()
|
||||
{
|
||||
Id = x.Id,
|
||||
Name = x.Name,
|
||||
IconUrl = x.GetIconUrl() ?? string.Empty,
|
||||
Color = x.Color.ToString()
|
||||
}));
|
||||
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
}
|
63
src/EllieBot/Services/GrpcApiService.cs
Normal file
63
src/EllieBot/Services/GrpcApiService.cs
Normal file
|
@ -0,0 +1,63 @@
|
|||
using Grpc;
|
||||
using Grpc.Core;
|
||||
using EllieBot.Common.ModuleBehaviors;
|
||||
|
||||
namespace EllieBot.GrpcApi;
|
||||
|
||||
public class GrpcApiService : IEService, IReadyExecutor
|
||||
{
|
||||
private Server? _app;
|
||||
|
||||
private static readonly bool _isEnabled = true;
|
||||
private readonly string _host = "localhost";
|
||||
private readonly int _port = 5030;
|
||||
private readonly ServerCredentials _creds = ServerCredentials.Insecure;
|
||||
|
||||
private readonly OtherSvc _other;
|
||||
private readonly ExprsSvc _exprs;
|
||||
private readonly ServerInfoSvc _info;
|
||||
private readonly GreetByeSvc _greet;
|
||||
|
||||
public GrpcApiService(
|
||||
OtherSvc other,
|
||||
ExprsSvc exprs,
|
||||
ServerInfoSvc info,
|
||||
GreetByeSvc greet)
|
||||
{
|
||||
_other = other;
|
||||
_exprs = exprs;
|
||||
_info = info;
|
||||
_greet = greet;
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
if (!_isEnabled)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_app = new()
|
||||
{
|
||||
Services =
|
||||
{
|
||||
GrpcOther.BindService(_other),
|
||||
GrpcExprs.BindService(_exprs),
|
||||
GrpcInfo.BindService(_info),
|
||||
GrpcGreet.BindService(_greet)
|
||||
},
|
||||
Ports =
|
||||
{
|
||||
new(_host, _port, _creds),
|
||||
}
|
||||
};
|
||||
_app.Start();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_app?.ShutdownAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
Log.Information("Grpc Api Server started on port {Host}:{Port}", _host, _port);
|
||||
}
|
||||
}
|
|
@ -1,164 +0,0 @@
|
|||
#nullable disable
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using EllieBot.Common.Yml;
|
||||
|
||||
namespace EllieBot.Services;
|
||||
|
||||
public sealed class BotCredsProvider : IBotCredsProvider
|
||||
{
|
||||
private const string CREDS_FILE_NAME = "creds.yml";
|
||||
private const string CREDS_EXAMPLE_FILE_NAME = "creds_example.yml";
|
||||
|
||||
private string CredsPath { get; }
|
||||
|
||||
private string CredsExamplePath { get; }
|
||||
|
||||
private readonly int? _totalShards;
|
||||
|
||||
|
||||
private readonly Creds _creds = new();
|
||||
private readonly IConfigurationRoot _config;
|
||||
|
||||
|
||||
private readonly object _reloadLock = new();
|
||||
|
||||
public BotCredsProvider(int? totalShards = null, string credPath = null)
|
||||
{
|
||||
_totalShards = totalShards;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(credPath))
|
||||
{
|
||||
CredsPath = credPath;
|
||||
CredsExamplePath = Path.Combine(Path.GetDirectoryName(credPath), CREDS_EXAMPLE_FILE_NAME);
|
||||
}
|
||||
else
|
||||
{
|
||||
CredsPath = Path.Combine(Directory.GetCurrentDirectory(), CREDS_FILE_NAME);
|
||||
CredsExamplePath = Path.Combine(Directory.GetCurrentDirectory(), CREDS_EXAMPLE_FILE_NAME);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CredsExamplePath))
|
||||
File.WriteAllText(CredsExamplePath, Yaml.Serializer.Serialize(_creds));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// this can fail in docker containers
|
||||
}
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
MigrateCredentials();
|
||||
|
||||
if (!File.Exists(CredsPath))
|
||||
{
|
||||
Log.Warning(
|
||||
"{CredsPath} is missing. Attempting to load creds from environment variables prefixed with 'EllieBot_'. Example is in {CredsExamplePath}",
|
||||
CredsPath,
|
||||
CredsExamplePath);
|
||||
}
|
||||
|
||||
_config = new ConfigurationBuilder().AddYamlFile(CredsPath, false, true)
|
||||
.AddEnvironmentVariables("EllieBot_")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
|
||||
Reload();
|
||||
}
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
lock (_reloadLock)
|
||||
{
|
||||
_creds.OwnerIds.Clear();
|
||||
_config.Bind(_creds);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_creds.Token))
|
||||
{
|
||||
Log.Error("Token is missing from creds.yml or Environment variables.\nAdd it and restart the program");
|
||||
Helpers.ReadErrorAndExit(5);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_creds.RestartCommand?.Cmd)
|
||||
|| string.IsNullOrWhiteSpace(_creds.RestartCommand?.Args))
|
||||
{
|
||||
if (Environment.OSVersion.Platform == PlatformID.Unix)
|
||||
{
|
||||
_creds.RestartCommand = new RestartConfig()
|
||||
{
|
||||
Args = "dotnet",
|
||||
Cmd = "EllieBot.dll -- {0}"
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
_creds.RestartCommand = new RestartConfig()
|
||||
{
|
||||
Args = "EllieBot.exe",
|
||||
Cmd = "{0}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_creds.RedisOptions))
|
||||
_creds.RedisOptions = "127.0.0.1,syncTimeout=3000";
|
||||
|
||||
// replace the old generated key with the shared key
|
||||
if (string.IsNullOrWhiteSpace(_creds.CoinmarketcapApiKey)
|
||||
|| _creds.CoinmarketcapApiKey.StartsWith("e79ec505-0913"))
|
||||
_creds.CoinmarketcapApiKey = "3077537c-7dfb-4d97-9a60-56fc9a9f5035";
|
||||
|
||||
_creds.TotalShards = _totalShards ?? _creds.TotalShards;
|
||||
}
|
||||
}
|
||||
|
||||
public void ModifyCredsFile(Action<IBotCredentials> func)
|
||||
{
|
||||
var ymlData = File.ReadAllText(CREDS_FILE_NAME);
|
||||
var creds = Yaml.Deserializer.Deserialize<Creds>(ymlData);
|
||||
|
||||
func(creds);
|
||||
|
||||
ymlData = Yaml.Serializer.Serialize(creds);
|
||||
File.WriteAllText(CREDS_FILE_NAME, ymlData);
|
||||
}
|
||||
|
||||
private void MigrateCredentials()
|
||||
{
|
||||
if (File.Exists(CREDS_FILE_NAME))
|
||||
{
|
||||
var creds = Yaml.Deserializer.Deserialize<Creds>(File.ReadAllText(CREDS_FILE_NAME));
|
||||
if (creds.Version <= 5)
|
||||
{
|
||||
creds.BotCache = BotCacheImplemenation.Redis;
|
||||
}
|
||||
|
||||
if (creds.Version <= 6)
|
||||
{
|
||||
creds.Version = 7;
|
||||
File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds));
|
||||
}
|
||||
|
||||
if (creds.Version <= 8)
|
||||
{
|
||||
creds.Version = 9;
|
||||
File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IBotCredentials GetCreds()
|
||||
{
|
||||
lock (_reloadLock)
|
||||
{
|
||||
return _creds;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,229 +0,0 @@
|
|||
#nullable disable
|
||||
using Google;
|
||||
using Google.Apis.Services;
|
||||
using Google.Apis.Urlshortener.v1;
|
||||
using Google.Apis.YouTube.v3;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml;
|
||||
|
||||
namespace EllieBot.Services;
|
||||
|
||||
public sealed partial class GoogleApiService : IGoogleApiService, IEService
|
||||
{
|
||||
private static readonly Regex
|
||||
_plRegex = new(@"(?:youtu\.be\/|list=)(?<id>[\da-zA-Z\-_]*)", RegexOptions.Compiled);
|
||||
|
||||
|
||||
private readonly YouTubeService _yt;
|
||||
private readonly UrlshortenerService _sh;
|
||||
|
||||
//private readonly Regex YtVideoIdRegex = new Regex(@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{6,11})", RegexOptions.Compiled);
|
||||
private readonly IBotCredsProvider _creds;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public GoogleApiService(IBotCredsProvider creds, IHttpClientFactory factory) : this()
|
||||
{
|
||||
_creds = creds;
|
||||
_httpFactory = factory;
|
||||
|
||||
var bcs = new BaseClientService.Initializer
|
||||
{
|
||||
ApplicationName = "Ellie Bot",
|
||||
ApiKey = _creds.GetCreds().GoogleApiKey
|
||||
};
|
||||
|
||||
_yt = new(bcs);
|
||||
_sh = new(bcs);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<string>> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keywords))
|
||||
throw new ArgumentNullException(nameof(keywords));
|
||||
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
|
||||
|
||||
var match = _plRegex.Match(keywords);
|
||||
if (match.Length > 1)
|
||||
return new[] { match.Groups["id"].Value };
|
||||
var query = _yt.Search.List("snippet");
|
||||
query.MaxResults = count;
|
||||
query.Type = "playlist";
|
||||
query.Q = keywords;
|
||||
|
||||
return (await query.ExecuteAsync()).Items.Select(i => i.Id.PlaylistId);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<string>> GetRelatedVideosAsync(string id, int count = 2, string user = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentNullException(nameof(id));
|
||||
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
|
||||
|
||||
var query = _yt.Search.List("snippet");
|
||||
query.MaxResults = count;
|
||||
query.Q = id;
|
||||
// query.RelatedToVideoId = id;
|
||||
query.Type = "video";
|
||||
query.QuotaUser = user;
|
||||
// bad workaround as there's no replacement for related video querying right now.
|
||||
// Query youtube with the id of the video, take a second video in the results
|
||||
// skip the first one as that's probably the same video.
|
||||
return (await query.ExecuteAsync()).Items.Select(i => "https://www.youtube.com/watch?v=" + i.Id.VideoId).Skip(1);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<string>> GetVideoLinksByKeywordAsync(string keywords, int count = 1)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keywords))
|
||||
throw new ArgumentNullException(nameof(keywords));
|
||||
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
|
||||
|
||||
var query = _yt.Search.List("snippet");
|
||||
query.MaxResults = count;
|
||||
query.Q = keywords;
|
||||
query.Type = "video";
|
||||
query.SafeSearch = SearchResource.ListRequest.SafeSearchEnum.Strict;
|
||||
return (await query.ExecuteAsync()).Items.Select(i => "https://www.youtube.com/watch?v=" + i.Id.VideoId);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<(string Name, string Id, string Url, string Thumbnail)>> GetVideoInfosByKeywordAsync(
|
||||
string keywords,
|
||||
int count = 1)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keywords))
|
||||
throw new ArgumentNullException(nameof(keywords));
|
||||
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
|
||||
|
||||
var query = _yt.Search.List("snippet");
|
||||
query.MaxResults = count;
|
||||
query.Q = keywords;
|
||||
query.Type = "video";
|
||||
return (await query.ExecuteAsync()).Items.Select(i
|
||||
=> (i.Snippet.Title.TrimTo(50),
|
||||
i.Id.VideoId,
|
||||
"https://www.youtube.com/watch?v=" + i.Id.VideoId,
|
||||
i.Snippet.Thumbnails.High.Url));
|
||||
}
|
||||
|
||||
public Task<string> ShortenUrl(Uri url)
|
||||
=> ShortenUrl(url.ToString());
|
||||
|
||||
public async Task<string> ShortenUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
throw new ArgumentNullException(nameof(url));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_creds.GetCreds().GoogleApiKey))
|
||||
return url;
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _sh.Url.Insert(new()
|
||||
{
|
||||
LongUrl = url
|
||||
})
|
||||
.ExecuteAsync();
|
||||
return response.Id;
|
||||
}
|
||||
catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
return url;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error shortening URL");
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<string>> GetPlaylistTracksAsync(string playlistId, int count = 50)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(playlistId))
|
||||
throw new ArgumentNullException(nameof(playlistId));
|
||||
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
|
||||
|
||||
string nextPageToken = null;
|
||||
|
||||
var toReturn = new List<string>(count);
|
||||
|
||||
do
|
||||
{
|
||||
var toGet = count > 50 ? 50 : count;
|
||||
count -= toGet;
|
||||
|
||||
var query = _yt.PlaylistItems.List("contentDetails");
|
||||
query.MaxResults = toGet;
|
||||
query.PlaylistId = playlistId;
|
||||
query.PageToken = nextPageToken;
|
||||
|
||||
var data = await query.ExecuteAsync();
|
||||
|
||||
toReturn.AddRange(data.Items.Select(i => i.ContentDetails.VideoId));
|
||||
nextPageToken = data.NextPageToken;
|
||||
} while (count > 0 && !string.IsNullOrWhiteSpace(nextPageToken));
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, TimeSpan>> GetVideoDurationsAsync(IEnumerable<string> videoIds)
|
||||
{
|
||||
var videoIdsList = videoIds as List<string> ?? videoIds.ToList();
|
||||
|
||||
var toReturn = new Dictionary<string, TimeSpan>();
|
||||
|
||||
if (!videoIdsList.Any())
|
||||
return toReturn;
|
||||
var remaining = videoIdsList.Count;
|
||||
|
||||
do
|
||||
{
|
||||
var toGet = remaining > 50 ? 50 : remaining;
|
||||
remaining -= toGet;
|
||||
|
||||
var q = _yt.Videos.List("contentDetails");
|
||||
q.Id = string.Join(",", videoIdsList.Take(toGet));
|
||||
videoIdsList = videoIdsList.Skip(toGet).ToList();
|
||||
var items = (await q.ExecuteAsync()).Items;
|
||||
foreach (var i in items)
|
||||
toReturn.Add(i.Id, XmlConvert.ToTimeSpan(i.ContentDetails.Duration));
|
||||
} while (remaining > 0);
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
public async Task<string> Translate(string sourceText, string sourceLanguage, string targetLanguage)
|
||||
{
|
||||
string text;
|
||||
|
||||
if (!Languages.ContainsKey(sourceLanguage) || !Languages.ContainsKey(targetLanguage))
|
||||
throw new ArgumentException(nameof(sourceLanguage) + "/" + nameof(targetLanguage));
|
||||
|
||||
|
||||
var url = new Uri(string.Format(
|
||||
"https://translate.googleapis.com/translate_a/single?client=gtx&sl={0}&tl={1}&dt=t&q={2}",
|
||||
ConvertToLanguageCode(sourceLanguage),
|
||||
ConvertToLanguageCode(targetLanguage),
|
||||
WebUtility.UrlEncode(sourceText)));
|
||||
using (var http = _httpFactory.CreateClient())
|
||||
{
|
||||
http.DefaultRequestHeaders.Add("user-agent",
|
||||
"Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36");
|
||||
text = await http.GetStringAsync(url);
|
||||
}
|
||||
|
||||
return string.Concat(JArray.Parse(text)[0].Select(x => x[0]));
|
||||
}
|
||||
|
||||
private string ConvertToLanguageCode(string language)
|
||||
{
|
||||
Languages.TryGetValue(language, out var mode);
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
namespace EllieBot.Services;
|
||||
|
||||
public sealed partial class GoogleApiService
|
||||
{
|
||||
private const string SUPPORTED = """
|
||||
afrikaans af
|
||||
albanian sq
|
||||
amharic am
|
||||
arabic ar
|
||||
armenian hy
|
||||
assamese as
|
||||
aymara ay
|
||||
azerbaijani az
|
||||
bambara bm
|
||||
basque eu
|
||||
belarusian be
|
||||
bengali bn
|
||||
bhojpuri bho
|
||||
bosnian bs
|
||||
bulgarian bg
|
||||
catalan ca
|
||||
cebuano ceb
|
||||
chinese zh-CN
|
||||
chinese-trad zh-TW
|
||||
corsican co
|
||||
croatian hr
|
||||
czech cs
|
||||
danish da
|
||||
dhivehi dv
|
||||
dogri doi
|
||||
dutch nl
|
||||
english en
|
||||
esperanto eo
|
||||
estonian et
|
||||
ewe ee
|
||||
filipino fil
|
||||
finnish fi
|
||||
french fr
|
||||
frisian fy
|
||||
galician gl
|
||||
georgian ka
|
||||
german de
|
||||
greek el
|
||||
guarani gn
|
||||
gujarati gu
|
||||
haitian ht
|
||||
hausa ha
|
||||
hawaiian haw
|
||||
hebrew he
|
||||
hindi hi
|
||||
hmong hmn
|
||||
hungarian hu
|
||||
icelandic is
|
||||
igbo ig
|
||||
ilocano ilo
|
||||
indonesian id
|
||||
irish ga
|
||||
italian it
|
||||
japanese ja
|
||||
javanese jv
|
||||
kannada kn
|
||||
kazakh kk
|
||||
khmer km
|
||||
kinyarwanda rw
|
||||
konkani gom
|
||||
korean ko
|
||||
krio kri
|
||||
kurdish ku
|
||||
kurdish-sor ckb
|
||||
kyrgyz ky
|
||||
lao lo
|
||||
latin la
|
||||
latvian lv
|
||||
lingala ln
|
||||
lithuanian lt
|
||||
luganda lg
|
||||
luxembourgish lb
|
||||
macedonian mk
|
||||
maithili mai
|
||||
malagasy mg
|
||||
malay ms
|
||||
malayalam ml
|
||||
maltese mt
|
||||
maori mi
|
||||
marathi mr
|
||||
meiteilon mni-Mtei
|
||||
mizo lus
|
||||
mongolian mn
|
||||
myanmar my
|
||||
nepali ne
|
||||
norwegian no
|
||||
nyanja ny
|
||||
odia or
|
||||
oromo om
|
||||
pashto ps
|
||||
persian fa
|
||||
polish pl
|
||||
portuguese pt
|
||||
punjabi pa
|
||||
quechua qu
|
||||
romanian ro
|
||||
russian ru
|
||||
samoan sm
|
||||
sanskrit sa
|
||||
scots gd
|
||||
sepedi nso
|
||||
serbian sr
|
||||
sesotho st
|
||||
shona sn
|
||||
sindhi sd
|
||||
sinhala si
|
||||
slovak sk
|
||||
slovenian sl
|
||||
somali so
|
||||
spanish es
|
||||
sundanese su
|
||||
swahili sw
|
||||
swedish sv
|
||||
tagalog tl
|
||||
tajik tg
|
||||
tamil ta
|
||||
tatar tt
|
||||
telugu te
|
||||
thai th
|
||||
tigrinya ti
|
||||
tsonga ts
|
||||
turkish tr
|
||||
turkmen tk
|
||||
twi ak
|
||||
ukrainian uk
|
||||
urdu ur
|
||||
uyghur ug
|
||||
uzbek uz
|
||||
vietnamese vi
|
||||
welsh cy
|
||||
xhosa xh
|
||||
yiddish yi
|
||||
yoruba yo
|
||||
zulu zu
|
||||
""";
|
||||
|
||||
|
||||
public IReadOnlyDictionary<string, string> Languages { get; }
|
||||
|
||||
private GoogleApiService()
|
||||
{
|
||||
var langs = SUPPORTED.Split("\n")
|
||||
.Select(x => x.Split(' '))
|
||||
.ToDictionary(x => x[0].Trim(), x => x[1].Trim());
|
||||
|
||||
foreach (var (_, v) in langs.ToArray())
|
||||
{
|
||||
langs.Add(v, v);
|
||||
}
|
||||
|
||||
Languages = langs;
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
namespace EllieBot.Services;
|
||||
|
||||
public sealed class ImageCache : IImageCache, IEService
|
||||
{
|
||||
private readonly IBotCache _cache;
|
||||
private readonly ImagesConfig _ic;
|
||||
private readonly Random _rng;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public ImageCache(
|
||||
IBotCache cache,
|
||||
ImagesConfig ic,
|
||||
IHttpClientFactory httpFactory)
|
||||
{
|
||||
_cache = cache;
|
||||
_ic = ic;
|
||||
_httpFactory = httpFactory;
|
||||
_rng = new EllieRandom();
|
||||
}
|
||||
|
||||
private static TypedKey<byte[]> GetImageKey(Uri url)
|
||||
=> new($"image:{url}");
|
||||
|
||||
public async Task<byte[]?> GetImageDataAsync(Uri url)
|
||||
=> await _cache.GetOrAddAsync(
|
||||
GetImageKey(url),
|
||||
async () =>
|
||||
{
|
||||
if (url.IsFile)
|
||||
{
|
||||
return await File.ReadAllBytesAsync(url.LocalPath);
|
||||
}
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var bytes = await http.GetByteArrayAsync(url);
|
||||
return bytes;
|
||||
},
|
||||
expiry: TimeSpan.FromHours(48));
|
||||
|
||||
private async Task<byte[]?> GetRandomImageDataAsync(Uri[] urls)
|
||||
{
|
||||
if (urls.Length == 0)
|
||||
return null;
|
||||
|
||||
var url = urls[_rng.Next(0, urls.Length)];
|
||||
|
||||
var data = await GetImageDataAsync(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetHeadsImageAsync()
|
||||
=> GetRandomImageDataAsync(_ic.Data.Coins.Heads);
|
||||
|
||||
public Task<byte[]?> GetTailsImageAsync()
|
||||
=> GetRandomImageDataAsync(_ic.Data.Coins.Tails);
|
||||
|
||||
public Task<byte[]?> GetCurrencyImageAsync()
|
||||
=> GetRandomImageDataAsync(_ic.Data.Currency);
|
||||
|
||||
public Task<byte[]?> GetXpBackgroundImageAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Xp.Bg);
|
||||
|
||||
public Task<byte[]?> GetDiceAsync(int num)
|
||||
=> GetImageDataAsync(_ic.Data.Dice[num]);
|
||||
|
||||
public Task<byte[]?> GetSlotEmojiAsync(int number)
|
||||
=> GetImageDataAsync(_ic.Data.Slots.Emojis[number]);
|
||||
|
||||
public Task<byte[]?> GetSlotBgAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Slots.Bg);
|
||||
}
|
|
@ -1,108 +0,0 @@
|
|||
using EllieBot.Common.Pokemon;
|
||||
using EllieBot.Modules.Games.Common.Trivia;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EllieBot.Services;
|
||||
|
||||
public sealed class LocalDataCache : ILocalDataCache, IEService
|
||||
{
|
||||
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<IReadOnlyDictionary<int, string>> _pokeMapKey
|
||||
= new("pokemon:ab_map2"); // 2 because ab_map was storing arrays
|
||||
|
||||
public async Task<IReadOnlyDictionary<int, string>?> GetPokemonMapAsync()
|
||||
=> await _cache.GetOrAddAsync(_pokeMapKey,
|
||||
async () =>
|
||||
{
|
||||
var fileName = POKEMON_MAP_PATH;
|
||||
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);
|
||||
var arr = await JsonSerializer.DeserializeAsync<PokemonNameId[]>(stream, _opts);
|
||||
|
||||
return (IReadOnlyDictionary<int, string>?)arr?.ToDictionary(x => x.Id, x => x.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex,
|
||||
"Error reading {FileName} file: {ErrorMessage}",
|
||||
fileName,
|
||||
ex.Message);
|
||||
|
||||
return default;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
private static TypedKey<TriviaQuestionModel[]> _triviaKey
|
||||
= new("trivia:questions");
|
||||
|
||||
public async Task<TriviaQuestionModel[]?> GetTriviaQuestionsAsync()
|
||||
=> await GetOrCreateCachedDataAsync(_triviaKey, QUESTIONS_FILE);
|
||||
}
|
|
@ -1,120 +0,0 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
using System.Globalization;
|
||||
|
||||
namespace EllieBot.Services;
|
||||
|
||||
public class Localization : ILocalization
|
||||
{
|
||||
private static readonly Dictionary<string, CommandData> _commandData =
|
||||
JsonConvert.DeserializeObject<Dictionary<string, CommandData>>(
|
||||
File.ReadAllText("./data/strings/commands/commands.en-US.json"));
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, CultureInfo> _guildCultureInfos;
|
||||
|
||||
public IDictionary<ulong, CultureInfo> GuildCultureInfos
|
||||
=> _guildCultureInfos;
|
||||
|
||||
public CultureInfo DefaultCultureInfo
|
||||
=> _bss.Data.DefaultLocale;
|
||||
|
||||
private readonly BotConfigService _bss;
|
||||
private readonly DbService _db;
|
||||
|
||||
public Localization(BotConfigService bss, Bot bot, DbService db)
|
||||
{
|
||||
_bss = bss;
|
||||
_db = db;
|
||||
|
||||
var cultureInfoNames = bot.AllGuildConfigs.ToDictionary(x => x.GuildId, x => x.Locale);
|
||||
|
||||
_guildCultureInfos = new(cultureInfoNames
|
||||
.ToDictionary(x => x.Key,
|
||||
x =>
|
||||
{
|
||||
CultureInfo cultureInfo = null;
|
||||
try
|
||||
{
|
||||
if (x.Value is null)
|
||||
return null;
|
||||
cultureInfo = new(x.Value);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return cultureInfo;
|
||||
})
|
||||
.Where(x => x.Value is not null));
|
||||
}
|
||||
|
||||
public void SetGuildCulture(IGuild guild, CultureInfo ci)
|
||||
=> SetGuildCulture(guild.Id, ci);
|
||||
|
||||
public void SetGuildCulture(ulong guildId, CultureInfo ci)
|
||||
{
|
||||
if (ci.Name == _bss.Data.DefaultLocale.Name)
|
||||
{
|
||||
RemoveGuildCulture(guildId);
|
||||
return;
|
||||
}
|
||||
|
||||
using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set);
|
||||
gc.Locale = ci.Name;
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
_guildCultureInfos.AddOrUpdate(guildId, ci, (_, _) => ci);
|
||||
}
|
||||
|
||||
public void RemoveGuildCulture(IGuild guild)
|
||||
=> RemoveGuildCulture(guild.Id);
|
||||
|
||||
public void RemoveGuildCulture(ulong guildId)
|
||||
{
|
||||
if (_guildCultureInfos.TryRemove(guildId, out _))
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set);
|
||||
gc.Locale = null;
|
||||
uow.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDefaultCulture(CultureInfo ci)
|
||||
=> _bss.ModifyConfig(bs =>
|
||||
{
|
||||
bs.DefaultLocale = ci;
|
||||
});
|
||||
|
||||
public void ResetDefaultCulture()
|
||||
=> SetDefaultCulture(CultureInfo.CurrentCulture);
|
||||
|
||||
public CultureInfo GetCultureInfo(IGuild guild)
|
||||
=> GetCultureInfo(guild?.Id);
|
||||
|
||||
public CultureInfo GetCultureInfo(ulong? guildId)
|
||||
{
|
||||
if (guildId is null || !GuildCultureInfos.TryGetValue(guildId.Value, out var info) || info is null)
|
||||
return _bss.Data.DefaultLocale;
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
public static CommandData LoadCommand(string key)
|
||||
{
|
||||
_commandData.TryGetValue(key, out var toReturn);
|
||||
|
||||
if (toReturn is null)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Cmd = key,
|
||||
Desc = key,
|
||||
Usage = [key]
|
||||
};
|
||||
}
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
using EllieBot.Common.JsonConverters;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public class JsonSeria : ISeria
|
||||
{
|
||||
private readonly JsonSerializerOptions _serializerOptions = new()
|
||||
{
|
||||
IncludeFields = true,
|
||||
Converters =
|
||||
{
|
||||
new Rgba32Converter(),
|
||||
new CultureInfoConverter()
|
||||
}
|
||||
};
|
||||
|
||||
public byte[] Serialize<T>(T data)
|
||||
=> JsonSerializer.SerializeToUtf8Bytes(data, _serializerOptions);
|
||||
|
||||
public T? Deserialize<T>(byte[]? data)
|
||||
{
|
||||
if (data is null)
|
||||
return default;
|
||||
|
||||
return JsonSerializer.Deserialize<T>(data, _serializerOptions);
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
using StackExchange.Redis;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public sealed class RedisPubSub : IPubSub
|
||||
{
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly ConnectionMultiplexer _multi;
|
||||
private readonly ISeria _serializer;
|
||||
|
||||
public RedisPubSub(ConnectionMultiplexer multi, ISeria serializer, IBotCredentials creds)
|
||||
{
|
||||
_multi = multi;
|
||||
_serializer = serializer;
|
||||
_creds = creds;
|
||||
}
|
||||
|
||||
public Task Pub<TData>(in TypedKey<TData> key, TData data)
|
||||
where TData : notnull
|
||||
{
|
||||
var serialized = _serializer.Serialize(data);
|
||||
return _multi.GetSubscriber()
|
||||
.PublishAsync(new RedisChannel($"{_creds.RedisKey()}:{key.Key}", RedisChannel.PatternMode.Literal),
|
||||
serialized,
|
||||
CommandFlags.FireAndForget);
|
||||
}
|
||||
|
||||
public Task Sub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
|
||||
where TData : notnull
|
||||
{
|
||||
var eventName = key.Key;
|
||||
|
||||
async void OnSubscribeHandler(RedisChannel _, RedisValue data)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataObj = _serializer.Deserialize<TData>(data);
|
||||
if (dataObj is not null)
|
||||
await action(dataObj);
|
||||
else
|
||||
{
|
||||
Log.Warning("Publishing event {EventName} with a null value. This is not allowed",
|
||||
eventName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Error handling the event {EventName}: {ErrorMessage}", eventName, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return _multi.GetSubscriber()
|
||||
.SubscribeAsync(
|
||||
new RedisChannel($"{_creds.RedisKey()}:{eventName}", RedisChannel.PatternMode.Literal),
|
||||
OnSubscribeHandler);
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
using EllieBot.Common.Configs;
|
||||
using EllieBot.Common.Yml;
|
||||
using System.Text.RegularExpressions;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public class YamlSeria : IConfigSeria
|
||||
{
|
||||
private static readonly Regex _codePointRegex =
|
||||
new(@"(\\U(?<code>[a-zA-Z0-9]{8})|\\u(?<code>[a-zA-Z0-9]{4})|\\x(?<code>[a-zA-Z0-9]{2}))",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private readonly IDeserializer _deserializer;
|
||||
private readonly ISerializer _serializer;
|
||||
|
||||
public YamlSeria()
|
||||
{
|
||||
_serializer = Yaml.Serializer;
|
||||
_deserializer = Yaml.Deserializer;
|
||||
}
|
||||
|
||||
public string Serialize<T>(T obj)
|
||||
where T : notnull
|
||||
{
|
||||
var escapedOutput = _serializer.Serialize(obj);
|
||||
var output = _codePointRegex.Replace(escapedOutput,
|
||||
me =>
|
||||
{
|
||||
var str = me.Groups["code"].Value;
|
||||
var newString = str.UnescapeUnicodeCodePoint();
|
||||
return newString;
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
public T Deserialize<T>(string data)
|
||||
=> _deserializer.Deserialize<T>(data);
|
||||
}
|
|
@ -1,120 +0,0 @@
|
|||
using OneOf;
|
||||
using OneOf.Types;
|
||||
using StackExchange.Redis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public sealed class RedisBotCache : IBotCache
|
||||
{
|
||||
private static readonly Type[] _supportedTypes =
|
||||
[
|
||||
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,
|
||||
keepTtl: true,
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,91 +0,0 @@
|
|||
#nullable disable
|
||||
using StackExchange.Redis;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
|
||||
namespace EllieBot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Uses <see cref="IStringsSource" /> to load strings into redis hash (only on Shard 0)
|
||||
/// and retrieves them from redis via <see cref="GetText" />
|
||||
/// </summary>
|
||||
public class RedisBotStringsProvider : IBotStringsProvider
|
||||
{
|
||||
private const string COMMANDS_KEY = "commands_v5";
|
||||
|
||||
private readonly ConnectionMultiplexer _redis;
|
||||
private readonly IStringsSource _source;
|
||||
private readonly IBotCredentials _creds;
|
||||
|
||||
public RedisBotStringsProvider(
|
||||
ConnectionMultiplexer redis,
|
||||
DiscordSocketClient discordClient,
|
||||
IStringsSource source,
|
||||
IBotCredentials creds)
|
||||
{
|
||||
_redis = redis;
|
||||
_source = source;
|
||||
_creds = creds;
|
||||
|
||||
if (discordClient.ShardId == 0)
|
||||
Reload();
|
||||
}
|
||||
|
||||
public string GetText(string localeName, string key)
|
||||
{
|
||||
var value = _redis.GetDatabase().HashGet($"{_creds.RedisKey()}:responses:{localeName}", key);
|
||||
return value;
|
||||
}
|
||||
|
||||
public CommandStrings GetCommandStrings(string localeName, string commandName)
|
||||
{
|
||||
string examplesStr = _redis.GetDatabase()
|
||||
.HashGet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}",
|
||||
$"{commandName}::examples");
|
||||
if (examplesStr == default)
|
||||
return null;
|
||||
|
||||
var descStr = _redis.GetDatabase()
|
||||
.HashGet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}", $"{commandName}::desc");
|
||||
if (descStr == default)
|
||||
return null;
|
||||
|
||||
var ex = examplesStr.Split('&').Map(HttpUtility.UrlDecode);
|
||||
|
||||
var paramsStr = _redis.GetDatabase()
|
||||
.HashGet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}", $"{commandName}::params");
|
||||
if (paramsStr == default)
|
||||
return null;
|
||||
|
||||
return new()
|
||||
{
|
||||
Examples = ex,
|
||||
Params = JsonSerializer.Deserialize<Dictionary<string, CommandStringParam>[]>(paramsStr),
|
||||
Desc = descStr
|
||||
};
|
||||
}
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
var redisDb = _redis.GetDatabase();
|
||||
foreach (var (localeName, localeStrings) in _source.GetResponseStrings())
|
||||
{
|
||||
var hashFields = localeStrings.Select(x => new HashEntry(x.Key, x.Value)).ToArray();
|
||||
|
||||
redisDb.HashSet($"{_creds.RedisKey()}:responses:{localeName}", hashFields);
|
||||
}
|
||||
|
||||
foreach (var (localeName, localeStrings) in _source.GetCommandStrings())
|
||||
{
|
||||
var hashFields = localeStrings
|
||||
.Select(x => new HashEntry($"{x.Key}::examples",
|
||||
string.Join('&', x.Value.Examples.Map(HttpUtility.UrlEncode))))
|
||||
.Concat(localeStrings.Select(x => new HashEntry($"{x.Key}::desc", x.Value.Desc)))
|
||||
.Concat(localeStrings.Select(x
|
||||
=> new HashEntry($"{x.Key}::params", JsonSerializer.Serialize(x.Value.Params))))
|
||||
.ToArray();
|
||||
|
||||
redisDb.HashSet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}", hashFields);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,132 +0,0 @@
|
|||
#nullable disable
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using EllieBot.Common.ModuleBehaviors;
|
||||
using EllieBot.Coordinator;
|
||||
|
||||
namespace EllieBot.Services;
|
||||
|
||||
public class RemoteGrpcCoordinator : ICoordinator, IReadyExecutor
|
||||
{
|
||||
private readonly Coordinator.Coordinator.CoordinatorClient _coordClient;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public RemoteGrpcCoordinator(IBotCredentials creds, DiscordSocketClient client)
|
||||
{
|
||||
var coordUrl = string.IsNullOrWhiteSpace(creds.CoordinatorUrl) ? "http://localhost:3442" : creds.CoordinatorUrl;
|
||||
|
||||
var channel = GrpcChannel.ForAddress(coordUrl);
|
||||
_coordClient = new(channel);
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public bool RestartBot()
|
||||
{
|
||||
_coordClient.RestartAllShards(new());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Die(bool graceful)
|
||||
=> _coordClient.Die(new()
|
||||
{
|
||||
Graceful = graceful
|
||||
});
|
||||
|
||||
public bool RestartShard(int shardId)
|
||||
{
|
||||
_coordClient.RestartShard(new()
|
||||
{
|
||||
ShardId = shardId
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public IList<ShardStatus> GetAllShardStatuses()
|
||||
{
|
||||
var res = _coordClient.GetAllStatuses(new());
|
||||
|
||||
return res.Statuses.ToArray()
|
||||
.Map(s => new ShardStatus
|
||||
{
|
||||
ConnectionState = FromCoordConnState(s.State),
|
||||
GuildCount = s.GuildCount,
|
||||
ShardId = s.ShardId,
|
||||
LastUpdate = s.LastUpdate.ToDateTime()
|
||||
});
|
||||
}
|
||||
|
||||
public int GetGuildCount()
|
||||
{
|
||||
var res = _coordClient.GetAllStatuses(new());
|
||||
|
||||
return res.Statuses.Sum(x => x.GuildCount);
|
||||
}
|
||||
|
||||
public async Task Reload()
|
||||
=> await _coordClient.ReloadAsync(new());
|
||||
|
||||
public Task OnReadyAsync()
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
var gracefulImminent = false;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reply = await _coordClient.HeartbeatAsync(new()
|
||||
{
|
||||
State = ToCoordConnState(_client.ConnectionState),
|
||||
GuildCount =
|
||||
_client.ConnectionState == ConnectionState.Connected ? _client.Guilds.Count : 0,
|
||||
ShardId = _client.ShardId
|
||||
},
|
||||
deadline: DateTime.UtcNow + TimeSpan.FromSeconds(10));
|
||||
gracefulImminent = reply.GracefulImminent;
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
if (!gracefulImminent)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Hearbeat failed and graceful shutdown was not expected: {Message}",
|
||||
ex.Message);
|
||||
break;
|
||||
}
|
||||
|
||||
Log.Information("Coordinator is restarting gracefully. Waiting...");
|
||||
await Task.Delay(30_000);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Unexpected heartbeat exception: {Message}", ex.Message);
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(7500);
|
||||
}
|
||||
|
||||
Environment.Exit(5);
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private ConnState ToCoordConnState(ConnectionState state)
|
||||
=> state switch
|
||||
{
|
||||
ConnectionState.Connecting => ConnState.Connecting,
|
||||
ConnectionState.Connected => ConnState.Connected,
|
||||
_ => ConnState.Disconnected
|
||||
};
|
||||
|
||||
private ConnectionState FromCoordConnState(ConnState state)
|
||||
=> state switch
|
||||
{
|
||||
ConnState.Connecting => ConnectionState.Connecting,
|
||||
ConnState.Connected => ConnectionState.Connected,
|
||||
_ => ConnectionState.Disconnected
|
||||
};
|
||||
}
|
Reference in a new issue