From a61b98a0f53e165549b036869f6e1019010b72fd Mon Sep 17 00:00:00 2001
From: Toastie <toastie@toastiet0ast.com>
Date: Tue, 18 Jun 2024 23:46:10 +1200
Subject: [PATCH] Updated services files

---
 .../Services/Impl/BotCredsProvider.cs         | 204 ++++++++++++++++
 .../Services/Impl/GoogleApiService.cs         | 229 ++++++++++++++++++
 .../GoogleApiService_SupportedLanguages.cs    | 160 ++++++++++++
 src/EllieBot/Services/Impl/ImageCache.cs      |  83 +++++++
 src/EllieBot/Services/Impl/LocalDataCache.cs  | 108 +++++++++
 src/EllieBot/Services/Impl/Localization.cs    | 121 +++++++++
 .../Services/Impl/PubSub/JsonSeria.cs         |  27 +++
 .../Services/Impl/PubSub/RedisPubSub.cs       |  57 +++++
 .../Services/Impl/PubSub/YamlSeria.cs         |  39 +++
 src/EllieBot/Services/Impl/RedisBotCache.cs   | 119 +++++++++
 .../Services/Impl/RedisBotStringsProvider.cs  |  91 +++++++
 .../Services/Impl/RemoteGrpcCoordinator.cs    | 132 ++++++++++
 12 files changed, 1370 insertions(+)
 create mode 100644 src/EllieBot/Services/Impl/BotCredsProvider.cs
 create mode 100644 src/EllieBot/Services/Impl/GoogleApiService.cs
 create mode 100644 src/EllieBot/Services/Impl/GoogleApiService_SupportedLanguages.cs
 create mode 100644 src/EllieBot/Services/Impl/ImageCache.cs
 create mode 100644 src/EllieBot/Services/Impl/LocalDataCache.cs
 create mode 100644 src/EllieBot/Services/Impl/Localization.cs
 create mode 100644 src/EllieBot/Services/Impl/PubSub/JsonSeria.cs
 create mode 100644 src/EllieBot/Services/Impl/PubSub/RedisPubSub.cs
 create mode 100644 src/EllieBot/Services/Impl/PubSub/YamlSeria.cs
 create mode 100644 src/EllieBot/Services/Impl/RedisBotCache.cs
 create mode 100644 src/EllieBot/Services/Impl/RedisBotStringsProvider.cs
 create mode 100644 src/EllieBot/Services/Impl/RemoteGrpcCoordinator.cs

diff --git a/src/EllieBot/Services/Impl/BotCredsProvider.cs b/src/EllieBot/Services/Impl/BotCredsProvider.cs
new file mode 100644
index 0000000..3d2638e
--- /dev/null
+++ b/src/EllieBot/Services/Impl/BotCredsProvider.cs
@@ -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;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Services/Impl/GoogleApiService.cs b/src/EllieBot/Services/Impl/GoogleApiService.cs
new file mode 100644
index 0000000..9d4a494
--- /dev/null
+++ b/src/EllieBot/Services/Impl/GoogleApiService.cs
@@ -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;
+    }
+}
+
diff --git a/src/EllieBot/Services/Impl/GoogleApiService_SupportedLanguages.cs b/src/EllieBot/Services/Impl/GoogleApiService_SupportedLanguages.cs
new file mode 100644
index 0000000..b4aa70a
--- /dev/null
+++ b/src/EllieBot/Services/Impl/GoogleApiService_SupportedLanguages.cs
@@ -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;
+
+    }
+
+}
\ No newline at end of file
diff --git a/src/EllieBot/Services/Impl/ImageCache.cs b/src/EllieBot/Services/Impl/ImageCache.cs
new file mode 100644
index 0000000..12ec051
--- /dev/null
+++ b/src/EllieBot/Services/Impl/ImageCache.cs
@@ -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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Services/Impl/LocalDataCache.cs b/src/EllieBot/Services/Impl/LocalDataCache.cs
new file mode 100644
index 0000000..111fd7a
--- /dev/null
+++ b/src/EllieBot/Services/Impl/LocalDataCache.cs
@@ -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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Services/Impl/Localization.cs b/src/EllieBot/Services/Impl/Localization.cs
new file mode 100644
index 0000000..3c2cb5b
--- /dev/null
+++ b/src/EllieBot/Services/Impl/Localization.cs
@@ -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;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Services/Impl/PubSub/JsonSeria.cs b/src/EllieBot/Services/Impl/PubSub/JsonSeria.cs
new file mode 100644
index 0000000..413b8f8
--- /dev/null
+++ b/src/EllieBot/Services/Impl/PubSub/JsonSeria.cs
@@ -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);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Services/Impl/PubSub/RedisPubSub.cs b/src/EllieBot/Services/Impl/PubSub/RedisPubSub.cs
new file mode 100644
index 0000000..fd4a36c
--- /dev/null
+++ b/src/EllieBot/Services/Impl/PubSub/RedisPubSub.cs
@@ -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);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Services/Impl/PubSub/YamlSeria.cs b/src/EllieBot/Services/Impl/PubSub/YamlSeria.cs
new file mode 100644
index 0000000..bedd0fa
--- /dev/null
+++ b/src/EllieBot/Services/Impl/PubSub/YamlSeria.cs
@@ -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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Services/Impl/RedisBotCache.cs b/src/EllieBot/Services/Impl/RedisBotCache.cs
new file mode 100644
index 0000000..fffc727
--- /dev/null
+++ b/src/EllieBot/Services/Impl/RedisBotCache.cs
@@ -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);
+            });
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Services/Impl/RedisBotStringsProvider.cs b/src/EllieBot/Services/Impl/RedisBotStringsProvider.cs
new file mode 100644
index 0000000..c0bef49
--- /dev/null
+++ b/src/EllieBot/Services/Impl/RedisBotStringsProvider.cs
@@ -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);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Services/Impl/RemoteGrpcCoordinator.cs b/src/EllieBot/Services/Impl/RemoteGrpcCoordinator.cs
new file mode 100644
index 0000000..de56a39
--- /dev/null
+++ b/src/EllieBot/Services/Impl/RemoteGrpcCoordinator.cs
@@ -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
+        };
+}
\ No newline at end of file