Updated services files
This commit is contained in:
parent
34eb87b13d
commit
a61b98a0f5
12 changed files with 1370 additions and 0 deletions
204
src/EllieBot/Services/Impl/BotCredsProvider.cs
Normal file
204
src/EllieBot/Services/Impl/BotCredsProvider.cs
Normal file
|
@ -0,0 +1,204 @@
|
|||
#nullable disable
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using EllieBot.Common.Yml;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
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();
|
||||
private readonly IDisposable _changeToken;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_config = new ConfigurationBuilder().AddYamlFile(CredsPath, false, true)
|
||||
.AddEnvironmentVariables("EllieBot_")
|
||||
.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.ToString());
|
||||
}
|
||||
|
||||
_changeToken = ChangeToken.OnChange(() => _config.GetReloadToken(), Reload);
|
||||
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 string OldCredsJsonPath
|
||||
=> Path.Combine(Directory.GetCurrentDirectory(), "credentials.json");
|
||||
|
||||
private string OldCredsJsonBackupPath
|
||||
=> Path.Combine(Directory.GetCurrentDirectory(), "credentials.json.bak");
|
||||
|
||||
private void MigrateCredentials()
|
||||
{
|
||||
if (File.Exists(OldCredsJsonPath))
|
||||
{
|
||||
Log.Information("Migrating old creds...");
|
||||
var jsonCredentialsFileText = File.ReadAllText(OldCredsJsonPath);
|
||||
var oldCreds = JsonConvert.DeserializeObject<OldCreds>(jsonCredentialsFileText);
|
||||
|
||||
if (oldCreds is null)
|
||||
{
|
||||
Log.Error("Error while reading old credentials file. Make sure that the file is formatted correctly");
|
||||
return;
|
||||
}
|
||||
|
||||
var creds = new Creds
|
||||
{
|
||||
Version = 1,
|
||||
Token = oldCreds.Token,
|
||||
OwnerIds = oldCreds.OwnerIds.Distinct().ToHashSet(),
|
||||
GoogleApiKey = oldCreds.GoogleApiKey,
|
||||
RapidApiKey = oldCreds.MashapeKey,
|
||||
OsuApiKey = oldCreds.OsuApiKey,
|
||||
CleverbotApiKey = oldCreds.CleverbotApiKey,
|
||||
TotalShards = oldCreds.TotalShards <= 1 ? 1 : oldCreds.TotalShards,
|
||||
Patreon = new Creds.PatreonSettings(oldCreds.PatreonAccessToken, null, null, oldCreds.PatreonCampaignId),
|
||||
Votes = new Creds.VotesSettings(oldCreds.VotesUrl, oldCreds.VotesToken, string.Empty, string.Empty),
|
||||
BotListToken = oldCreds.BotListToken,
|
||||
RedisOptions = oldCreds.RedisOptions,
|
||||
LocationIqApiKey = oldCreds.LocationIqApiKey,
|
||||
TimezoneDbApiKey = oldCreds.TimezoneDbApiKey,
|
||||
CoinmarketcapApiKey = oldCreds.CoinmarketcapApiKey
|
||||
};
|
||||
|
||||
File.Move(OldCredsJsonPath, OldCredsJsonBackupPath, true);
|
||||
File.WriteAllText(CredsPath, Yaml.Serializer.Serialize(creds));
|
||||
|
||||
Log.Warning(
|
||||
"Data from credentials.json has been moved to creds.yml\nPlease inspect your creds.yml for correctness");
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IBotCredentials GetCreds()
|
||||
{
|
||||
lock (_reloadLock)
|
||||
{
|
||||
return _creds;
|
||||
}
|
||||
}
|
||||
}
|
229
src/EllieBot/Services/Impl/GoogleApiService.cs
Normal file
229
src/EllieBot/Services/Impl/GoogleApiService.cs
Normal file
|
@ -0,0 +1,229 @@
|
|||
#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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
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;
|
||||
|
||||
}
|
||||
|
||||
}
|
83
src/EllieBot/Services/Impl/ImageCache.cs
Normal file
83
src/EllieBot/Services/Impl/ImageCache.cs
Normal file
|
@ -0,0 +1,83 @@
|
|||
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[]?> GetRategirlBgAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rategirl.Matrix);
|
||||
|
||||
public Task<byte[]?> GetRategirlDotAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rategirl.Dot);
|
||||
|
||||
public Task<byte[]?> GetDiceAsync(int num)
|
||||
=> GetImageDataAsync(_ic.Data.Dice[num]);
|
||||
|
||||
public Task<byte[]?> GetSlotEmojiAsync(int number)
|
||||
=> GetImageDataAsync(_ic.Data.Slots.Emojis[number]);
|
||||
|
||||
public Task<byte[]?> GetSlotBgAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Slots.Bg);
|
||||
|
||||
public Task<byte[]?> GetRipBgAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rip.Bg);
|
||||
|
||||
public Task<byte[]?> GetRipOverlayAsync()
|
||||
=> GetImageDataAsync(_ic.Data.Rip.Overlay);
|
||||
}
|
108
src/EllieBot/Services/Impl/LocalDataCache.cs
Normal file
108
src/EllieBot/Services/Impl/LocalDataCache.cs
Normal file
|
@ -0,0 +1,108 @@
|
|||
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);
|
||||
}
|
121
src/EllieBot/Services/Impl/Localization.cs
Normal file
121
src/EllieBot/Services/Impl/Localization.cs
Normal file
|
@ -0,0 +1,121 @@
|
|||
#nullable disable
|
||||
using EllieBot.Db;
|
||||
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;
|
||||
}
|
||||
}
|
27
src/EllieBot/Services/Impl/PubSub/JsonSeria.cs
Normal file
27
src/EllieBot/Services/Impl/PubSub/JsonSeria.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
using EllieBot.Common.JsonConverters;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EllieBot.Common;
|
||||
|
||||
public class JsonSeria : ISeria
|
||||
{
|
||||
private readonly JsonSerializerOptions _serializerOptions = new()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
57
src/EllieBot/Services/Impl/PubSub/RedisPubSub.cs
Normal file
57
src/EllieBot/Services/Impl/PubSub/RedisPubSub.cs
Normal file
|
@ -0,0 +1,57 @@
|
|||
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);
|
||||
}
|
||||
}
|
39
src/EllieBot/Services/Impl/PubSub/YamlSeria.cs
Normal file
39
src/EllieBot/Services/Impl/PubSub/YamlSeria.cs
Normal file
|
@ -0,0 +1,39 @@
|
|||
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);
|
||||
}
|
119
src/EllieBot/Services/Impl/RedisBotCache.cs
Normal file
119
src/EllieBot/Services/Impl/RedisBotCache.cs
Normal file
|
@ -0,0 +1,119 @@
|
|||
using OneOf;
|
||||
using OneOf.Types;
|
||||
using StackExchange.Redis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace 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,
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
91
src/EllieBot/Services/Impl/RedisBotStringsProvider.cs
Normal file
91
src/EllieBot/Services/Impl/RedisBotStringsProvider.cs
Normal file
|
@ -0,0 +1,91 @@
|
|||
#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);
|
||||
}
|
||||
}
|
||||
}
|
132
src/EllieBot/Services/Impl/RemoteGrpcCoordinator.cs
Normal file
132
src/EllieBot/Services/Impl/RemoteGrpcCoordinator.cs
Normal file
|
@ -0,0 +1,132 @@
|
|||
#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
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue