dev: Added initial version of the grpc api. Added relevant dummy settings to creds (they have no effect rn)

dev: Yt searches now INTERNALLY return multiple results but there is no way right now to paginate plain text results
dev: moved some stuff around
This commit is contained in:
Kwoth
2024-09-26 07:26:18 +00:00
parent c473669cbc
commit 3c108e531e
45 changed files with 1039 additions and 261 deletions

View File

@@ -6,27 +6,28 @@ namespace NadekoBot.Common;
public sealed class Creds : IBotCredentials
{
[Comment("""DO NOT CHANGE""")]
public int Version { get; set; }
public int Version { get; set; } = 10;
[Comment("""Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/""")]
public string Token { get; set; }
[Comment("""
List of Ids of the users who have bot owner permissions
**DO NOT ADD PEOPLE YOU DON'T TRUST**
""")]
List of Ids of the users who have bot owner permissions
**DO NOT ADD PEOPLE YOU DON'T TRUST**
""")]
public ICollection<ulong> OwnerIds { get; set; }
[Comment("Keep this on 'true' unless you're sure your bot shouldn't use privileged intents or you're waiting to be accepted")]
[Comment(
"Keep this on 'true' unless you're sure your bot shouldn't use privileged intents or you're waiting to be accepted")]
public bool UsePrivilegedIntents { get; set; }
[Comment("""
The number of shards that the bot will be running on.
Leave at 1 if you don't know what you're doing.
note: If you are planning to have more than one shard, then you must change botCache to 'redis'.
Also, in that case you should be using NadekoBot.Coordinator to start the bot, and it will correctly override this value.
""")]
The number of shards that the bot will be running on.
Leave at 1 if you don't know what you're doing.
note: If you are planning to have more than one shard, then you must change botCache to 'redis'.
Also, in that case you should be using NadekoBot.Coordinator to start the bot, and it will correctly override this value.
""")]
public int TotalShards { get; set; }
[Comment("""
@@ -37,34 +38,34 @@ public sealed class Creds : IBotCredentials
For example '@Bot how's the weather in Paris' will return the current weather in Paris as if you were to run `.weather Paris` command.
""")]
public string NadekoAiToken { get; set; }
[Comment(
[Comment(
"""
Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it.
Then, go to APIs and Services -> Credentials and click Create credentials -> API key.
Used only for Youtube Data Api (at the moment).
""")]
Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it.
Then, go to APIs and Services -> Credentials and click Create credentials -> API key.
Used only for Youtube Data Api (at the moment).
""")]
public string GoogleApiKey { get; set; }
[Comment(
[Comment(
"""
Create a new custom search here https://programmablesearchengine.google.com/cse/create/new
Enable SafeSearch
Remove all Sites to Search
Enable Search the entire web
Copy the 'Search Engine ID' to the SearchId field
Do all steps again but enable image search for the ImageSearchId
""")]
Create a new custom search here https://programmablesearchengine.google.com/cse/create/new
Enable SafeSearch
Remove all Sites to Search
Enable Search the entire web
Copy the 'Search Engine ID' to the SearchId field
Do all steps again but enable image search for the ImageSearchId
""")]
public GoogleApiConfig Google { get; set; }
[Comment("""Settings for voting system for discordbots. Meant for use on global Nadeko.""")]
public VotesSettings Votes { get; set; }
[Comment("""
Patreon auto reward system settings.
go to https://www.patreon.com/portal -> my clients -> create client
""")]
Patreon auto reward system settings.
go to https://www.patreon.com/portal -> my clients -> create client
""")]
public PatreonSettings Patreon { get; set; }
[Comment("""Api key for sending stats to DiscordBotList.""")]
@@ -75,27 +76,27 @@ public sealed class Creds : IBotCredentials
[Comment(@"OpenAi api key.")]
public string Gpt3ApiKey { get; set; }
[Comment("""
Which cache implementation should bot use.
'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset.
'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml
""")]
Which cache implementation should bot use.
'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset.
'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml
""")]
public BotCacheImplemenation BotCache { get; set; }
[Comment("""
Redis connection string. Don't change if you don't know what you're doing.
Only used if botCache is set to 'redis'
""")]
Redis connection string. Don't change if you don't know what you're doing.
Only used if botCache is set to 'redis'
""")]
public string RedisOptions { get; set; }
[Comment("""Database options. Don't change if you don't know what you're doing. Leave null for default values""")]
public DbOptions Db { get; set; }
[Comment("""
Address and port of the coordinator endpoint. Leave empty for default.
Change only if you've changed the coordinator address or port.
""")]
Address and port of the coordinator endpoint. Leave empty for default.
Change only if you've changed the coordinator address or port.
""")]
public string CoordinatorUrl { get; set; }
[Comment(
@@ -103,23 +104,23 @@ public sealed class Creds : IBotCredentials
public string RapidApiKey { get; set; }
[Comment("""
https://locationiq.com api key (register and you will receive the token in the email).
Used only for .time command.
""")]
https://locationiq.com api key (register and you will receive the token in the email).
Used only for .time command.
""")]
public string LocationIqApiKey { get; set; }
[Comment("""
https://timezonedb.com api key (register and you will receive the token in the email).
Used only for .time command
""")]
https://timezonedb.com api key (register and you will receive the token in the email).
Used only for .time command
""")]
public string TimezoneDbApiKey { get; set; }
[Comment("""
https://pro.coinmarketcap.com/account/ api key. There is a free plan for personal use.
Used for cryptocurrency related commands.
""")]
https://pro.coinmarketcap.com/account/ api key. There is a free plan for personal use.
Used for cryptocurrency related commands.
""")]
public string CoinmarketcapApiKey { get; set; }
// [Comment(@"https://polygon.io/dashboard/api-keys api key. Free plan allows for 5 queries per minute.
// Used for stocks related commands.")]
// public string PolygonIoApiKey { get; set; }
@@ -128,9 +129,9 @@ public sealed class Creds : IBotCredentials
public string OsuApiKey { get; set; }
[Comment("""
Optional Trovo client id.
You should use this if Trovo stream notifications stopped working or you're getting ratelimit errors.
""")]
Optional Trovo client id.
You should use this if Trovo stream notifications stopped working or you're getting ratelimit errors.
""")]
public string TrovoClientId { get; set; }
[Comment("""Obtain by creating an application at https://dev.twitch.tv/console/apps""")]
@@ -140,23 +141,30 @@ public sealed class Creds : IBotCredentials
public string TwitchClientSecret { get; set; }
[Comment("""
Command and args which will be used to restart the bot.
Only used if bot is executed directly (NOT through the coordinator)
placeholders:
{0} -> shard id
{1} -> total shards
Linux default
cmd: dotnet
args: "NadekoBot.dll -- {0}"
Windows default
cmd: NadekoBot.exe
args: "{0}"
""")]
Command and args which will be used to restart the bot.
Only used if bot is executed directly (NOT through the coordinator)
placeholders:
{0} -> shard id
{1} -> total shards
Linux default
cmd: dotnet
args: "NadekoBot.dll -- {0}"
Windows default
cmd: NadekoBot.exe
args: "{0}"
""")]
public RestartConfig RestartCommand { get; set; }
[Comment("""
Settings for the grpc api.
We don't provide support for this.
If you leave certPath empty, the api will run on http.
""")]
public ApiConfig Api { get; set; }
public Creds()
{
Version = 9;
Token = string.Empty;
UsePrivilegedIntents = true;
OwnerIds = new List<ulong>();
@@ -179,24 +187,26 @@ public sealed class Creds : IBotCredentials
RestartCommand = new RestartConfig();
Google = new GoogleApiConfig();
Api = new ApiConfig();
}
public class DbOptions
: IDbOptions
{
[Comment("""
Database type. "sqlite", "mysql" and "postgresql" are supported.
Default is "sqlite"
""")]
Database type. "sqlite", "mysql" and "postgresql" are supported.
Default is "sqlite"
""")]
public string Type { get; set; }
[Comment("""
Database connection string.
You MUST change this if you're not using "sqlite" type.
Default is "Data Source=data/NadekoBot.db"
Example for mysql: "Server=localhost;Port=3306;Uid=root;Pwd=my_super_secret_mysql_password;Database=nadeko"
Example for postgresql: "Server=localhost;Port=5432;User Id=postgres;Password=my_super_secret_postgres_password;Database=nadeko;"
""")]
Database connection string.
You MUST change this if you're not using "sqlite" type.
Default is "Data Source=data/NadekoBot.db"
Example for mysql: "Server=localhost;Port=3306;Uid=root;Pwd=my_super_secret_mysql_password;Database=nadeko"
Example for postgresql: "Server=localhost;Port=5432;User Id=postgres;Password=my_super_secret_postgres_password;Database=nadeko;"
""")]
public string ConnectionString { get; set; }
}
@@ -231,29 +241,29 @@ public sealed class Creds : IBotCredentials
public sealed record VotesSettings : IVotesSettings
{
[Comment("""
top.gg votes service url
This is the url of your instance of the NadekoBot.Votes api
Example: https://votes.my.cool.bot.com
""")]
top.gg votes service url
This is the url of your instance of the NadekoBot.Votes api
Example: https://votes.my.cool.bot.com
""")]
public string TopggServiceUrl { get; set; }
[Comment("""
Authorization header value sent to the TopGG service url with each request
This should be equivalent to the TopggKey in your NadekoBot.Votes api appsettings.json file
""")]
Authorization header value sent to the TopGG service url with each request
This should be equivalent to the TopggKey in your NadekoBot.Votes api appsettings.json file
""")]
public string TopggKey { get; set; }
[Comment("""
discords.com votes service url
This is the url of your instance of the NadekoBot.Votes api
Example: https://votes.my.cool.bot.com
""")]
discords.com votes service url
This is the url of your instance of the NadekoBot.Votes api
Example: https://votes.my.cool.bot.com
""")]
public string DiscordsServiceUrl { get; set; }
[Comment("""
Authorization header value sent to the Discords service url with each request
This should be equivalent to the DiscordsKey in your NadekoBot.Votes api appsettings.json file
""")]
Authorization header value sent to the Discords service url with each request
This should be equivalent to the DiscordsKey in your NadekoBot.Votes api appsettings.json file
""")]
public string DiscordsKey { get; set; }
public VotesSettings()
@@ -272,13 +282,19 @@ public sealed class Creds : IBotCredentials
DiscordsKey = discordsKey;
}
}
public sealed record ApiConfig
{
public bool Enabled { get; set; } = false;
public string CertPath { get; set; } = string.Empty;
public string CertPassword { get; set; } = string.Empty;
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 43120;
}
}
public class GoogleApiConfig : IGoogleApiConfig
{
public string SearchId { get; init; }
public string ImageSearchId { get; init; }
}
}

View File

@@ -0,0 +1,158 @@
#nullable disable
using Microsoft.Extensions.Configuration;
using NadekoBot.Common.Yml;
namespace NadekoBot.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 'NadekoBot_'. Example is in {CredsExamplePath}",
CredsPath,
CredsExamplePath);
}
_config = new ConfigurationBuilder().AddYamlFile(CredsPath, false, true)
.AddEnvironmentVariables("NadekoBot_")
.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 = "NadekoBot.dll -- {0}"
};
}
else
{
_creds.RestartCommand = new RestartConfig()
{
Args = "NadekoBot.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 <= 9)
{
creds.Version = 10;
File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds));
}
}
}
public IBotCredentials GetCreds()
{
lock (_reloadLock)
{
return _creds;
}
}
}

View 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 NadekoBot.Services;
public sealed partial class GoogleApiService : IGoogleApiService, INService
{
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 = "Nadeko 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<IReadOnlyList<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).ToArray();
}
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;
}
}

View File

@@ -0,0 +1,160 @@
namespace NadekoBot.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;
}
}

View File

@@ -0,0 +1,71 @@
namespace NadekoBot.Services;
public sealed class ImageCache : IImageCache, INService
{
private readonly IBotCache _cache;
private readonly ImagesConfig _ic;
private readonly Random _rng;
private readonly IHttpClientFactory _httpFactory;
public ImageCache(
IBotCache cache,
ImagesConfig ic,
IHttpClientFactory httpFactory)
{
_cache = cache;
_ic = ic;
_httpFactory = httpFactory;
_rng = new NadekoRandom();
}
private 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);
}

View File

@@ -0,0 +1,108 @@
using NadekoBot.Common.Pokemon;
using NadekoBot.Modules.Games.Common.Trivia;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace NadekoBot.Services;
public sealed class LocalDataCache : ILocalDataCache, INService
{
private const string POKEMON_ABILITIES_FILE = "data/pokemon/pokemon_abilities.json";
private const string POKEMON_LIST_FILE = "data/pokemon/pokemon_list.json";
private const string POKEMON_MAP_PATH = "data/pokemon/name-id_map.json";
private const string QUESTIONS_FILE = "data/trivia_questions.json";
private readonly IBotCache _cache;
private readonly JsonSerializerOptions _opts = new JsonSerializerOptions()
{
AllowTrailingCommas = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
PropertyNameCaseInsensitive = true
};
public LocalDataCache(IBotCache cache)
=> _cache = cache;
private async Task<T?> GetOrCreateCachedDataAsync<T>(
TypedKey<T> key,
string fileName)
=> await _cache.GetOrAddAsync(key,
async () =>
{
if (!File.Exists(fileName))
{
Log.Warning($"{fileName} is missing. Relevant data can't be loaded");
return default;
}
try
{
await using var stream = File.OpenRead(fileName);
return await JsonSerializer.DeserializeAsync<T>(stream, _opts);
}
catch (Exception ex)
{
Log.Error(ex,
"Error reading {FileName} file: {ErrorMessage}",
fileName,
ex.Message);
return default;
}
});
private static TypedKey<IReadOnlyDictionary<string, SearchPokemon>> _pokemonListKey
= new("pokemon:list");
public async Task<IReadOnlyDictionary<string, SearchPokemon>?> GetPokemonsAsync()
=> await GetOrCreateCachedDataAsync(_pokemonListKey, POKEMON_LIST_FILE);
private static TypedKey<IReadOnlyDictionary<string, SearchPokemonAbility>> _pokemonAbilitiesKey
= new("pokemon:abilities");
public async Task<IReadOnlyDictionary<string, SearchPokemonAbility>?> GetPokemonAbilitiesAsync()
=> await GetOrCreateCachedDataAsync(_pokemonAbilitiesKey, POKEMON_ABILITIES_FILE);
private static TypedKey<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);
}

View File

@@ -0,0 +1,120 @@
#nullable disable
using Newtonsoft.Json;
using System.Globalization;
namespace NadekoBot.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;
}
}

View File

@@ -0,0 +1,28 @@
using NadekoBot.Common.JsonConverters;
using System.Text.Json;
namespace NadekoBot.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);
}
}

View File

@@ -0,0 +1,57 @@
using StackExchange.Redis;
namespace NadekoBot.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);
}
}

View File

@@ -0,0 +1,39 @@
using NadekoBot.Common.Configs;
using NadekoBot.Common.Yml;
using System.Text.RegularExpressions;
using YamlDotNet.Serialization;
namespace NadekoBot.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);
}

View File

@@ -0,0 +1,120 @@
using OneOf;
using OneOf.Types;
using StackExchange.Redis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace NadekoBot.Common;
public sealed class RedisBotCache : IBotCache
{
private static readonly Type[] _supportedTypes =
[
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);
});
}
}

View File

@@ -0,0 +1,91 @@
#nullable disable
using StackExchange.Redis;
using System.Text.Json;
using System.Web;
namespace NadekoBot.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);
}
}
}

View File

@@ -0,0 +1,132 @@
#nullable disable
using Grpc.Core;
using Grpc.Net.Client;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Coordinator;
namespace NadekoBot.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
};
}

View File

@@ -6,7 +6,7 @@ public interface IGoogleApiService
{
IReadOnlyDictionary<string, string> Languages { get; }
Task<IEnumerable<string>> GetVideoLinksByKeywordAsync(string keywords, int count = 1);
Task<IReadOnlyList<string>> GetVideoLinksByKeywordAsync(string keywords, int count = 1);
Task<IEnumerable<(string Name, string Id, string Url, string Thumbnail)>> GetVideoInfosByKeywordAsync(string keywords, int count = 1);
Task<IEnumerable<string>> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1);
Task<IEnumerable<string>> GetRelatedVideosAsync(string id, int count = 1, string user = null);

View File

@@ -10,10 +10,9 @@ public class YtdlOperation
private readonly string _baseArgString;
private readonly bool _isYtDlp;
public YtdlOperation(string baseArgString, bool isYtDlp = false)
public YtdlOperation(string baseArgString)
{
_baseArgString = baseArgString;
_isYtDlp = isYtDlp;
}
private Process CreateProcess(string[] args)
@@ -23,7 +22,7 @@ public class YtdlOperation
{
StartInfo = new()
{
FileName = _isYtDlp ? "yt-dlp" : "youtube-dl",
FileName = "yt-dlp",
Arguments = string.Format(_baseArgString, newArgs),
UseShellExecute = false,
RedirectStandardError = true,
@@ -47,18 +46,18 @@ public class YtdlOperation
var str = await process.StandardOutput.ReadToEndAsync();
var err = await process.StandardError.ReadToEndAsync();
if (!string.IsNullOrEmpty(err))
Log.Warning("YTDL warning: {YtdlWarning}", err);
Log.Warning("yt-dlp warning: {YtdlWarning}", err);
return str;
}
catch (Win32Exception)
{
Log.Error("youtube-dl is likely not installed. Please install it before running the command again");
Log.Error("yt-dlp is likely not installed. Please install it before running the command again");
return default;
}
catch (Exception ex)
{
Log.Error(ex, "Exception running youtube-dl: {ErrorMessage}", ex.Message);
Log.Error(ex, "Exception running yt-dlp: {ErrorMessage}", ex.Message);
return default;
}
}