diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs b/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs new file mode 100644 index 0000000..c47eed7 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs @@ -0,0 +1,41 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class AnimeResult +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("airing_status")] + public string AiringStatusParsed { get; set; } + + [JsonPropertyName("title_english")] + public string TitleEnglish { get; set; } + + [JsonPropertyName("total_episodes")] + public int TotalEpisodes { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("image_url_lge")] + public string ImageUrlLarge { get; set; } + + [JsonPropertyName("genres")] + public string[] Genres { get; set; } + + [JsonPropertyName("average_score")] + public float AverageScore { get; set; } + + + public string AiringStatus + => AiringStatusParsed.ToTitleCase(); + + public string Link + => "http://anilist.co/anime/" + Id; + + public string Synopsis + => Description?[..(Description.Length > 500 ? 500 : Description.Length)] + "..."; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs b/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs new file mode 100644 index 0000000..4e95d39 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs @@ -0,0 +1,77 @@ +#nullable disable +using AngleSharp; +using AngleSharp.Html.Dom; +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class AnimeSearchCommands : EllieModule + { + [Cmd] + public async Task Anime([Leftover] string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + + var animeData = await _service.GetAnimeData(query); + + if (animeData is null) + { + await Response().Error(strs.failed_finding_anime).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(animeData.Synopsis.Replace("
", + Environment.NewLine, + StringComparison.InvariantCulture)) + .WithTitle(animeData.TitleEnglish) + .WithUrl(animeData.Link) + .WithImageUrl(animeData.ImageUrlLarge) + .AddField(GetText(strs.episodes), animeData.TotalEpisodes.ToString(), true) + .AddField(GetText(strs.status), animeData.AiringStatus, true) + .AddField(GetText(strs.genres), + string.Join(",\n", animeData.Genres.Any() ? animeData.Genres : ["none"]), + true) + .WithFooter($"{GetText(strs.score)} {animeData.AverageScore} / 100"); + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Manga([Leftover] string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + + var mangaData = await _service.GetMangaData(query); + + if (mangaData is null) + { + await Response().Error(strs.failed_finding_manga).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(mangaData.Synopsis.Replace("
", + Environment.NewLine, + StringComparison.InvariantCulture)) + .WithTitle(mangaData.TitleEnglish) + .WithUrl(mangaData.Link) + .WithImageUrl(mangaData.ImageUrlLge) + .AddField(GetText(strs.chapters), mangaData.TotalChapters.ToString(), true) + .AddField(GetText(strs.status), mangaData.PublishingStatus, true) + .AddField(GetText(strs.genres), + string.Join(",\n", mangaData.Genres.Any() ? mangaData.Genres : ["none"]), + true) + .WithFooter($"{GetText(strs.score)} {mangaData.AverageScore} / 100"); + + await Response().Embed(embed).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs b/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs new file mode 100644 index 0000000..4cf1b01 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs @@ -0,0 +1,79 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using System.Net.Http.Json; + +namespace EllieBot.Modules.Searches.Services; + +public class AnimeSearchService : IEService +{ + private readonly IBotCache _cache; + private readonly IHttpClientFactory _httpFactory; + + public AnimeSearchService(IBotCache cache, IHttpClientFactory httpFactory) + { + _cache = cache; + _httpFactory = httpFactory; + } + + public async Task GetAnimeData(string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentNullException(nameof(query)); + + TypedKey GetKey(string link) + => new TypedKey($"anime2:{link}"); + + try + { + var suffix = Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture)); + var link = $"https://aniapi.nadeko.bot/anime/{suffix}"; + link = link.ToLowerInvariant(); + var result = await _cache.GetAsync(GetKey(link)); + if (!result.TryPickT0(out var data, out _)) + { + using var http = _httpFactory.CreateClient(); + data = await http.GetFromJsonAsync(link); + + await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(12)); + } + + return data; + } + catch + { + return null; + } + } + + public async Task GetMangaData(string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentNullException(nameof(query)); + + TypedKey GetKey(string link) + => new TypedKey($"manga2:{link}"); + + try + { + var link = "https://aniapi.nadeko.bot/manga/" + + Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture)); + link = link.ToLowerInvariant(); + + var result = await _cache.GetAsync(GetKey(link)); + if (!result.TryPickT0(out var data, out _)) + { + using var http = _httpFactory.CreateClient(); + data = await http.GetFromJsonAsync(link); + + await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(3)); + } + + + return data; + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Anime/MangaResult.cs b/src/EllieBot/Modules/Searches/Anime/MangaResult.cs new file mode 100644 index 0000000..9a32703 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/MangaResult.cs @@ -0,0 +1,40 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class MangaResult +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("publishing_status")] + public string PublishingStatus { get; set; } + + [JsonPropertyName("image_url_lge")] + public string ImageUrlLge { get; set; } + + [JsonPropertyName("title_english")] + public string TitleEnglish { get; set; } + + [JsonPropertyName("total_chapters")] + public int TotalChapters { get; set; } + + [JsonPropertyName("total_volumes")] + public int TotalVolumes { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("genres")] + public string[] Genres { get; set; } + + [JsonPropertyName("average_score")] + public float AverageScore { get; set; } + + public string Link + => "http://anilist.co/manga/" + Id; + + public string Synopsis + => Description?[..(Description.Length > 500 ? 500 : Description.Length)] + "..."; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs b/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs new file mode 100644 index 0000000..b5dabb3 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs @@ -0,0 +1,231 @@ +#nullable disable +using EllieBot.Modules.Searches.Services; +using System.Globalization; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + public partial class FinanceCommands : EllieModule + { + private readonly IStockDataService _stocksService; + private readonly IStockChartDrawingService _stockDrawingService; + + public FinanceCommands(IStockDataService stocksService, IStockChartDrawingService stockDrawingService) + { + _stocksService = stocksService; + _stockDrawingService = stockDrawingService; + } + + [Cmd] + public async Task Stock([Leftover] string query) + { + using var typing = ctx.Channel.EnterTypingState(); + + var stock = await _stocksService.GetStockDataAsync(query); + + if (stock is null) + { + var symbols = await _stocksService.SearchSymbolAsync(query); + + if (symbols.Count == 0) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + var symbol = symbols.First(); + var promptEmbed = _sender.CreateEmbed() + .WithDescription(symbol.Description) + .WithTitle(GetText(strs.did_you_mean(symbol.Symbol))); + + if (!await PromptUserConfirmAsync(promptEmbed)) + return; + + query = symbol.Symbol; + stock = await _stocksService.GetStockDataAsync(query); + + if (stock is null) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + } + + var candles = await _stocksService.GetCandleDataAsync(query); + var stockImageTask = _stockDrawingService.GenerateCombinedChartAsync(candles); + + var localCulture = (CultureInfo)Culture.Clone(); + localCulture.NumberFormat.CurrencySymbol = "$"; + + var sign = stock.Price >= stock.Close + ? "\\🔼" + : "\\🔻"; + + var change = (stock.Price - stock.Close).ToString("N2", Culture); + var changePercent = (1 - (stock.Close / stock.Price)).ToString("P1", Culture); + + var price = stock.Price.ToString("C2", localCulture); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(stock.Symbol) + .WithUrl($"https://www.tradingview.com/chart/?symbol={stock.Symbol}") + .WithTitle(stock.Name) + .AddField(GetText(strs.price), $"{sign} **{price}**", true) + .AddField(GetText(strs.market_cap), stock.MarketCap, true) + .AddField(GetText(strs.volume_24h), stock.DailyVolume.ToString("C0", localCulture), true) + .AddField("Change", $"{change} ({changePercent})", true) + // .AddField("Change 50d", $"{sign50}{change50}", true) + // .AddField("Change 200d", $"{sign200}{change200}", true) + .WithFooter(stock.Exchange); + + var message = await Response().Embed(eb).SendAsync(); + await using var imageData = await stockImageTask; + if (imageData is null) + return; + + var fileName = $"{query}-sparkline.{imageData.Extension}"; + using var attachment = new FileAttachment( + imageData.FileData, + fileName + ); + await message.ModifyAsync(mp => + { + mp.Attachments = + new(new[] { attachment }); + + mp.Embed = eb.WithImageUrl($"attachment://{fileName}").Build(); + }); + } + + + [Cmd] + public async Task Crypto(string name) + { + name = name?.ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(name)) + return; + + var (crypto, nearest) = await _service.GetCryptoData(name); + + if (nearest is not null) + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.crypto_not_found)) + .WithDescription( + GetText(strs.did_you_mean(Format.Bold($"{nearest.Name} ({nearest.Symbol})")))); + + if (await PromptUserConfirmAsync(embed)) + crypto = nearest; + } + + if (crypto is null) + { + await Response().Error(strs.crypto_not_found).SendAsync(); + return; + } + + var usd = crypto.Quote["USD"]; + + var localCulture = (CultureInfo)Culture.Clone(); + localCulture.NumberFormat.CurrencySymbol = "$"; + + var sevenDay = (usd.PercentChange7d / 100).ToString("P2", localCulture); + var lastDay = (usd.PercentChange24h / 100).ToString("P2", localCulture); + var price = usd.Price < 0.01 + ? usd.Price.ToString(localCulture) + : usd.Price.ToString("C2", localCulture); + + var volume = usd.Volume24h.ToString("C0", localCulture); + var marketCap = usd.MarketCap.ToString("C0", localCulture); + var dominance = (usd.MarketCapDominance / 100).ToString("P2", localCulture); + + await using var sparkline = await _service.GetSparklineAsync(crypto.Id, usd.PercentChange7d >= 0); + var fileName = $"{crypto.Slug}_7d.png"; + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor($"#{crypto.CmcRank}") + .WithTitle($"{crypto.Name} ({crypto.Symbol})") + .WithUrl($"https://coinmarketcap.com/currencies/{crypto.Slug}/") + .WithThumbnailUrl( + $"https://s3.coinmarketcap.com/static/img/coins/128x128/{crypto.Id}.png") + .AddField(GetText(strs.market_cap), marketCap, true) + .AddField(GetText(strs.price), price, true) + .AddField(GetText(strs.volume_24h), volume, true) + .AddField(GetText(strs.change_7d_24h), $"{sevenDay} / {lastDay}", true) + .AddField(GetText(strs.market_cap_dominance), dominance, true) + .WithImageUrl($"attachment://{fileName}"); + + if (crypto.CirculatingSupply is double cs) + { + var csStr = cs.ToString("N0", localCulture); + + if (crypto.MaxSupply is double ms) + { + var perc = (cs / ms).ToString("P1", localCulture); + + toSend.AddField(GetText(strs.circulating_supply), $"{csStr} ({perc})", true); + } + else + { + toSend.AddField(GetText(strs.circulating_supply), csStr, true); + } + } + + + await ctx.Channel.SendFileAsync(sparkline, fileName, embed: toSend.Build()); + } + + [Cmd] + public async Task Coins(int page = 1) + { + if (--page < 0) + return; + + if (page > 25) + page = 25; + + await Response() + .Paginated() + .PageItems(async (page) => + { + var coins = await _service.GetTopCoins(page); + return coins; + }) + .PageSize(10) + .Page((items, _) => + { + var embed = _sender.CreateEmbed() + .WithOkColor(); + + if (items.Count > 0) + { + foreach (var coin in items) + { + embed.AddField($"#{coin.MarketCapRank} {coin.Symbol} - {coin.Name}", + $""" + `Price:` {GetArrowEmoji(coin.PercentChange24h)} {coin.CurrentPrice.ToShortString()}$ ({GetSign(coin.PercentChange24h)}{Math.Round(coin.PercentChange24h, 2)}%) + `MarketCap:` {coin.MarketCap.ToShortString()}$ + `Supply:` {(coin.CirculatingSupply?.ToShortString() ?? "?")} / {(coin.TotalSupply?.ToShortString() ?? "?")} + """, + inline: false); + } + } + + return embed; + }) + .CurrentPage(page) + .AddFooter(false) + .SendAsync(); + } + + private static string GetArrowEmoji(decimal value) + => value > 0 ? "▲" : "▼"; + + private static string GetSign(decimal value) + => value >= 0 ? "+" : ""; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs b/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs new file mode 100644 index 0000000..0ffd422 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs @@ -0,0 +1,266 @@ +#nullable enable +using EllieBot.Modules.Searches.Common; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Globalization; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Xml; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Searches.Services; + +public class CryptoService : IEService +{ + private readonly IBotCache _cache; + private readonly IHttpClientFactory _httpFactory; + private readonly IBotCredentials _creds; + + private readonly SemaphoreSlim _getCryptoLock = new(1, 1); + + public CryptoService(IBotCache cache, IHttpClientFactory httpFactory, IBotCredentials creds) + { + _cache = cache; + _httpFactory = httpFactory; + _creds = creds; + } + + private PointF[] GetSparklinePointsFromSvgText(string svgText) + { + var xml = new XmlDocument(); + xml.LoadXml(svgText); + + var gElement = xml["svg"]?["g"]; + if (gElement is null) + return Array.Empty(); + + Span points = new PointF[gElement.ChildNodes.Count]; + var cnt = 0; + + bool GetValuesFromAttributes( + XmlAttributeCollection attrs, + out float x1, + out float y1, + out float x2, + out float y2) + { + (x1, y1, x2, y2) = (0, 0, 0, 0); + return attrs["x1"]?.Value is string x1Str + && float.TryParse(x1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x1) + && attrs["y1"]?.Value is string y1Str + && float.TryParse(y1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y1) + && attrs["x2"]?.Value is string x2Str + && float.TryParse(x2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x2) + && attrs["y2"]?.Value is string y2Str + && float.TryParse(y2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y2); + } + + foreach (XmlElement x in gElement.ChildNodes) + { + if (x.Name != "line") + continue; + + if (GetValuesFromAttributes(x.Attributes, out var x1, out var y1, out var x2, out var y2)) + { + points[cnt++] = new(x1, y1); + // this point will be set twice to the same value + // on all points except the last one + if (cnt + 1 < points.Length) + points[cnt + 1] = new(x2, y2); + } + } + + if (cnt == 0) + return Array.Empty(); + + return points.Slice(0, cnt).ToArray(); + } + + private SixLabors.ImageSharp.Image GenerateSparklineChart(PointF[] points, bool up) + { + const int width = 164; + const int height = 48; + + var img = new Image(width, height, Color.Transparent); + var color = up + ? Color.Green + : Color.FromRgb(220, 0, 0); + + img.Mutate(x => + { + x.DrawLine(color, 2, points); + }); + + return img; + } + + public async Task<(CmcResponseData? Data, CmcResponseData? Nearest)> GetCryptoData(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return (null, null); + + name = name.ToUpperInvariant(); + var cryptos = await GetCryptoDataInternal(); + + if (cryptos is null or { Count: 0 }) + return (null, null); + + var crypto = cryptos.FirstOrDefault(x + => x.Slug.ToUpperInvariant() == name + || x.Name.ToUpperInvariant() == name + || x.Symbol.ToUpperInvariant() == name); + + if (crypto is not null) + return (crypto, null); + + + var nearest = cryptos + .Select(elem => (Elem: elem, + Distance: elem.Name.ToUpperInvariant().LevenshteinDistance(name))) + .OrderBy(x => x.Distance) + .FirstOrDefault(x => x.Distance <= 2); + + return (null, nearest.Elem); + } + + public async Task?> GetCryptoDataInternal() + { + await _getCryptoLock.WaitAsync(); + try + { + var data = await _cache.GetOrAddAsync(new("ellie:crypto_data"), + async () => + { + try + { + using var http = _httpFactory.CreateClient(); + var data = await http.GetFromJsonAsync( + "https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?" + + $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}" + + "&start=1" + + "&limit=5000" + + "&convert=USD"); + + return data; + } + catch (Exception ex) + { + Log.Error(ex, "Error getting crypto data: {Message}", ex.Message); + return default; + } + }, + TimeSpan.FromHours(2)); + + if (data is null) + return default; + + return data.Data; + } + catch (Exception ex) + { + Log.Error(ex, "Error retreiving crypto data: {Message}", ex.Message); + return default; + } + finally + { + _getCryptoLock.Release(); + } + } + + private TypedKey GetSparklineKey(int id) + => new($"crypto:sparkline:{id}"); + + public async Task GetSparklineAsync(int id, bool up) + { + try + { + var bytes = await _cache.GetOrAddAsync(GetSparklineKey(id), + async () => + { + // if it fails, generate a new one + var points = await DownloadSparklinePointsAsync(id); + var sparkline = GenerateSparklineChart(points, up); + + using var stream = await sparkline.ToStreamAsync(); + return stream.ToArray(); + }, + TimeSpan.FromHours(1)); + + if (bytes is { Length: > 0 }) + { + return bytes.ToStream(); + } + + return default; + } + catch (Exception ex) + { + Log.Warning(ex, + "Exception occurred while downloading sparkline points: {ErrorMessage}", + ex.Message); + return default; + } + } + + private async Task DownloadSparklinePointsAsync(int id) + { + using var http = _httpFactory.CreateClient(); + var str = await http.GetStringAsync( + $"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg"); + var points = GetSparklinePointsFromSvgText(str); + return points; + } + + private static TypedKey> GetTopCoinsKey() + => new($"crypto:top_coins"); + + public async Task?> GetTopCoins(int page) + { + if (page >= 25) + page = 24; + + using var http = _httpFactory.CreateClient(); + + http.AddFakeHeaders(); + + var result = await _cache.GetOrAddAsync>(GetTopCoinsKey(), + async () => await http.GetFromJsonAsync>( + "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250") + ?? [], + expiry: TimeSpan.FromHours(1)); + + return result!.Skip(page * 10).Take(10).ToList(); + } +} + +public sealed class GeckoCoinsResult +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("symbol")] + public required string Symbol { get; init; } + + [JsonPropertyName("current_price")] + public required decimal CurrentPrice { get; init; } + + [JsonPropertyName("price_change_percentage_24h")] + public required decimal PercentChange24h { get; init; } + + [JsonPropertyName("market_cap")] + public required decimal MarketCap { get; init; } + + [JsonPropertyName("circulating_supply")] + public required decimal? CirculatingSupply { get; init; } + + [JsonPropertyName("total_supply")] + public required decimal? TotalSupply { get; init; } + + [JsonPropertyName("market_cap_rank")] + public required int MarketCapRank { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs b/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs new file mode 100644 index 0000000..9b000bd --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs @@ -0,0 +1,112 @@ +using AngleSharp; +using CsvHelper; +using CsvHelper.Configuration; +using System.Globalization; +using System.Text.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class DefaultStockDataService : IStockDataService, IEService +{ + private readonly IHttpClientFactory _httpClientFactory; + + public DefaultStockDataService(IHttpClientFactory httpClientFactory) + => _httpClientFactory = httpClientFactory; + + public async Task GetStockDataAsync(string query) + { + try + { + if (!query.IsAlphaNumeric()) + return default; + + using var http = _httpClientFactory.CreateClient(); + + var quoteHtmlPage = $"https://finance.yahoo.com/quote/{query.ToUpperInvariant()}"; + + var config = Configuration.Default.WithDefaultLoader(); + using var document = await BrowsingContext.New(config).OpenAsync(quoteHtmlPage); + + var tickerName = document.QuerySelector("div.top > .left > .container > h1") + ?.TextContent; + + if (tickerName is null) + return default; + + var marketcap = document + .QuerySelector("li > span > fin-streamer[data-field='marketCap']") + ?.TextContent; + + + var volume = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketVolume']") + ?.TextContent; + + var close = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketPreviousClose']") + ?.TextContent + ?? "0"; + + var price = document.QuerySelector("fin-streamer.livePrice > span") + ?.TextContent + ?? "0"; + + return new() + { + Name = tickerName, + Symbol = query, + Price = double.Parse(price, NumberStyles.Any, CultureInfo.InvariantCulture), + Close = double.Parse(close, NumberStyles.Any, CultureInfo.InvariantCulture), + MarketCap = marketcap, + DailyVolume = (long)double.Parse(volume ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture), + }; + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting stock data: {ErrorMessage}", ex.ToString()); + return default; + } + } + + public async Task> SearchSymbolAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentNullException(nameof(query)); + + query = Uri.EscapeDataString(query); + + using var http = _httpClientFactory.CreateClient(); + + var res = await http.GetStringAsync( + "https://finance.yahoo.com/_finance_doubledown/api/resource/searchassist" + + $";searchTerm={query}" + + "?device=console"); + + var data = JsonSerializer.Deserialize(res); + + if (data is null or { Items: null }) + return Array.Empty(); + + return data.Items + .Where(x => x.Type == "S") + .Select(x => new SymbolData(x.Symbol, x.Name)) + .ToList(); + } + + private static CsvConfiguration _csvConfig = new(CultureInfo.InvariantCulture); + + public async Task> GetCandleDataAsync(string query) + { + using var http = _httpClientFactory.CreateClient(); + await using var resStream = await http.GetStreamAsync( + $"https://query1.finance.yahoo.com/v7/finance/download/{query}" + + $"?period1={DateTime.UtcNow.Subtract(30.Days()).ToTimestamp()}" + + $"&period2={DateTime.UtcNow.ToTimestamp()}" + + "&interval=1d"); + + using var textReader = new StreamReader(resStream); + using var csv = new CsvReader(textReader, _csvConfig); + var records = csv.GetRecords().ToArray(); + + return records + .Map(static x => new CandleData(x.Open, x.Close, x.High, x.Low, x.Volume)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs new file mode 100644 index 0000000..97da2da --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs @@ -0,0 +1,12 @@ +using SixLabors.ImageSharp; + +namespace EllieBot.Modules.Searches; + +/// +/// All data required to draw a candle +/// +/// Whether the candle is green +/// Rectangle for the body +/// High line point +/// Low line point +public record CandleDrawingData(bool IsGreen, RectangleF BodyRect, PointF High, PointF Low); \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs new file mode 100644 index 0000000..9676e88 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public interface IStockChartDrawingService +{ + Task GenerateSparklineAsync(IReadOnlyCollection series); + Task GenerateCombinedChartAsync(IReadOnlyCollection series); + Task GenerateCandleChartAsync(IReadOnlyCollection series); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs new file mode 100644 index 0000000..95aa51f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs @@ -0,0 +1,200 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Runtime.CompilerServices; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Searches; + +public sealed class ImagesharpStockChartDrawingService : IStockChartDrawingService, IEService +{ + private const int WIDTH = 300; + private const int HEIGHT = 100; + private const decimal MAX_HEIGHT = HEIGHT * 0.8m; + + private static readonly Rgba32 _backgroundColor = Rgba32.ParseHex("17181E"); + private static readonly Rgba32 _lineGuideColor = Rgba32.ParseHex("212125"); + private static readonly Rgba32 _sparklineColor = Rgba32.ParseHex("2961FC"); + private static readonly Rgba32 _greenBrush = Rgba32.ParseHex("26A69A"); + private static readonly Rgba32 _redBrush = Rgba32.ParseHex("EF5350"); + + private static float GetNormalizedPoint(decimal max, decimal point, decimal range) + => (float)((MAX_HEIGHT * ((max - point) / range)) + HeightOffset()); + + private PointF[] GetSparklinePointsInternal(IReadOnlyCollection series) + { + var candleStep = WIDTH / (series.Count + 1); + var max = series.Max(static x => x.High); + var min = series.Min(static x => x.Low); + + var range = max - min; + + var points = new PointF[series.Count]; + + var i = 0; + foreach (var candle in series) + { + var x = candleStep * (i + 1); + + var y = GetNormalizedPoint(max, candle.Close, range); + points[i++] = new(x, y); + } + + return points; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static decimal HeightOffset() + => (HEIGHT - MAX_HEIGHT) / 2m; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Image CreateCanvasInternal() + => new Image(WIDTH, HEIGHT, _backgroundColor); + + private CandleDrawingData[] GetChartDrawingDataInternal(IReadOnlyCollection series) + { + var candleMargin = 2; + var candleStep = (WIDTH - (candleMargin * series.Count)) / (series.Count + 1); + var max = series.Max(static x => x.High); + var min = series.Min(static x => x.Low); + + var range = max - min; + + var drawData = new CandleDrawingData[series.Count]; + + var candleWidth = candleStep; + + var i = 0; + foreach (var candle in series) + { + var offsetX = (i - 1) * candleMargin; + var x = (candleStep * (i + 1)) + offsetX; + var yOpen = GetNormalizedPoint(max, candle.Open, range); + var yClose = GetNormalizedPoint(max, candle.Close, range); + var y = candle.Open > candle.Close + ? yOpen + : yClose; + + var sizeH = Math.Abs(yOpen - yClose); + + var high = GetNormalizedPoint(max, candle.High, range); + var low = GetNormalizedPoint(max, candle.Low, range); + drawData[i] = new(candle.Open < candle.Close, + new(x, y, candleWidth, sizeH), + new(x + (candleStep / 2), high), + new(x + (candleStep / 2), low)); + ++i; + } + + return drawData; + } + + private void DrawChartData(Image image, CandleDrawingData[] drawData) + => image.Mutate(ctx => + { + foreach (var data in drawData) + ctx.DrawLine(data.IsGreen + ? _greenBrush + : _redBrush, + 1, + data.High, + data.Low); + + + foreach (var data in drawData) + ctx.Fill(data.IsGreen + ? _greenBrush + : _redBrush, + data.BodyRect); + }); + + private void DrawLineGuides(Image image, IReadOnlyCollection series) + { + var max = series.Max(x => x.High); + var min = series.Min(x => x.Low); + + var step = (max - min) / 5; + + var lines = new float[6]; + + for (var i = 0; i < 6; i++) + { + var y = GetNormalizedPoint(max, min + (step * i), max - min); + lines[i] = y; + } + + image.Mutate(ctx => + { + // draw guides + foreach (var y in lines) + ctx.DrawLine(_lineGuideColor, 1, new PointF(0, y), new PointF(WIDTH, y)); + + // // draw min and max price on the chart + // ctx.DrawText(min.ToString(CultureInfo.InvariantCulture), + // SystemFonts.CreateFont("Arial", 5), + // Color.White, + // new PointF(0, (float)HeightOffset() - 5) + // ); + // + // ctx.DrawText(max.ToString("N1", CultureInfo.InvariantCulture), + // SystemFonts.CreateFont("Arial", 5), + // Color.White, + // new PointF(0, HEIGHT - (float)HeightOffset()) + // ); + }); + } + + public Task GenerateSparklineAsync(IReadOnlyCollection series) + { + if (series.Count == 0) + return Task.FromResult(default); + + using var image = CreateCanvasInternal(); + + var points = GetSparklinePointsInternal(series); + + image.Mutate(ctx => + { + ctx.DrawLine(_sparklineColor, 2, points); + }); + + return Task.FromResult(new("png", image.ToStream())); + } + + public Task GenerateCombinedChartAsync(IReadOnlyCollection series) + { + if (series.Count == 0) + return Task.FromResult(default); + + using var image = CreateCanvasInternal(); + + DrawLineGuides(image, series); + + var chartData = GetChartDrawingDataInternal(series); + DrawChartData(image, chartData); + + var points = GetSparklinePointsInternal(series); + image.Mutate(ctx => + { + ctx.DrawLine(Color.ParseHex("00FFFFAA"), 1, points); + }); + + return Task.FromResult(new("png", image.ToStream())); + } + + public Task GenerateCandleChartAsync(IReadOnlyCollection series) + { + if (series.Count == 0) + return Task.FromResult(default); + + using var image = CreateCanvasInternal(); + + DrawLineGuides(image, series); + + var drawData = GetChartDrawingDataInternal(series); + DrawChartData(image, drawData); + + return Task.FromResult(new("png", image.ToStream())); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs b/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs new file mode 100644 index 0000000..5f778e8 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public interface IStockDataService +{ + public Task GetStockDataAsync(string symbol); + Task> SearchSymbolAsync(string query); + Task> GetCandleDataAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs new file mode 100644 index 0000000..97d1a17 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public record CandleData( + decimal Open, + decimal Close, + decimal High, + decimal Low, + long Volume); \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs new file mode 100644 index 0000000..d49d1ba --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public record ImageData(string Extension, Stream FileData) : IAsyncDisposable +{ + public ValueTask DisposeAsync() + => FileData.DisposeAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs b/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs new file mode 100644 index 0000000..13ea277 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs @@ -0,0 +1,43 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class QuoteResponse +{ + public class ResultModel + { + [JsonPropertyName("longName")] + public string LongName { get; set; } + + [JsonPropertyName("regularMarketPrice")] + public double RegularMarketPrice { get; set; } + + [JsonPropertyName("regularMarketPreviousClose")] + public double RegularMarketPreviousClose { get; set; } + + [JsonPropertyName("fullExchangeName")] + public string FullExchangeName { get; set; } + + [JsonPropertyName("averageDailyVolume10Day")] + public int AverageDailyVolume10Day { get; set; } + + [JsonPropertyName("fiftyDayAverageChangePercent")] + public double FiftyDayAverageChangePercent { get; set; } + + [JsonPropertyName("twoHundredDayAverageChangePercent")] + public double TwoHundredDayAverageChangePercent { get; set; } + + [JsonPropertyName("marketCap")] + public long MarketCap { get; set; } + + [JsonPropertyName("symbol")] + public string Symbol { get; set; } + } + + [JsonPropertyName("result")] + public List Result { get; set; } + + [JsonPropertyName("error")] + public object Error { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs new file mode 100644 index 0000000..dfb99c7 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public class StockData +{ + public string Name { get; set; } + public string Symbol { get; set; } + public double Price { get; set; } + public string MarketCap { get; set; } + public double Close { get; set; } + public double Change50d { get; set; } + public double Change200d { get; set; } + public long DailyVolume { get; set; } + public string Exchange { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs new file mode 100644 index 0000000..01ef65d --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs @@ -0,0 +1,3 @@ +namespace EllieBot.Modules.Searches; + +public record SymbolData(string Symbol, string Description); \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs new file mode 100644 index 0000000..619bdc3 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Modules.Searches; + +public class YahooFinanceCandleData +{ + public DateTime Date { get; set; } + public decimal Open { get; set; } + public decimal High { get; set; } + public decimal Low { get; set; } + public decimal Close { get; set; } + public decimal AdjClose { get; set; } + public long Volume { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs new file mode 100644 index 0000000..168dd82 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs @@ -0,0 +1,19 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class YahooFinanceSearchResponse +{ + [JsonPropertyName("suggestionTitleAccessor")] + public string SuggestionTitleAccessor { get; set; } + + [JsonPropertyName("suggestionMeta")] + public List SuggestionMeta { get; set; } + + [JsonPropertyName("hiConf")] + public bool HiConf { get; set; } + + [JsonPropertyName("items")] + public List Items { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs new file mode 100644 index 0000000..e8eaa9f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs @@ -0,0 +1,25 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class YahooFinanceSearchResponseItem +{ + [JsonPropertyName("symbol")] + public string Symbol { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("exch")] + public string Exch { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("exchDisp")] + public string ExchDisp { get; set; } + + [JsonPropertyName("typeDisp")] + public string TypeDisp { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs new file mode 100644 index 0000000..4efc94f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class YahooQueryModel +{ + [JsonPropertyName("quoteResponse")] + public QuoteResponse QuoteResponse { get; set; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs b/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs new file mode 100644 index 0000000..316fc73 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs @@ -0,0 +1,148 @@ +using CodeHollow.FeedReader; +using EllieBot.Modules.Searches.Services; +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class FeedCommands : EllieModule + { + private static readonly Regex _ytChannelRegex = + new(@"youtube\.com\/(?:c\/|channel\/|user\/)?(?[a-zA-Z0-9\-_]{1,})"); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public Task YtUploadNotif(string url, [Leftover] string? message = null) + => YtUploadNotif(url, null, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(2)] + public Task YtUploadNotif(string url, ITextChannel? channel = null, [Leftover] string? message = null) + { + var m = _ytChannelRegex.Match(url); + if (!m.Success) + return Response().Error(strs.invalid_input).SendAsync(); + + channel ??= ctx.Channel as ITextChannel; + + if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone) + message = message?.SanitizeAllMentions(); + + var channelId = m.Groups["channelid"].Value; + + return Feed($"https://www.youtube.com/feeds/videos.xml?channel_id={channelId}", channel, message); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Feed(string url, [Leftover] string? message = null) + => Feed(url, null, message); + + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Feed(string url, ITextChannel? channel = null, [Leftover] string? message = null) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + await Response().Error(strs.feed_invalid_url).SendAsync(); + return; + } + + channel ??= (ITextChannel)ctx.Channel; + + if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone) + message = message?.SanitizeAllMentions(); + + try + { + await FeedReader.ReadAsync(url); + } + catch (Exception ex) + { + Log.Information(ex, "Unable to get feeds from that url"); + await Response().Error(strs.feed_cant_parse).SendAsync(); + return; + } + + if (ctx.User is not IGuildUser gu || !gu.GuildPermissions.Administrator) + message = message?.SanitizeMentions(true); + + var result = _service.AddFeed(ctx.Guild.Id, channel.Id, url, message); + if (result == FeedAddResult.Success) + { + await Response().Confirm(strs.feed_added).SendAsync(); + return; + } + + if (result == FeedAddResult.Duplicate) + { + await Response().Error(strs.feed_duplicate).SendAsync(); + return; + } + + if (result == FeedAddResult.LimitReached) + { + await Response().Error(strs.feed_limit_reached).SendAsync(); + return; + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task FeedRemove(int index) + { + if (_service.RemoveFeed(ctx.Guild.Id, --index)) + await Response().Confirm(strs.feed_removed).SendAsync(); + else + await Response().Error(strs.feed_out_of_range).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task FeedList(int page = 1) + { + if (--page < 0) + return; + + var feeds = _service.GetFeeds(ctx.Guild.Id); + + if (!feeds.Any()) + { + await Response() + .Embed(_sender.CreateEmbed().WithOkColor().WithDescription(GetText(strs.feed_no_feed))) + .SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(feeds) + .PageSize(10) + .CurrentPage(page) + .Page((items, cur) => + { + var embed = _sender.CreateEmbed().WithOkColor(); + var i = 0; + var fs = string.Join("\n", + items.Select(x => $"`{(cur * 10) + ++i}.` <#{x.ChannelId}> {x.Url}")); + + return embed.WithDescription(fs); + }) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs b/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs new file mode 100644 index 0000000..d08ec0a --- /dev/null +++ b/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs @@ -0,0 +1,309 @@ +#nullable disable +using CodeHollow.FeedReader; +using CodeHollow.FeedReader.Feeds; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Searches.Services; + +public class FeedsService : IEService +{ + private readonly DbService _db; + private readonly ConcurrentDictionary> _subs; + private readonly DiscordSocketClient _client; + private readonly IMessageSenderService _sender; + + private readonly ConcurrentDictionary _lastPosts = new(); + private readonly Dictionary _errorCounters = new(); + + public FeedsService( + IBot bot, + DbService db, + DiscordSocketClient client, + IMessageSenderService sender) + { + _db = db; + + using (var uow = db.GetDbContext()) + { + var guildConfigIds = bot.AllGuildConfigs.Select(x => x.Id).ToList(); + _subs = uow.Set() + .AsQueryable() + .Where(x => guildConfigIds.Contains(x.Id)) + .Include(x => x.FeedSubs) + .ToList() + .SelectMany(x => x.FeedSubs) + .GroupBy(x => x.Url.ToLower()) + .ToDictionary(x => x.Key, x => x.ToList()) + .ToConcurrent(); + } + + _client = client; + _sender = sender; + + _ = Task.Run(TrackFeeds); + } + + private void ClearErrors(string url) + => _errorCounters.Remove(url); + + private async Task AddError(string url, List ids) + { + try + { + var newValue = _errorCounters[url] = _errorCounters.GetValueOrDefault(url) + 1; + + if (newValue >= 100) + { + // remove from db + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .DeleteAsync(x => ids.Contains(x.Id)); + + // remove from the local cache + _subs.TryRemove(url, out _); + + // reset the error counter + ClearErrors(url); + } + + return newValue; + } + catch (Exception ex) + { + Log.Error(ex, "Error adding rss errors..."); + return 0; + } + } + + private DateTime? GetPubDate(FeedItem item) + { + if (item.PublishingDate is not null) + return item.PublishingDate; + if (item.SpecificItem is AtomFeedItem atomItem) + return atomItem.UpdatedDate; + return null; + } + + public async Task TrackFeeds() + { + while (true) + { + var allSendTasks = new List(_subs.Count); + foreach (var kvp in _subs) + { + if (kvp.Value.Count == 0) + continue; + + var rssUrl = kvp.Value.First().Url; + try + { + var feed = await FeedReader.ReadAsync(rssUrl); + + var items = new List<(FeedItem Item, DateTime LastUpdate)>(); + foreach (var item in feed.Items) + { + var pubDate = GetPubDate(item); + + if (pubDate is null) + continue; + + items.Add((item, pubDate.Value.ToUniversalTime())); + + // show at most 3 items if you're behind + if (items.Count > 2) + break; + } + + if (items.Count == 0) + continue; + + if (!_lastPosts.TryGetValue(kvp.Key, out var lastFeedUpdate)) + { + lastFeedUpdate = _lastPosts[kvp.Key] = items[0].LastUpdate; + } + + for (var index = 1; index <= items.Count; index++) + { + var (feedItem, itemUpdateDate) = items[^index]; + if (itemUpdateDate <= lastFeedUpdate) + continue; + + var embed = _sender.CreateEmbed().WithFooter(rssUrl); + + _lastPosts[kvp.Key] = itemUpdateDate; + + var link = feedItem.SpecificItem.Link; + if (!string.IsNullOrWhiteSpace(link) && Uri.IsWellFormedUriString(link, UriKind.Absolute)) + embed.WithUrl(link); + + var title = string.IsNullOrWhiteSpace(feedItem.Title) ? "-" : feedItem.Title; + + var gotImage = false; + if (feedItem.SpecificItem is MediaRssFeedItem mrfi + && (mrfi.Enclosure?.MediaType?.StartsWith("image/") ?? false)) + { + var imgUrl = mrfi.Enclosure.Url; + if (!string.IsNullOrWhiteSpace(imgUrl) + && Uri.IsWellFormedUriString(imgUrl, UriKind.Absolute)) + { + embed.WithImageUrl(imgUrl); + gotImage = true; + } + } + + if (!gotImage && feedItem.SpecificItem is AtomFeedItem afi) + { + var previewElement = afi.Element.Elements() + .FirstOrDefault(x => x.Name.LocalName == "preview"); + + if (previewElement is null) + { + previewElement = afi.Element.Elements() + .FirstOrDefault(x => x.Name.LocalName == "thumbnail"); + } + + if (previewElement is not null) + { + var urlAttribute = previewElement.Attribute("url"); + if (urlAttribute is not null + && !string.IsNullOrWhiteSpace(urlAttribute.Value) + && Uri.IsWellFormedUriString(urlAttribute.Value, UriKind.Absolute)) + { + embed.WithImageUrl(urlAttribute.Value); + gotImage = true; + } + } + } + + embed.WithTitle(title.TrimTo(256)); + + var desc = feedItem.Description?.StripHtml(); + if (!string.IsNullOrWhiteSpace(feedItem.Description)) + embed.WithDescription(desc.TrimTo(2048)); + + + var tasks = new List(); + + foreach (var val in kvp.Value) + { + var ch = _client.GetGuild(val.GuildConfig.GuildId).GetTextChannel(val.ChannelId); + + if (ch is null) + continue; + + var sendTask = _sender.Response(ch) + .Embed(embed) + .Text(string.IsNullOrWhiteSpace(val.Message) + ? string.Empty + : val.Message) + .SendAsync(); + tasks.Add(sendTask); + } + + allSendTasks.Add(tasks.WhenAll()); + + // as data retrieval was successful, reset error counter + ClearErrors(rssUrl); + } + } + catch (Exception ex) + { + var errorCount = await AddError(rssUrl, kvp.Value.Select(x => x.Id).ToList()); + + Log.Warning("An error occured while getting rss stream ({ErrorCount} / 100) {RssFeed}" + + "\n {Message}", + errorCount, + rssUrl, + $"[{ex.GetType().Name}]: {ex.Message}"); + } + } + + await Task.WhenAll(Task.WhenAll(allSendTasks), Task.Delay(30000)); + } + } + + public List GetFeeds(ulong guildId) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs)) + .FeedSubs.OrderBy(x => x.Id) + .ToList(); + } + + public FeedAddResult AddFeed( + ulong guildId, + ulong channelId, + string rssFeed, + string message) + { + ArgumentNullException.ThrowIfNull(rssFeed, nameof(rssFeed)); + + var fs = new FeedSub + { + ChannelId = channelId, + Url = rssFeed.Trim(), + Message = message + }; + + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs)); + + if (gc.FeedSubs.Any(x => x.Url.ToLower() == fs.Url.ToLower())) + return FeedAddResult.Duplicate; + if (gc.FeedSubs.Count >= 10) + return FeedAddResult.LimitReached; + + gc.FeedSubs.Add(fs); + uow.SaveChanges(); + //adding all, in case bot wasn't on this guild when it started + foreach (var feed in gc.FeedSubs) + { + _subs.AddOrUpdate(feed.Url.ToLower(), + [feed], + (_, old) => + { + old.Add(feed); + return old; + }); + } + + return FeedAddResult.Success; + } + + public bool RemoveFeed(ulong guildId, int index) + { + if (index < 0) + return false; + + using var uow = _db.GetDbContext(); + var items = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs)) + .FeedSubs.OrderBy(x => x.Id) + .ToList(); + + if (items.Count <= index) + return false; + var toRemove = items[index]; + _subs.AddOrUpdate(toRemove.Url.ToLower(), + [], + (_, old) => + { + old.Remove(toRemove); + return old; + }); + uow.Remove(toRemove); + uow.SaveChanges(); + + return true; + } +} + +public enum FeedAddResult +{ + Success, + LimitReached, + Invalid, + Duplicate, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/JokeCommands.cs b/src/EllieBot/Modules/Searches/JokeCommands.cs new file mode 100644 index 0000000..d41c50a --- /dev/null +++ b/src/EllieBot/Modules/Searches/JokeCommands.cs @@ -0,0 +1,53 @@ +#nullable disable +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class JokeCommands : EllieModule + { + [Cmd] + public async Task Yomama() + => await Response().Confirm(await _service.GetYomamaJoke()).SendAsync(); + + [Cmd] + public async Task Randjoke() + { + var (setup, punchline) = await _service.GetRandomJoke(); + await Response().Confirm(setup, punchline).SendAsync(); + } + + [Cmd] + public async Task ChuckNorris() + => await Response().Confirm(await _service.GetChuckNorrisJoke()).SendAsync(); + + [Cmd] + public async Task WowJoke() + { + if (!_service.WowJokes.Any()) + { + await Response().Error(strs.jokes_not_loaded).SendAsync(); + return; + } + + var joke = _service.WowJokes[new EllieRandom().Next(0, _service.WowJokes.Count)]; + await Response().Confirm(joke.Question, joke.Answer).SendAsync(); + } + + [Cmd] + public async Task MagicItem() + { + if (!_service.MagicItems.Any()) + { + await Response().Error(strs.magicitems_not_loaded).SendAsync(); + return; + } + + var item = _service.MagicItems[new EllieRandom().Next(0, _service.MagicItems.Count)]; + + await Response().Confirm("✨" + item.Name, item.Description).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Osu/OsuCommands.cs b/src/EllieBot/Modules/Searches/Osu/OsuCommands.cs new file mode 100644 index 0000000..f122c33 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Osu/OsuCommands.cs @@ -0,0 +1,124 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using Newtonsoft.Json; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class OsuCommands : EllieModule + { + private readonly IBotCredentials _creds; + private readonly IHttpClientFactory _httpFactory; + + public OsuCommands(IBotCredentials creds, IHttpClientFactory factory) + { + _creds = creds; + _httpFactory = factory; + } + + [Cmd] + public async Task Osu(string user, [Leftover] string mode = null) + { + if (string.IsNullOrWhiteSpace(user)) + return; + + try + { + if (string.IsNullOrWhiteSpace(_creds.OsuApiKey)) + { + await Response().Error(strs.osu_api_key).SendAsync(); + return; + } + + var obj = await _service.GetOsuData(user, mode); + if (obj is null) + { + await Response().Error(strs.osu_user_not_found).SendAsync(); + return; + } + + var userId = obj.UserId; + var smode = OsuService.ResolveGameMode(obj.ModeNumber); + + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle($"osu! {smode} profile for {user}") + .WithThumbnailUrl($"https://a.ppy.sh/{userId}") + .WithDescription($"https://osu.ppy.sh/u/{userId}") + .AddField("Official Rank", $"#{obj.PpRank}", true) + .AddField("Country Rank", + $"#{obj.PpCountryRank} :flag_{obj.Country.ToLower()}:", + true) + .AddField("Total PP", Math.Round(obj.PpRaw, 2), true) + .AddField("Accuracy", Math.Round(obj.Accuracy, 2) + "%", true) + .AddField("Playcount", obj.Playcount, true) + .AddField("Level", Math.Round(obj.Level), true)) + .SendAsync(); + } + catch (Exception ex) + { + await Response().Error(strs.osu_failed).SendAsync(); + Log.Warning(ex, "Osu command failed"); + } + } + + [Cmd] + public async Task Gatari(string user, [Leftover] string mode = null) + { + var modeNumber = OsuService.ResolveGameMode(mode); + var modeStr = OsuService.ResolveGameMode(modeNumber); + var (userData, userStats) = await _service.GetGatariDataAsync(user, mode); + if (userStats is null) + { + await Response().Error(strs.osu_user_not_found).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle($"osu!Gatari {modeStr} profile for {user}") + .WithThumbnailUrl($"https://a.gatari.pw/{userStats.Id}") + .WithDescription($"https://osu.gatari.pw/u/{userStats.Id}") + .AddField("Official Rank", $"#{userStats.Rank}", true) + .AddField("Country Rank", + $"#{userStats.CountryRank} :flag_{userData.Country.ToLower()}:", + true) + .AddField("Total PP", userStats.Pp, true) + .AddField("Accuracy", $"{Math.Round(userStats.AvgAccuracy, 2)}%", true) + .AddField("Playcount", userStats.Playcount, true) + .AddField("Level", userStats.Level, true); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Osu5(string user, [Leftover] string mode = null) + { + if (string.IsNullOrWhiteSpace(_creds.OsuApiKey)) + { + await Response().Error("An osu! API key is required.").SendAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(user)) + { + await Response().Error("Please provide a username.").SendAsync(); + return; + } + + var plays = await _service.GetOsuPlay(user, mode); + + + var eb = _sender.CreateEmbed().WithOkColor().WithTitle($"Top 5 plays for {user}"); + + foreach(var (title, desc) in plays) + eb.AddField(title, desc); + + await Response().Embed(eb).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Osu/OsuService.cs b/src/EllieBot/Modules/Searches/Osu/OsuService.cs new file mode 100644 index 0000000..18327c9 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Osu/OsuService.cs @@ -0,0 +1,227 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using Newtonsoft.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class OsuService : IEService +{ + private readonly IHttpClientFactory _httpFactory; + private readonly IBotCredentials _creds; + + public OsuService(IHttpClientFactory httpFactory, IBotCredentials creds) + { + _httpFactory = httpFactory; + _creds = creds; + } + + public async Task GetOsuData(string username, string mode) + { + using var http = _httpFactory.CreateClient(); + + var modeNumber = string.IsNullOrWhiteSpace(mode) ? 0 : ResolveGameMode(mode); + var userReq = $"https://osu.ppy.sh/api/get_user?k={_creds.OsuApiKey}&u={username}&m={modeNumber}"; + var userResString = await http.GetStringAsync(userReq); + + if (string.IsNullOrWhiteSpace(userResString)) + return null; + var objs = JsonConvert.DeserializeObject>(userResString); + + if (objs.Count == 0) + { + return null; + } + + var obj = objs[0]; + + obj.ModeNumber = modeNumber; + return obj; + } + + public static int ResolveGameMode(string mode) + { + switch (mode?.ToUpperInvariant()) + { + case "STD": + case "STANDARD": + return 0; + case "TAIKO": + return 1; + case "CTB": + case "CATCHTHEBEAT": + return 2; + case "MANIA": + case "OSU!MANIA": + return 3; + default: + return 0; + } + } + + public static string ResolveGameMode(int mode) + { + switch (mode) + { + case 0: + return "Standard"; + case 1: + return "Taiko"; + case 2: + return "Catch"; + case 3: + return "Mania"; + default: + return "Standard"; + } + } + + public async Task<(GatariUserData userData, GatariUserStats userStats)> GetGatariDataAsync( + string user, + string mode) + { + using var http = _httpFactory.CreateClient(); + var modeNumber = string.IsNullOrWhiteSpace(mode) ? 0 : ResolveGameMode(mode); + + var resString = await http.GetStringAsync($"https://api.gatari.pw/user/stats?u={user}&mode={modeNumber}"); + + var statsResponse = JsonConvert.DeserializeObject(resString); + if (statsResponse.Code != 200 || statsResponse.Stats.Id == 0) + { + return default; + } + + var usrResString = await http.GetStringAsync($"https://api.gatari.pw/users/get?u={user}"); + + var userData = JsonConvert.DeserializeObject(usrResString).Users[0]; + var userStats = statsResponse.Stats; + + return (userData, userStats); + } + + public async Task<(string title, string desc)[]> GetOsuPlay(string user, string mode) + { + using var http = _httpFactory.CreateClient(); + var m = 0; + if (!string.IsNullOrWhiteSpace(mode)) + m = OsuService.ResolveGameMode(mode); + + var reqString = "https://osu.ppy.sh/api/get_user_best" + + $"?k={_creds.OsuApiKey}" + + $"&u={Uri.EscapeDataString(user)}" + + "&type=string" + + "&limit=5" + + $"&m={m}"; + + var resString = await http.GetStringAsync(reqString); + var obj = JsonConvert.DeserializeObject>(resString); + + var mapTasks = obj.Select(async item => + { + var mapReqString = "https://osu.ppy.sh/api/get_beatmaps" + + $"?k={_creds.OsuApiKey}" + + $"&b={item.BeatmapId}"; + + var mapResString = await http.GetStringAsync(mapReqString); + var map = JsonConvert.DeserializeObject>(mapResString).FirstOrDefault(); + if (map is null) + return default; + var pp = Math.Round(item.Pp, 2); + var acc = CalculateAcc(item, m); + var mods = ResolveMods(item.EnabledMods); + + var title = $"{map.Artist}-{map.Title} ({map.Version})"; + var desc = $@"[/b/{item.BeatmapId}](https://osu.ppy.sh/b/{item.BeatmapId}) + {pp + "pp",-7} | {acc + "%",-7} + "; + if (mods != "+") + desc += Format.Bold(mods); + + return (title, desc); + }); + + return await Task.WhenAll(mapTasks); + } + + //https://osu.ppy.sh/wiki/Accuracy + private static double CalculateAcc(OsuUserBests play, int mode) + { + double hitPoints; + double totalHits; + if (mode == 0) + { + hitPoints = (play.Count50 * 50) + (play.Count100 * 100) + (play.Count300 * 300); + totalHits = play.Count50 + play.Count100 + play.Count300 + play.Countmiss; + totalHits *= 300; + } + else if (mode == 1) + { + hitPoints = (play.Countmiss * 0) + (play.Count100 * 0.5) + play.Count300; + totalHits = (play.Countmiss + play.Count100 + play.Count300) * 300; + hitPoints *= 300; + } + else if (mode == 2) + { + hitPoints = play.Count50 + play.Count100 + play.Count300; + totalHits = play.Countmiss + play.Count50 + play.Count100 + play.Count300 + play.Countkatu; + } + else + { + hitPoints = (play.Count50 * 50) + + (play.Count100 * 100) + + (play.Countkatu * 200) + + ((play.Count300 + play.Countgeki) * 300); + + totalHits = (play.Countmiss + + play.Count50 + + play.Count100 + + play.Countkatu + + play.Count300 + + play.Countgeki) + * 300; + } + + + return Math.Round(hitPoints / totalHits * 100, 2); + } + + + //https://github.com/ppy/osu-api/wiki#mods + private static string ResolveMods(int mods) + { + var modString = "+"; + + if (IsBitSet(mods, 0)) + modString += "NF"; + if (IsBitSet(mods, 1)) + modString += "EZ"; + if (IsBitSet(mods, 8)) + modString += "HT"; + + if (IsBitSet(mods, 3)) + modString += "HD"; + if (IsBitSet(mods, 4)) + modString += "HR"; + if (IsBitSet(mods, 6) && !IsBitSet(mods, 9)) + modString += "DT"; + if (IsBitSet(mods, 9)) + modString += "NC"; + if (IsBitSet(mods, 10)) + modString += "FL"; + + if (IsBitSet(mods, 5)) + modString += "SD"; + if (IsBitSet(mods, 14)) + modString += "PF"; + + if (IsBitSet(mods, 7)) + modString += "RX"; + if (IsBitSet(mods, 11)) + modString += "AT"; + if (IsBitSet(mods, 12)) + modString += "SO"; + return modString; + } + + private static bool IsBitSet(int mods, int pos) + => (mods & (1 << pos)) != 0; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs b/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs new file mode 100644 index 0000000..6250e87 --- /dev/null +++ b/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs @@ -0,0 +1,74 @@ +#nullable disable +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class PokemonSearchCommands : EllieModule + { + private readonly ILocalDataCache _cache; + + public PokemonSearchCommands(ILocalDataCache cache) + => _cache = cache; + + [Cmd] + public async Task Pokemon([Leftover] string pokemon = null) + { + pokemon = pokemon?.Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(pokemon)) + return; + + foreach (var kvp in await _cache.GetPokemonsAsync()) + { + if (kvp.Key.ToUpperInvariant() == pokemon.ToUpperInvariant()) + { + var p = kvp.Value; + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(kvp.Key.ToTitleCase()) + .WithDescription(p.BaseStats.ToString()) + .WithThumbnailUrl( + $"https://assets.pokemon.com/assets/cms2/img/pokedex/detail/{p.Id.ToString("000")}.png") + .AddField(GetText(strs.types), string.Join("\n", p.Types), true) + .AddField(GetText(strs.height_weight), + GetText(strs.height_weight_val(p.HeightM, p.WeightKg)), + true) + .AddField(GetText(strs.abilities), + string.Join("\n", p.Abilities.Select(a => a.Value)), + true)).SendAsync(); + return; + } + } + + await Response().Error(strs.pokemon_none).SendAsync(); + } + + [Cmd] + public async Task PokemonAbility([Leftover] string ability = null) + { + ability = ability?.Trim().ToUpperInvariant().Replace(" ", "", StringComparison.InvariantCulture); + if (string.IsNullOrWhiteSpace(ability)) + return; + foreach (var kvp in await _cache.GetPokemonAbilitiesAsync()) + { + if (kvp.Key.ToUpperInvariant() == ability) + { + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(kvp.Value.Name) + .WithDescription(string.IsNullOrWhiteSpace(kvp.Value.Desc) + ? kvp.Value.ShortDesc + : kvp.Value.Desc) + .AddField(GetText(strs.rating), + kvp.Value.Rating.ToString(Culture), + true)).SendAsync(); + return; + } + } + + await Response().Error(strs.pokemon_ability_none).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Religious/Common/BibleVerse.cs b/src/EllieBot/Modules/Searches/Religious/Common/BibleVerse.cs new file mode 100644 index 0000000..77b74f5 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Religious/Common/BibleVerse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class BibleVerse +{ + [JsonPropertyName("book_name")] + public required string BookName { get; set; } + + public required int Chapter { get; set; } + public required int Verse { get; set; } + public required string Text { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Religious/Common/BibleVerses.cs b/src/EllieBot/Modules/Searches/Religious/Common/BibleVerses.cs new file mode 100644 index 0000000..4e7b66d --- /dev/null +++ b/src/EllieBot/Modules/Searches/Religious/Common/BibleVerses.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public class BibleVerses +{ + public string? Error { get; set; } + public BibleVerse[]? Verses { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Religious/Common/QuranAyah.cs b/src/EllieBot/Modules/Searches/Religious/Common/QuranAyah.cs new file mode 100644 index 0000000..6880737 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Religious/Common/QuranAyah.cs @@ -0,0 +1,19 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class QuranAyah +{ + [JsonPropertyName("number")] + public int Number { get; set; } + + [JsonPropertyName("audio")] + public string Audio { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("text")] + public string Text { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Religious/Common/QuranResponse.cs b/src/EllieBot/Modules/Searches/Religious/Common/QuranResponse.cs new file mode 100644 index 0000000..86bee95 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Religious/Common/QuranResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class QuranResponse +{ + [JsonPropertyName("code")] + public required int Code { get; set; } + + [JsonPropertyName("status")] + public required string Status { get; set; } + + [JsonPropertyName("data")] + public required T[] Data { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Religious/ReligiousApiService.cs b/src/EllieBot/Modules/Searches/Religious/ReligiousApiService.cs new file mode 100644 index 0000000..ccef2b8 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Religious/ReligiousApiService.cs @@ -0,0 +1,62 @@ +using OneOf; +using OneOf.Types; +using System.Net; +using System.Net.Http.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class ReligiousApiService : IEService +{ + private readonly IHttpClientFactory _httpFactory; + + public ReligiousApiService(IHttpClientFactory httpFactory) + { + _httpFactory = httpFactory; + } + + public async Task>> GetBibleVerseAsync(string book, string chapterAndVerse) + { + if (string.IsNullOrWhiteSpace(book) || string.IsNullOrWhiteSpace(chapterAndVerse)) + return new Error("Invalid input."); + + + book = Uri.EscapeDataString(book); + chapterAndVerse = Uri.EscapeDataString(chapterAndVerse); + + using var http = _httpFactory.CreateClient(); + try + { + var res = await http.GetFromJsonAsync($"https://bible-api.com/{book} {chapterAndVerse}"); + + if (res is null || res.Error is not null || res.Verses is null || res.Verses.Length == 0) + { + return new Error(res?.Error ?? "No verse found."); + } + + return res.Verses[0]; + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return new Error("No verse found."); + } + } + + public async Task, Error>> GetQuranVerseAsync(string ayah) + { + if (string.IsNullOrWhiteSpace(ayah)) + return new Error(strs.invalid_input); + + ayah = Uri.EscapeDataString(ayah); + + using var http = _httpFactory.CreateClient(); + var res = await http.GetFromJsonAsync>( + $"https://api.alquran.cloud/v1/ayah/{ayah}/editions/en.asad,ar.alafasy"); + + if (res is null or not { Code: 200 }) + { + return new Error(strs.not_found); + } + + return res; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Religious/ReligiousCommands.cs b/src/EllieBot/Modules/Searches/Religious/ReligiousCommands.cs new file mode 100644 index 0000000..074cff5 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Religious/ReligiousCommands.cs @@ -0,0 +1,60 @@ +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + public partial class ReligiousCommands : EllieModule + { + private readonly IHttpClientFactory _httpFactory; + + public ReligiousCommands(IHttpClientFactory httpFactory) + => _httpFactory = httpFactory; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Bible(string book, string chapterAndVerse) + { + var res = await _service.GetBibleVerseAsync(book, chapterAndVerse); + + if (!res.TryPickT0(out var verse, out var error)) + { + await Response().Error(error.Value).SendAsync(); + return; + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle($"{verse.BookName} {verse.Chapter}:{verse.Verse}") + .WithDescription(verse.Text)) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Quran(string ayah) + { + var res = await _service.GetQuranVerseAsync(ayah); + + if (!res.TryPickT0(out var qr, out var error)) + { + await Response().Error(error.Value).SendAsync(); + return; + } + + var english = qr.Data[0]; + var arabic = qr.Data[1]; + + using var http = _httpFactory.CreateClient(); + await using var audio = await http.GetStreamAsync(arabic.Audio); + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .AddField("Arabic", arabic.Text) + .AddField("English", english.Text) + .WithFooter(arabic.Number.ToString())) + .File(audio, Uri.EscapeDataString(ayah) + ".mp3") + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs b/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs new file mode 100644 index 0000000..bfc22d4 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs @@ -0,0 +1,65 @@ +using EllieBot.Modules.Searches.GoogleScrape; +using EllieBot.Modules.Searches.Youtube; + +namespace EllieBot.Modules.Searches; + +public sealed class DefaultSearchServiceFactory : ISearchServiceFactory, INService +{ + private readonly SearchesConfigService _scs; + private readonly SearxSearchService _sss; + private readonly GoogleSearchService _gss; + + private readonly YtdlpYoutubeSearchService _ytdlp; + private readonly YtdlYoutubeSearchService _ytdl; + private readonly YoutubeDataApiSearchService _ytdata; + private readonly InvidiousYtSearchService _iYtSs; + private readonly GoogleScrapeService _gscs; + + public DefaultSearchServiceFactory( + SearchesConfigService scs, + GoogleSearchService gss, + GoogleScrapeService gscs, + SearxSearchService sss, + YtdlpYoutubeSearchService ytdlp, + YtdlYoutubeSearchService ytdl, + YoutubeDataApiSearchService ytdata, + InvidiousYtSearchService iYtSs) + { + _scs = scs; + _sss = sss; + _gss = gss; + _gscs = gscs; + _iYtSs = iYtSs; + + _ytdlp = ytdlp; + _ytdl = ytdl; + _ytdata = ytdata; + } + + public ISearchService GetSearchService(string? hint = null) + => _scs.Data.WebSearchEngine switch + { + WebSearchEngine.Google => _gss, + WebSearchEngine.Google_Scrape => _gscs, + WebSearchEngine.Searx => _sss, + _ => _gss + }; + + public ISearchService GetImageSearchService(string? hint = null) + => _scs.Data.ImgSearchEngine switch + { + ImgSearchEngine.Google => _gss, + ImgSearchEngine.Searx => _sss, + _ => _gss + }; + + public IYoutubeSearchService GetYoutubeSearchService(string? hint = null) + => _scs.Data.YtProvider switch + { + YoutubeSearcher.YtDataApiv3 => _ytdata, + YoutubeSearcher.Ytdlp => _ytdlp, + YoutubeSearcher.Ytdl => _ytdl, + YoutubeSearcher.Invidious => _iYtSs, + _ => _ytdl + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs new file mode 100644 index 0000000..74fd3c0 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs @@ -0,0 +1,22 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleCustomSearchResult : ISearchResult +{ + ISearchResultInformation ISearchResult.Info + => Info; + + public string? Answer + => null; + + IReadOnlyCollection ISearchResult.Entries + => Entries ?? Array.Empty(); + + [JsonPropertyName("searchInformation")] + public GoogleSearchResultInformation Info { get; init; } = null!; + + [JsonPropertyName("items")] + public IReadOnlyCollection? Entries { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs new file mode 100644 index 0000000..503a1cc --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleImageData +{ + [JsonPropertyName("contextLink")] + public string ContextLink { get; init; } = null!; + + [JsonPropertyName("thumbnailLink")] + public string ThumbnailLink { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs new file mode 100644 index 0000000..9cf406b --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs @@ -0,0 +1,19 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleImageResult : IImageSearchResult +{ + ISearchResultInformation IImageSearchResult.Info + => Info; + + IReadOnlyCollection IImageSearchResult.Entries + => Entries ?? Array.Empty(); + + [JsonPropertyName("searchInformation")] + public GoogleSearchResultInformation Info { get; init; } = null!; + + [JsonPropertyName("items")] + public IReadOnlyCollection? Entries { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs new file mode 100644 index 0000000..cd06fae --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs @@ -0,0 +1,13 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleImageResultEntry : IImageSearchResultEntry +{ + [JsonPropertyName("link")] + public string Link { get; init; } = null!; + + [JsonPropertyName("image")] + public GoogleImageData Image { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs new file mode 100644 index 0000000..0106c0a --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs @@ -0,0 +1,13 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleSearchResultInformation : ISearchResultInformation +{ + [JsonPropertyName("formattedTotalResults")] + public string TotalResults { get; init; } = null!; + + [JsonPropertyName("formattedSearchTime")] + public string SearchTime { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs new file mode 100644 index 0000000..563012d --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs @@ -0,0 +1,66 @@ +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches; + +public sealed class GoogleSearchService : SearchServiceBase, INService +{ + private readonly IBotCredsProvider _creds; + private readonly IHttpClientFactory _httpFactory; + + public GoogleSearchService(IBotCredsProvider creds, IHttpClientFactory httpFactory) + { + _creds = creds; + _httpFactory = httpFactory; + } + + public override async ITask SearchImagesAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var creds = _creds.GetCreds(); + var key = creds.Google.ImageSearchId; + var cx = string.IsNullOrWhiteSpace(key) + ? "c3f56de3be2034c07" + : key; + + using var http = _httpFactory.CreateClient("google:search"); + http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip"); + await using var stream = await http.GetStreamAsync( + $"https://customsearch.googleapis.com/customsearch/v1" + + $"?cx={cx}" + + $"&q={Uri.EscapeDataString(query)}" + + $"&fields=items(image(contextLink%2CthumbnailLink)%2Clink)%2CsearchInformation" + + $"&key={creds.GoogleApiKey}" + + $"&searchType=image" + + $"&safe=active"); + + var result = await System.Text.Json.JsonSerializer.DeserializeAsync(stream); + + return result; + } + + public override async ITask SearchAsync(string? query) + { + ArgumentNullException.ThrowIfNull(query); + + var creds = _creds.GetCreds(); + var key = creds.Google.SearchId; + var cx = string.IsNullOrWhiteSpace(key) + ? "c7f1dac95987d4571" + : key; + + using var http = _httpFactory.CreateClient("google:search"); + http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip"); + await using var stream = await http.GetStreamAsync( + $"https://customsearch.googleapis.com/customsearch/v1" + + $"?cx={cx}" + + $"&q={Uri.EscapeDataString(query)}" + + $"&fields=items(title%2Clink%2CdisplayLink%2Csnippet)%2CsearchInformation" + + $"&key={creds.GoogleApiKey}" + + $"&safe=active"); + + var result = await System.Text.Json.JsonSerializer.DeserializeAsync(stream); + + return result; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs new file mode 100644 index 0000000..bf23180 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs @@ -0,0 +1,19 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class OfficialGoogleSearchResultEntry : ISearchResultEntry +{ + [JsonPropertyName("title")] + public string Title { get; init; } = null!; + + [JsonPropertyName("link")] + public string Url { get; init; } = null!; + + [JsonPropertyName("displayLink")] + public string DisplayUrl { get; init; } = null!; + + [JsonPropertyName("snippet")] + public string Description { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs new file mode 100644 index 0000000..2cb9e0f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs @@ -0,0 +1,121 @@ +using AngleSharp.Html.Dom; +using AngleSharp.Html.Parser; +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches.GoogleScrape; + +public sealed class GoogleScrapeService : SearchServiceBase, INService +{ + private static readonly HtmlParser _googleParser = new(new() + { + IsScripting = false, + IsEmbedded = false, + IsSupportingProcessingInstructions = false, + IsKeepingSourceReferences = false, + IsNotSupportingFrames = true + }); + + + private readonly IHttpClientFactory _httpFactory; + + public GoogleScrapeService(IHttpClientFactory httpClientFactory) + => _httpFactory = httpClientFactory; + + public override async ITask SearchAsync(string? query) + { + ArgumentNullException.ThrowIfNull(query); + + query = Uri.EscapeDataString(query)?.Replace(' ', '+'); + + var fullQueryLink = $"https://www.google.ca/search?q={query}&safe=on&lr=lang_eng&hl=en&ie=utf-8&oe=utf-8"; + + using var msg = new HttpRequestMessage(HttpMethod.Get, fullQueryLink); + msg.Headers.Add("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36"); + msg.Headers.Add("Cookie", "CONSENT=YES+shp.gws-20210601-0-RC2.en+FX+423;"); + + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + + using var response = await http.SendAsync(msg); + await using var content = await response.Content.ReadAsStreamAsync(); + + using var document = await _googleParser.ParseDocumentAsync(content); + var elems = document.QuerySelectorAll("div.g, div.mnr-c > div > div"); + + var resultsElem = document.QuerySelector("#result-stats"); + var resultsArr = resultsElem?.TextContent.Split("results"); + var totalResults = resultsArr?.Length is null or 0 + ? null + : resultsArr[0]; + + var time = resultsArr is null or {Length: < 2} + ? null + : resultsArr[1] + .Replace("(", string.Empty) + .Replace("seconds)", string.Empty); + + //var time = resultsElem.Children.FirstOrDefault()?.TextContent + //^ this doesn't work for some reason, is completely missing in parsed collection + if (!elems.Any()) + return default; + + var results = elems.Select(elem => + { + var aTag = elem.QuerySelector("a"); + + if (aTag is null) + return null; + + var url = ((IHtmlAnchorElement)aTag).Href; + var title = aTag.QuerySelector("h3")?.TextContent; + + var txt = aTag.ParentElement + ?.NextElementSibling + ?.QuerySelector("span") + ?.TextContent + .StripHtml() + ?? elem + ?.QuerySelectorAll("span") + .Skip(3) + .FirstOrDefault() + ?.TextContent + .StripHtml(); + // .Select(x => x.TextContent.StripHtml()) + // .Join("\n"); + + if (string.IsNullOrWhiteSpace(url) + || string.IsNullOrWhiteSpace(title) + || string.IsNullOrWhiteSpace(txt)) + return null; + + return new PlainSearchResultEntry + { + Title = title, + Url = url, + DisplayUrl = url, + Description = txt + }; + }) + .Where(x => x is not null) + .ToList(); + + // return new GoogleSearchResult(results.AsReadOnly(), fullQueryLink, totalResults); + + return new PlainGoogleScrapeSearchResult() + { + Answer = null, + Entries = results!, + Info = new PlainSearchResultInfo() + { + SearchTime = time ?? "?", + TotalResults = totalResults ?? "?" + } + }; + } + + + // someone can mr this + public override ITask SearchImagesAsync(string query) + => throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs new file mode 100644 index 0000000..9abc999 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches.GoogleScrape; + +public class PlainGoogleScrapeSearchResult : ISearchResult +{ + public required string? Answer { get; init; } + public required IReadOnlyCollection Entries { get; init; } + public required ISearchResultInformation Info { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs new file mode 100644 index 0000000..99fad02 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Searches.GoogleScrape; + +public sealed class PlainSearchResultEntry : ISearchResultEntry +{ + public string Title { get; init; } = null!; + public string Url { get; init; } = null!; + public string DisplayUrl { get; init; } = null!; + public string? Description { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs new file mode 100644 index 0000000..92ba006 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches.GoogleScrape; + +public sealed class PlainSearchResultInfo : ISearchResultInformation +{ + public string TotalResults { get; init; } = null!; + public string SearchTime { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs b/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs new file mode 100644 index 0000000..d470613 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Modules.Searches; + +public interface IImageSearchResult +{ + ISearchResultInformation Info { get; } + + IReadOnlyCollection Entries { get; } +} + +public interface IImageSearchResultEntry +{ + string Link { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResult.cs b/src/EllieBot/Modules/Searches/Search/ISearchResult.cs new file mode 100644 index 0000000..d910819 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public interface ISearchResult +{ + string? Answer { get; } + IReadOnlyCollection Entries { get; } + ISearchResultInformation Info { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs new file mode 100644 index 0000000..e4dfc44 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Searches; + +public interface ISearchResultEntry +{ + string Title { get; } + string Url { get; } + string DisplayUrl { get; } + string? Description { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs new file mode 100644 index 0000000..dfd9a53 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public interface ISearchResultInformation +{ + string TotalResults { get; } + string SearchTime { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchService.cs b/src/EllieBot/Modules/Searches/Search/ISearchService.cs new file mode 100644 index 0000000..7454a60 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchService.cs @@ -0,0 +1,9 @@ +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches; + +public interface ISearchService +{ + ITask SearchAsync(string? query); + ITask SearchImagesAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs b/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs new file mode 100644 index 0000000..bb46b09 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs @@ -0,0 +1,10 @@ +using EllieBot.Modules.Searches.Youtube; + +namespace EllieBot.Modules.Searches; + +public interface ISearchServiceFactory +{ + public ISearchService GetSearchService(string? hint = null); + public ISearchService GetImageSearchService(string? hint = null); + public IYoutubeSearchService GetYoutubeSearchService(string? hint = null); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/SearchCommands.cs b/src/EllieBot/Modules/Searches/Search/SearchCommands.cs new file mode 100644 index 0000000..63e0821 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/SearchCommands.cs @@ -0,0 +1,205 @@ +using EllieBot.Modules.Searches.Youtube; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + public partial class SearchCommands : EllieModule + { + private readonly ISearchServiceFactory _searchFactory; + private readonly IBotCache _cache; + + public SearchCommands( + ISearchServiceFactory searchFactory, + IBotCache cache) + { + _searchFactory = searchFactory; + _cache = cache; + } + + [Cmd] + public async Task Google([Leftover] string? query = null) + { + query = query?.Trim(); + + if (string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.specify_search_params).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var search = _searchFactory.GetSearchService(); + var data = await search.SearchAsync(query); + + if (data is null or { Entries: null or { Count: 0 } }) + { + await Response().Error(strs.no_results).SendAsync(); + return; + } + + // 3 with an answer + // 4 without an answer + // 5 is ideal but it lookes horrible on mobile + + var takeCount = string.IsNullOrWhiteSpace(data.Answer) + ? 4 + : 3; + + var descStr = data.Entries + .Take(takeCount) + .Select(static res => $@"**[{Format.Sanitize(res.Title)}]({res.Url})** +*{Format.EscapeUrl(res.DisplayUrl)}* +{Format.Sanitize(res.Description ?? "-")}") + .Join("\n\n"); + + if (!string.IsNullOrWhiteSpace(data.Answer)) + descStr = Format.Code(data.Answer) + "\n\n" + descStr; + + descStr = descStr.TrimTo(4096); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithTitle(query.TrimTo(64)!) + .WithDescription(descStr) + .WithFooter( + GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)), + "https://i.imgur.com/G46fm8J.png"); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Image([Leftover] string query) + { + query = query.Trim(); + + if (string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.specify_search_params).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var search = _searchFactory.GetImageSearchService(); + var data = await search.SearchImagesAsync(query); + + if (data is null or { Entries: null or { Count: 0 } }) + { + await Response().Error(strs.no_search_results).SendAsync(); + return; + } + + var embeds = new List(4); + + + EmbedBuilder CreateEmbed(IImageSearchResultEntry entry) + { + return _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithTitle(query) + .WithUrl("https://google.com") + .WithImageUrl(entry.Link); + } + + await Response() + .Paginated() + .Items(data.Entries) + .PageSize(1) + .AddFooter(false) + .Page((items, _) => + { + var item = items.FirstOrDefault(); + + if (item is null) + return _sender.CreateEmbed() + .WithDescription(GetText(strs.no_search_results)); + + var embed = CreateEmbed(item); + embeds.Add(embed); + + return embed; + }) + .SendAsync(); + } + + private TypedKey GetYtCacheKey(string query) + => new($"search:youtube:{query}"); + + private async Task AddYoutubeUrlToCacheAsync(string query, string url) + => await _cache.AddAsync(GetYtCacheKey(query), url, expiry: 1.Hours()); + + private async Task GetYoutubeUrlFromCacheAsync(string query) + { + var result = await _cache.GetAsync(GetYtCacheKey(query)); + + if (!result.TryGetValue(out var url) || string.IsNullOrWhiteSpace(url)) + return null; + + return new VideoInfo() + { + Url = url + }; + } + + [Cmd] + public async Task Youtube([Leftover] string? query = null) + { + query = query?.Trim(); + + if (string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.specify_search_params).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var maybeResult = await GetYoutubeUrlFromCacheAsync(query) + ?? await _searchFactory.GetYoutubeSearchService().SearchAsync(query); + if (maybeResult is not { } result || result is { Url: null }) + { + await Response().Error(strs.no_results).SendAsync(); + return; + } + + await AddYoutubeUrlToCacheAsync(query, result.Url); + await Response().Text(result.Url).SendAsync(); + } + +// [Cmd] +// public async Task DuckDuckGo([Leftover] string query = null) +// { +// query = query?.Trim(); +// if (!await ValidateQuery(query)) +// return; +// +// _ = ctx.Channel.TriggerTypingAsync(); +// +// var data = await _service.DuckDuckGoSearchAsync(query); +// if (data is null) +// { +// await Response().Error(strs.no_results).SendAsync(); +// return; +// } +// +// var desc = data.Results.Take(5) +// .Select(res => $@"[**{res.Title}**]({res.Link}) +// {res.Text.TrimTo(380 - res.Title.Length - res.Link.Length)}"); +// +// var descStr = string.Join("\n\n", desc); +// +// var embed = _sender.CreateEmbed() +// .WithAuthor(ctx.User.ToString(), +// "https://upload.wikimedia.org/wikipedia/en/9/90/The_DuckDuckGo_Duck.png") +// .WithDescription($"{GetText(strs.search_for)} **{query}**\n\n" + descStr) +// .WithOkColor(); +// +// await Response().Embed(embed).SendAsync(); +// } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs b/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs new file mode 100644 index 0000000..c346306 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs @@ -0,0 +1,9 @@ +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches; + +public abstract class SearchServiceBase : ISearchService +{ + public abstract ITask SearchAsync(string? query); + public abstract ITask SearchImagesAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs new file mode 100644 index 0000000..54fdcdd --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs @@ -0,0 +1,28 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxImageSearchResult : IImageSearchResult +{ + public string SearchTime { get; set; } = null!; + + public ISearchResultInformation Info + => new SearxSearchResultInformation() + { + SearchTime = SearchTime, + TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture) + }; + + public IReadOnlyCollection Entries + => Results; + + [JsonPropertyName("results")] + public List Results { get; set; } = new List(); + + [JsonPropertyName("query")] + public string Query { get; set; } = null!; + + [JsonPropertyName("number_of_results")] + public double NumberOfResults { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs new file mode 100644 index 0000000..888a2ce --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxImageSearchResultEntry : IImageSearchResultEntry +{ + public string Link + => ImageSource.StartsWith("//") + ? "https:" + ImageSource + : ImageSource; + + [JsonPropertyName("img_src")] + public string ImageSource { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs new file mode 100644 index 0000000..1fd9ee2 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs @@ -0,0 +1,30 @@ +// using System.Text.Json.Serialization; +// +// namespace EllieBot.Modules.Searches; +// +// public sealed class SearxInfobox +// { +// [JsonPropertyName("infobox")] +// public string Infobox { get; set; } +// +// [JsonPropertyName("id")] +// public string Id { get; set; } +// +// [JsonPropertyName("content")] +// public string Content { get; set; } +// +// [JsonPropertyName("img_src")] +// public string ImgSrc { get; set; } +// +// [JsonPropertyName("urls")] +// public List Urls { get; } = new List(); +// +// [JsonPropertyName("engine")] +// public string Engine { get; set; } +// +// [JsonPropertyName("engines")] +// public List Engines { get; } = new List(); +// +// [JsonPropertyName("attributes")] +// public List Attributes { get; } = new List(); +// } \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs new file mode 100644 index 0000000..7071ea7 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchAttribute +{ + [JsonPropertyName("label")] + public string? Label { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("entity")] + public string? Entity { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs new file mode 100644 index 0000000..3483548 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchResult : ISearchResult +{ + [JsonPropertyName("query")] + public string Query { get; set; } = null!; + + [JsonPropertyName("number_of_results")] + public double NumberOfResults { get; set; } + + [JsonPropertyName("results")] + public List Results { get; set; } = new List(); + + [JsonPropertyName("answers")] + public List Answers { get; set; } = new List(); + // + // [JsonPropertyName("corrections")] + // public List Corrections { get; } = new List(); + + // [JsonPropertyName("infoboxes")] + // public List Infoboxes { get; } = new List(); + // + // [JsonPropertyName("suggestions")] + // public List Suggestions { get; } = new List(); + + // [JsonPropertyName("unresponsive_engines")] + // public List UnresponsiveEngines { get; } = new List(); + + + public string SearchTime { get; set; } = null!; + + public IReadOnlyCollection Entries + => Results; + + public ISearchResultInformation Info + => new SearxSearchResultInformation() + { + SearchTime = SearchTime, + TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture) + }; + + public string? Answer + => Answers.FirstOrDefault(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs new file mode 100644 index 0000000..9670a17 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchResultEntry : ISearchResultEntry +{ + public string DisplayUrl + => Url; + + public string Description + => Content.TrimTo(768)!; + + [JsonPropertyName("url")] + public string Url { get; set; } = null!; + + [JsonPropertyName("title")] + public string Title { get; set; } = null!; + + [JsonPropertyName("content")] + public string? Content { get; set; } + + // [JsonPropertyName("engine")] + // public string Engine { get; set; } + // + // [JsonPropertyName("parsed_url")] + // public List ParsedUrl { get; } = new List(); + // + // [JsonPropertyName("template")] + // public string Template { get; set; } + // + // [JsonPropertyName("engines")] + // public List Engines { get; } = new List(); + // + // [JsonPropertyName("positions")] + // public List Positions { get; } = new List(); + // + // [JsonPropertyName("score")] + // public double Score { get; set; } + // + // [JsonPropertyName("category")] + // public string Category { get; set; } + // + // [JsonPropertyName("pretty_url")] + // public string PrettyUrl { get; set; } + // + // [JsonPropertyName("open_group")] + // public bool OpenGroup { get; set; } + // + // [JsonPropertyName("close_group")] + // public bool? CloseGroup { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs new file mode 100644 index 0000000..33b8077 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchResultInformation : ISearchResultInformation +{ + public string TotalResults { get; init; } = string.Empty; + public string SearchTime { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs new file mode 100644 index 0000000..780c985 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs @@ -0,0 +1,77 @@ +using MorseCode.ITask; +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchService : SearchServiceBase, INService +{ + private readonly IHttpClientFactory _http; + private readonly SearchesConfigService _scs; + + private static readonly Random _rng = new EllieRandom(); + + public SearxSearchService(IHttpClientFactory http, SearchesConfigService scs) + => (_http, _scs) = (http, scs); + + private string GetRandomInstance() + { + var instances = _scs.Data.SearxInstances; + + if (instances is null or { Count: 0 }) + throw new InvalidOperationException("No searx instances specified in searches.yml"); + + return instances[_rng.Next(0, instances.Count)]; + } + + public override async ITask SearchAsync(string? query) + { + ArgumentNullException.ThrowIfNull(query); + + var instanceUrl = GetRandomInstance(); + + Log.Information("Using {Instance} instance for web search...", instanceUrl); + var startTime = Stopwatch.GetTimestamp(); + + using var http = _http.CreateClient(); + await using var res = await http.GetStreamAsync($"{instanceUrl}" + + $"?q={Uri.EscapeDataString(query)}" + + $"&format=json" + + $"&strict=2"); + + var elapsed = Stopwatch.GetElapsedTime(startTime); + var dat = await JsonSerializer.DeserializeAsync(res); + + if (dat is null) + return new SearxSearchResult(); + + dat.SearchTime = elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture); + return dat; + } + + public override async ITask SearchImagesAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var instanceUrl = GetRandomInstance(); + + Log.Information("Using {Instance} instance for img search...", instanceUrl); + var startTime = Stopwatch.GetTimestamp(); + using var http = _http.CreateClient(); + await using var res = await http.GetStreamAsync($"{instanceUrl}" + + $"?q={Uri.EscapeDataString(query)}" + + $"&format=json" + + $"&category_images=on" + + $"&strict=2"); + + var elapsed = Stopwatch.GetElapsedTime(startTime); + var dat = await JsonSerializer.DeserializeAsync(res); + + if (dat is null) + return new SearxImageSearchResult(); + + dat.SearchTime = elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture); + return dat; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs new file mode 100644 index 0000000..07f8591 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxUrlData +{ + [JsonPropertyName("title")] + public string Title { get; set; } = null!; + + [JsonPropertyName("url")] + public string Url { get; set; } = null!; + + [JsonPropertyName("official")] + public bool? Official { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs new file mode 100644 index 0000000..5b9bfab --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public interface IYoutubeSearchService +{ + Task SearchAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs new file mode 100644 index 0000000..d9a3f89 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class InvidiousSearchResponse +{ + [JsonPropertyName("videoId")] + public required string VideoId { get; init; } + + [JsonPropertyName("title")] + public required string Title { get; init; } + + [JsonPropertyName("videoThumbnails")] + public required List Thumbnails { get; init; } + + [JsonPropertyName("lengthSeconds")] + public required int LengthSeconds { get; init; } + + [JsonPropertyName("description")] + public required string Description { get; init; } +} + +public sealed class InvidiousVideoResponse +{ + [JsonPropertyName("title")] + public required string Title { get; init; } + + [JsonPropertyName("videoId")] + public required string VideoId { get; init; } + + [JsonPropertyName("lengthSeconds")] + public required int LengthSeconds { get; init; } + + [JsonPropertyName("videoThumbnails")] + public required List Thumbnails { get; init; } + + [JsonPropertyName("adaptiveFormats")] + public required List AdaptiveFormats { get; init; } +} + +public sealed class InvidiousAdaptiveFormat +{ + [JsonPropertyName("url")] + public required string Url { get; init; } + + [JsonPropertyName("audioQuality")] + public string? AudioQuality { get; init; } +} + +public sealed class InvidiousPlaylistResponse +{ + [JsonPropertyName("title")] + public required string Title { get; init; } + + [JsonPropertyName("videos")] + public required List Videos { get; init; } +} + +public sealed class InvidiousThumbnail +{ + [JsonPropertyName("url")] + public required string Url { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs new file mode 100644 index 0000000..93ebe03 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs @@ -0,0 +1,47 @@ +using EllieBot.Modules.Searches.Youtube; +using System.Net.Http.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class InvidiousYtSearchService : IYoutubeSearchService, INService +{ + private readonly IHttpClientFactory _http; + private readonly SearchesConfigService _scs; + private readonly EllieRandom _rng; + + public InvidiousYtSearchService( + IHttpClientFactory http, + SearchesConfigService scs) + { + _http = http; + _scs = scs; + _rng = new(); + } + + public async Task SearchAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var instances = _scs.Data.InvidiousInstances; + if (instances is null or { Count: 0 }) + { + Log.Warning("Attempted to use Invidious as the .youtube provider but there are no 'invidiousInstances' " + + "specified in `data/searches.yml`"); + return null; + } + + var instance = instances[_rng.Next(0, instances.Count)]; + + var url = $"{instance}/api/v1/search" + + $"?q={query}" + + $"&type=video"; + using var http = _http.CreateClient(); + var res = await http.GetFromJsonAsync>( + url); + + if (res is null or { Count: 0 }) + return null; + + return new VideoInfo(res[0].VideoId); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs b/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs new file mode 100644 index 0000000..5f53b9b --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public readonly struct VideoInfo +{ + public VideoInfo(string videoId) + => Url = $"https://youtube.com/watch?v={videoId}"; + + public string Url { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs new file mode 100644 index 0000000..d82387a --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs @@ -0,0 +1,26 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public sealed class YoutubeDataApiSearchService : IYoutubeSearchService, INService +{ + private readonly IGoogleApiService _gapi; + + public YoutubeDataApiSearchService(IGoogleApiService gapi) + { + _gapi = gapi; + } + + public async Task SearchAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var results = await _gapi.GetVideoLinksByKeywordAsync(query); + var first = results.FirstOrDefault(); + if (first is null) + return null; + + return new() + { + Url = first + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs new file mode 100644 index 0000000..ececc4b --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public sealed class YtdlYoutubeSearchService : YoutubedlxServiceBase, INService +{ + public override async Task SearchAsync(string query) + => await InternalGetInfoAsync(query, false); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs new file mode 100644 index 0000000..9eca5a7 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public sealed class YtdlpYoutubeSearchService : YoutubedlxServiceBase, INService +{ + public override async Task SearchAsync(string query) + => await InternalGetInfoAsync(query, true); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs new file mode 100644 index 0000000..6239bdd --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs @@ -0,0 +1,34 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public abstract class YoutubedlxServiceBase : IYoutubeSearchService +{ + private YtdlOperation CreateYtdlOp(bool isYtDlp) + => new YtdlOperation("-4 " + + "--geo-bypass " + + "--encoding UTF8 " + + "--get-id " + + "--no-check-certificate " + + "--default-search " + + "\"ytsearch:\" -- \"{0}\"", + isYtDlp: isYtDlp); + + protected async Task InternalGetInfoAsync(string query, bool isYtDlp) + { + var op = CreateYtdlOp(isYtDlp); + var data = await op.GetDataAsync(query); + var items = data?.Split('\n'); + if (items is null or { Length: 0 }) + return null; + + var id = items.FirstOrDefault(x => x.Length is > 5 and < 15); + if (id is null) + return null; + + return new VideoInfo() + { + Url = $"https://youtube.com/watch?v={id}" + }; + } + + public abstract Task SearchAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Searches.cs b/src/EllieBot/Modules/Searches/Searches.cs new file mode 100644 index 0000000..70e2556 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Searches.cs @@ -0,0 +1,461 @@ +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Modules.Searches.Common; +using EllieBot.Modules.Searches.Services; +using Newtonsoft.Json; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Diagnostics.CodeAnalysis; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Searches; + +public partial class Searches : EllieModule +{ + private readonly IBotCredentials _creds; + private readonly IGoogleApiService _google; + private readonly IHttpClientFactory _httpFactory; + private readonly IMemoryCache _cache; + private readonly ITimezoneService _tzSvc; + + public Searches( + IBotCredentials creds, + IGoogleApiService google, + IHttpClientFactory factory, + IMemoryCache cache, + ITimezoneService tzSvc) + { + _creds = creds; + _google = google; + _httpFactory = factory; + _cache = cache; + _tzSvc = tzSvc; + } + + [Cmd] + public async Task Weather([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + var embed = _sender.CreateEmbed(); + var data = await _service.GetWeatherDataAsync(query); + + if (data is null) + embed.WithDescription(GetText(strs.city_not_found)).WithErrorColor(); + else + { + var f = StandardConversions.CelsiusToFahrenheit; + + var tz = _tzSvc.GetTimeZoneOrUtc(ctx.Guild?.Id); + var sunrise = data.Sys.Sunrise.ToUnixTimestamp(); + var sunset = data.Sys.Sunset.ToUnixTimestamp(); + sunrise = sunrise.ToOffset(tz.GetUtcOffset(sunrise)); + sunset = sunset.ToOffset(tz.GetUtcOffset(sunset)); + var timezone = $"UTC{sunrise:zzz}"; + + embed + .AddField("🌍 " + Format.Bold(GetText(strs.location)), + $"[{data.Name + ", " + data.Sys.Country}](https://openweathermap.org/city/{data.Id})", + true) + .AddField("📏 " + Format.Bold(GetText(strs.latlong)), $"{data.Coord.Lat}, {data.Coord.Lon}", true) + .AddField("☁ " + Format.Bold(GetText(strs.condition)), + string.Join(", ", data.Weather.Select(w => w.Main)), + true) + .AddField("😓 " + Format.Bold(GetText(strs.humidity)), $"{data.Main.Humidity}%", true) + .AddField("💨 " + Format.Bold(GetText(strs.wind_speed)), data.Wind.Speed + " m/s", true) + .AddField("🌡 " + Format.Bold(GetText(strs.temperature)), + $"{data.Main.Temp:F1}°C / {f(data.Main.Temp):F1}°F", + true) + .AddField("🔆 " + Format.Bold(GetText(strs.min_max)), + $"{data.Main.TempMin:F1}°C - {data.Main.TempMax:F1}°C\n{f(data.Main.TempMin):F1}°F - {f(data.Main.TempMax):F1}°F", + true) + .AddField("🌄 " + Format.Bold(GetText(strs.sunrise)), $"{sunrise:HH:mm} {timezone}", true) + .AddField("🌇 " + Format.Bold(GetText(strs.sunset)), $"{sunset:HH:mm} {timezone}", true) + .WithOkColor() + .WithFooter("Powered by openweathermap.org", + $"https://openweathermap.org/img/w/{data.Weather[0].Icon}.png"); + } + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Time([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var (data, err) = await _service.GetTimeDataAsync(query); + if (err is not null) + { + await HandleErrorAsync(err.Value); + return; + } + + if (string.IsNullOrWhiteSpace(data.TimeZoneName)) + { + await Response().Error(strs.timezone_db_api_key).SendAsync(); + return; + } + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.time_new)) + .WithDescription(Format.Code(data.Time.ToString(Culture))) + .AddField(GetText(strs.location), string.Join('\n', data.Address.Split(", ")), true) + .AddField(GetText(strs.timezone), data.TimeZoneName, true); + + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + public async Task Movie([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var movie = await _service.GetMovieDataAsync(query); + if (movie is null) + { + await Response().Error(strs.imdb_fail).SendAsync(); + return; + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(movie.Title) + .WithUrl($"https://www.imdb.com/title/{movie.ImdbId}/") + .WithDescription(movie.Plot.TrimTo(1000)) + .AddField("Rating", movie.ImdbRating, true) + .AddField("Genre", movie.Genre, true) + .AddField("Year", movie.Year, true) + .WithImageUrl(Uri.IsWellFormedUriString(movie.Poster, UriKind.Absolute) + ? movie.Poster + : null)) + .SendAsync(); + } + + [Cmd] + public Task RandomCat() + => InternalRandomImage(SearchesService.ImageTag.Cats); + + [Cmd] + public Task RandomDog() + => InternalRandomImage(SearchesService.ImageTag.Dogs); + + [Cmd] + public Task RandomFood() + => InternalRandomImage(SearchesService.ImageTag.Food); + + [Cmd] + public Task RandomBird() + => InternalRandomImage(SearchesService.ImageTag.Birds); + + private Task InternalRandomImage(SearchesService.ImageTag tag) + { + var url = _service.GetRandomImageUrl(tag); + return Response().Embed(_sender.CreateEmbed().WithOkColor().WithImageUrl(url)).SendAsync(); + } + + [Cmd] + public async Task Lmgtfy([Leftover] string smh) + { + if (!await ValidateQuery(smh)) + return; + + var link = $"https://letmegooglethat.com/?q={Uri.EscapeDataString(smh)}"; + var shortenedUrl = await _service.ShortenLink(link) ?? link; + await Response().Confirm($"<{shortenedUrl}>").SendAsync(); + } + + [Cmd] + public async Task Shorten([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + var shortLink = await _service.ShortenLink(query); + + if (shortLink is null) + { + await Response().Error(strs.error_occured).SendAsync(); + return; + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .AddField(GetText(strs.original_url), $"<{query}>") + .AddField(GetText(strs.short_url), $"<{shortLink}>")) + .SendAsync(); + } + + + [Cmd] + public async Task MagicTheGathering([Leftover] string search) + { + if (!await ValidateQuery(search)) + return; + + await ctx.Channel.TriggerTypingAsync(); + var card = await _service.GetMtgCardAsync(search); + + if (card is null) + { + await Response().Error(strs.card_not_found).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(card.Name) + .WithDescription(card.Description) + .WithImageUrl(card.ImageUrl) + .AddField(GetText(strs.store_url), card.StoreUrl, true) + .AddField(GetText(strs.cost), card.ManaCost, true) + .AddField(GetText(strs.types), card.Types, true); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Hearthstone([Leftover] string name) + { + if (!await ValidateQuery(name)) + return; + + if (string.IsNullOrWhiteSpace(_creds.RapidApiKey)) + { + await Response().Error(strs.mashape_api_missing).SendAsync(); + return; + } + + await ctx.Channel.TriggerTypingAsync(); + var card = await _service.GetHearthstoneCardDataAsync(name); + + if (card is null) + { + await Response().Error(strs.card_not_found).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithOkColor().WithImageUrl(card.Img); + + if (!string.IsNullOrWhiteSpace(card.Flavor)) + embed.WithDescription(card.Flavor); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task UrbanDict([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync($"https://api.urbandictionary.com/v0/define?" + + $"term={Uri.EscapeDataString(query)}"); + var allItems = JsonConvert.DeserializeObject(res)?.List; + + if (allItems is null or { Length: 0 }) + { + await Response().Error(strs.ud_error).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(allItems) + .PageSize(1) + .CurrentPage(0) + .Page((items, _) => + { + var item = items[0]; + return _sender.CreateEmbed() + .WithOkColor() + .WithUrl(item.Permalink) + .WithTitle(item.Word) + .WithDescription(item.Definition); + }) + .SendAsync(); + } + + [Cmd] + public async Task Define([Leftover] string word) + { + if (!await ValidateQuery(word)) + return; + + + var maybeItems = await _service.GetDefinitionsAsync(word); + + if (!maybeItems.TryPickT0(out var defs, out var error)) + { + await HandleErrorAsync(error); + return; + } + + await Response() + .Paginated() + .Items(defs) + .PageSize(1) + .Page((items, _) => + { + var model = items.First(); + var embed = _sender.CreateEmbed() + .WithDescription(ctx.User.Mention) + .AddField(GetText(strs.word), model.Word, true) + .AddField(GetText(strs._class), model.WordType, true) + .AddField(GetText(strs.definition), model.Definition) + .WithOkColor(); + + if (!string.IsNullOrWhiteSpace(model.Example)) + embed.AddField(GetText(strs.example), model.Example); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + public async Task Catfact() + { + var maybeFact = await _service.GetCatFactAsync(); + + if (!maybeFact.TryPickT0(out var fact, out var error)) + { + await HandleErrorAsync(error); + return; + } + + await Response().Confirm("🐈" + GetText(strs.catfact), fact).SendAsync(); + } + + [Cmd] + public async Task Wiki([Leftover] string query) + { + query = query.Trim(); + + if (!await ValidateQuery(query)) + return; + + var maybeRes = await _service.GetWikipediaPageAsync(query); + if (!maybeRes.TryPickT0(out var res, out var error)) + { + await HandleErrorAsync(error); + return; + } + + var data = res.Data; + await Response().Text(data.Url).SendAsync(); + } + + public Task HandleErrorAsync(ErrorType error) + { + var errorKey = error switch + { + ErrorType.ApiKeyMissing => strs.api_key_missing, + ErrorType.InvalidInput => strs.invalid_input, + ErrorType.NotFound => strs.not_found, + ErrorType.Unknown => strs.error_occured, + _ => strs.error_occured, + }; + + return Response().Error(errorKey).SendAsync(); + } + + [Cmd] + public async Task Color(params Color[] colors) + { + if (!colors.Any()) + return; + + var colorObjects = colors.Take(10).ToArray(); + + using var img = new Image(colorObjects.Length * 50, 50); + for (var i = 0; i < colorObjects.Length; i++) + { + var x = i * 50; + var j = i; + img.Mutate(m => m.FillPolygon(colorObjects[j], new(x, 0), new(x + 50, 0), new(x + 50, 50), new(x, 50))); + } + + await using var ms = await img.ToStreamAsync(); + await ctx.Channel.SendFileAsync(ms, "colors.png"); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Avatar([Leftover] IGuildUser? usr = null) + { + usr ??= (IGuildUser)ctx.User; + + var avatarUrl = usr.RealAvatarUrl(2048); + + await Response() + .Embed( + _sender.CreateEmbed() + .WithOkColor() + .AddField("Username", usr.ToString()) + .AddField("Avatar Url", avatarUrl) + .WithThumbnailUrl(avatarUrl.ToString())) + .SendAsync(); + } + + [Cmd] + public async Task Wikia(string target, [Leftover] string query) + { + if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.wikia_input_error).SendAsync(); + return; + } + + var maybeRes = await _service.GetWikiaPageAsync(target, query); + + if (!maybeRes.TryPickT0(out var res, out var error)) + { + await HandleErrorAsync(error); + return; + } + + var response = $"### {res.Title}\n{res.Url}"; + await Response().Text(response).Sanitize().SendAsync(); + } + + [Cmd] + public async Task Steam([Leftover] string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var appId = await _service.GetSteamAppIdByName(query); + if (appId == -1) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + await Response().Text($"https://store.steampowered.com/app/{appId}").SendAsync(); + } + + private async Task ValidateQuery([MaybeNullWhen(false)] string query) + { + if (!string.IsNullOrWhiteSpace(query)) + return true; + + await Response().Error(strs.specify_search_params).SendAsync(); + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/SearchesService.cs b/src/EllieBot/Modules/Searches/SearchesService.cs new file mode 100644 index 0000000..5767240 --- /dev/null +++ b/src/EllieBot/Modules/Searches/SearchesService.cs @@ -0,0 +1,616 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OneOf; +using System.Text.Json; + +namespace EllieBot.Modules.Searches.Services; + +public class SearchesService : INService +{ + public enum ImageTag + { + Food, + Dogs, + Cats, + Birds + } + + public List WowJokes { get; } = []; + public List MagicItems { get; } = []; + private readonly IHttpClientFactory _httpFactory; + private readonly IGoogleApiService _google; + private readonly IBotCache _c; + private readonly IBotCredsProvider _creds; + private readonly EllieRandom _rng; + private readonly List _yomamaJokes; + + private readonly object _yomamaLock = new(); + private int yomamaJokeIndex; + private readonly ConcurrentDictionary _cachedShortenedLinks = new(); + + public SearchesService( + IGoogleApiService google, + IBotCache c, + IHttpClientFactory factory, + FontProvider fonts, + IBotCredsProvider creds) + { + _httpFactory = factory; + _google = google; + _c = c; + _creds = creds; + _rng = new(); + + //joke commands + if (File.Exists("data/wowjokes.json")) + WowJokes = JsonConvert.DeserializeObject>(File.ReadAllText("data/wowjokes.json")); + else + Log.Warning("data/wowjokes.json is missing. WOW Jokes are not loaded"); + + if (File.Exists("data/magicitems.json")) + MagicItems = JsonConvert.DeserializeObject>(File.ReadAllText("data/magicitems.json")); + else + Log.Warning("data/magicitems.json is missing. Magic items are not loaded"); + + if (File.Exists("data/yomama.txt")) + _yomamaJokes = File.ReadAllLines("data/yomama.txt").Shuffle().ToList(); + else + { + _yomamaJokes = []; + Log.Warning("data/yomama.txt is missing. .yomama command won't work"); + } + } + + public async Task GetWeatherDataAsync(string query) + { + query = query.Trim().ToLowerInvariant(); + + return await _c.GetOrAddAsync(new($"ellie_weather_{query}"), + async () => await GetWeatherDataFactory(query), + TimeSpan.FromHours(3)); + } + + private async Task GetWeatherDataFactory(string query) + { + using var http = _httpFactory.CreateClient(); + try + { + var data = await http.GetStringAsync("https://api.openweathermap.org/data/2.5/weather?" + + $"q={query}&" + + "appid=42cd627dd60debf25a5739e50a217d74&" + + "units=metric"); + + if (string.IsNullOrWhiteSpace(data)) + return null; + + return JsonConvert.DeserializeObject(data); + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting weather data"); + return null; + } + } + + public Task<((string Address, DateTime Time, string TimeZoneName), ErrorType?)> GetTimeDataAsync(string arg) + => GetTimeDataFactory(arg); + + //return _cache.GetOrAddCachedDataAsync($"ellie_time_{arg}", + // GetTimeDataFactory, + // arg, + // TimeSpan.FromMinutes(1)); + private async Task<((string Address, DateTime Time, string TimeZoneName), ErrorType?)> GetTimeDataFactory( + string query) + { + query = query.Trim(); + + if (string.IsNullOrEmpty(query)) + return (default, ErrorType.InvalidInput); + + + var locIqKey = _creds.GetCreds().LocationIqApiKey; + var tzDbKey = _creds.GetCreds().TimezoneDbApiKey; + if (string.IsNullOrWhiteSpace(locIqKey) || string.IsNullOrWhiteSpace(tzDbKey)) + return (default, ErrorType.ApiKeyMissing); + + try + { + using var http = _httpFactory.CreateClient(); + var res = await _c.GetOrAddAsync(new($"searches:geo:{query}"), + async () => + { + var url = "https://eu1.locationiq.com/v1/search.php?" + + (string.IsNullOrWhiteSpace(locIqKey) + ? "key=" + : $"key={locIqKey}&") + + $"q={Uri.EscapeDataString(query)}&" + + "format=json"; + + var res = await http.GetStringAsync(url); + return res; + }, + TimeSpan.FromHours(1)); + + var responses = JsonConvert.DeserializeObject(res); + if (responses is null || responses.Length == 0) + { + Log.Warning("Geocode lookup failed for: {Query}", query); + return (default, ErrorType.NotFound); + } + + var geoData = responses[0]; + + using var req = new HttpRequestMessage(HttpMethod.Get, + "http://api.timezonedb.com/v2.1/get-time-zone?" + + $"key={tzDbKey}" + + $"&format=json" + + $"&by=position" + + $"&lat={geoData.Lat}" + + $"&lng={geoData.Lon}"); + + using var geoRes = await http.SendAsync(req); + var resString = await geoRes.Content.ReadAsStringAsync(); + var timeObj = JsonConvert.DeserializeObject(resString); + + var time = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(timeObj.Timestamp); + + return ((Address: responses[0].DisplayName, Time: time, TimeZoneName: timeObj.TimezoneName), default); + } + catch (Exception ex) + { + Log.Error(ex, "Weather error: {Message}", ex.Message); + return (default, ErrorType.NotFound); + } + } + + public string GetRandomImageUrl(ImageTag tag) + { + var subpath = tag.ToString().ToLowerInvariant(); + + var max = tag switch + { + ImageTag.Food => 773, + ImageTag.Dogs => 750, + ImageTag.Cats => 773, + ImageTag.Birds => 578, + _ => 100, + }; + + + return $"https://ellie-pictures.nyc3.digitaloceanspaces.com/{subpath}/" + + _rng.Next(1, max).ToString("000") + + ".png"; + } + + public Task GetYomamaJoke() + { + string joke; + lock (_yomamaLock) + { + if (yomamaJokeIndex >= _yomamaJokes.Count) + { + yomamaJokeIndex = 0; + var newList = _yomamaJokes.ToList(); + _yomamaJokes.Clear(); + _yomamaJokes.AddRange(newList.Shuffle()); + } + + joke = _yomamaJokes[yomamaJokeIndex++]; + } + + return Task.FromResult(joke); + + // using (var http = _httpFactory.CreateClient()) + // { + // var response = await http.GetStringAsync(new Uri("http://api.yomomma.info/")); + // return JObject.Parse(response)["joke"].ToString() + " 😆"; + // } + } + + public async Task<(string Setup, string Punchline)> GetRandomJoke() + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync("https://official-joke-api.appspot.com/random_joke"); + var resObj = JsonConvert.DeserializeAnonymousType(res, + new + { + setup = "", + punchline = "" + }); + return (resObj.setup, resObj.punchline); + } + + public async Task GetChuckNorrisJoke() + { + using var http = _httpFactory.CreateClient(); + var response = await http.GetStringAsync(new Uri("https://api.chucknorris.io/jokes/random")); + return JObject.Parse(response)["value"] + " 😆"; + } + + public async Task GetMtgCardAsync(string search) + { + search = search.Trim().ToLowerInvariant(); + var data = await _c.GetOrAddAsync(new($"mtg:{search}"), + async () => await GetMtgCardFactory(search), + TimeSpan.FromDays(1)); + + if (data is null || data.Length == 0) + return null; + + return data[_rng.Next(0, data.Length)]; + } + + private async Task GetMtgCardFactory(string search) + { + async Task GetMtgDataAsync(MtgResponse.Data card) + { + string storeUrl; + try + { + storeUrl = await _google.ShortenUrl("https://shop.tcgplayer.com/productcatalog/product/show?" + + "newSearch=false&" + + "ProductType=All&" + + "IsProductNameExact=false&" + + $"ProductName={Uri.EscapeDataString(card.Name)}"); + } + catch { storeUrl = ""; } + + return new() + { + Description = card.Text, + Name = card.Name, + ImageUrl = card.ImageUrl, + StoreUrl = storeUrl, + Types = string.Join(",\n", card.Types), + ManaCost = card.ManaCost + }; + } + + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + var response = + await http.GetStringAsync($"https://api.magicthegathering.io/v1/cards?name={Uri.EscapeDataString(search)}"); + + var responseObject = JsonConvert.DeserializeObject(response); + if (responseObject is null) + return Array.Empty(); + + var cards = responseObject.Cards.Take(5).ToArray(); + if (cards.Length == 0) + return Array.Empty(); + + return await cards.Select(GetMtgDataAsync).WhenAll(); + } + + public async Task GetHearthstoneCardDataAsync(string name) + { + name = name.ToLowerInvariant(); + return await _c.GetOrAddAsync($"hearthstone:{name}", + () => HearthstoneCardDataFactory(name), + TimeSpan.FromDays(1)); + } + + private async Task HearthstoneCardDataFactory(string name) + { + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + http.DefaultRequestHeaders.Add("x-rapidapi-key", _creds.GetCreds().RapidApiKey); + try + { + var response = await http.GetStringAsync("https://omgvamp-hearthstone-v1.p.rapidapi.com/" + + $"cards/search/{Uri.EscapeDataString(name)}"); + var objs = JsonConvert.DeserializeObject(response); + if (objs is null || objs.Length == 0) + return null; + var data = objs.FirstOrDefault(x => x.Collectible) + ?? objs.FirstOrDefault(x => !string.IsNullOrEmpty(x.PlayerClass)) ?? objs.FirstOrDefault(); + if (data is null) + return null; + if (!string.IsNullOrWhiteSpace(data.Img)) + data.Img = await _google.ShortenUrl(data.Img); + // if (!string.IsNullOrWhiteSpace(data.Text)) + // { + // var converter = new Converter(); + // data.Text = converter.Convert(data.Text); + // } + + return data; + } + catch (Exception ex) + { + Log.Error(ex, "Error getting Hearthstone Card: {ErrorMessage}", ex.Message); + return null; + } + } + + public async Task GetMovieDataAsync(string name) + { + name = name.Trim().ToLowerInvariant(); + return await _c.GetOrAddAsync(new($"movie:{name}"), + () => GetMovieDataFactory(name), + TimeSpan.FromDays(1)); + } + + private async Task GetMovieDataFactory(string name) + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync("https://omdbapi.nadeko.bot/" + + $"?t={name.Trim().Replace(' ', '+')}" + + "&y=" + + "&plot=full" + + "&r=json"); + var movie = JsonConvert.DeserializeObject(res); + if (movie?.Title is null) + return null; + movie.Poster = await _google.ShortenUrl(movie.Poster); + return movie; + } + + public async Task GetSteamAppIdByName(string query) + { + const string steamGameIdsKey = "steam_names_to_appid"; + + var gamesMap = await _c.GetOrAddAsync(new(steamGameIdsKey), + async () => + { + using var http = _httpFactory.CreateClient(); + + // https://api.steampowered.com/ISteamApps/GetAppList/v2/ + var gamesStr = await http.GetStringAsync("https://api.steampowered.com/ISteamApps/GetAppList/v2/"); + var apps = JsonConvert + .DeserializeAnonymousType(gamesStr, + new + { + applist = new + { + apps = new List() + } + })! + .applist.apps; + + return apps.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .GroupBy(x => x.Name) + .ToDictionary(x => x.Key, x => x.First().AppId); + }, + TimeSpan.FromHours(24)); + + if (gamesMap is null) + return -1; + + query = query.Trim(); + + var keyList = gamesMap.Keys.ToList(); + + var key = keyList.FirstOrDefault(x => x.Equals(query, StringComparison.OrdinalIgnoreCase)); + + if (key == default) + { + key = keyList.FirstOrDefault(x => x.StartsWith(query, StringComparison.OrdinalIgnoreCase)); + if (key == default) + return -1; + } + + return gamesMap[key]; + } + + public async Task> GetWikipediaPageAsync(string query) + { + query = query.Trim(); + if (string.IsNullOrEmpty(query)) + { + return ErrorType.InvalidInput; + } + + try + { + var result = await _c.GetOrAddAsync($"wikipedia_{query}", + async () => + { + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + + return await http.GetStringAsync( + "https://en.wikipedia.org/w/api.php?action=query" + + "&format=json" + + "&prop=info" + + "&redirects=1" + + "&formatversion=2" + + "&inprop=url" + + "&titles=" + + Uri.EscapeDataString(query)); + }, + TimeSpan.FromHours(1)) + .ConfigureAwait(false); + + var data = JsonConvert.DeserializeObject(result); + + if (data.Query.Pages is null || !data.Query.Pages.Any() || data.Query.Pages.First().Missing) + { + return ErrorType.NotFound; + } + + Log.Information("Sending wikipedia url for: {Query}", query); + + return new WikipediaReply + { + Data = new() + { + Url = data.Query.Pages[0].FullUrl, + } + }; + } + catch (Exception ex) + { + Log.Error(ex, "Error retrieving wikipedia data for: '{Query}'", query); + + return ErrorType.Unknown; + } + } + + public async Task> GetCatFactAsync() + { + using var http = _httpFactory.CreateClient(); + var response = await http.GetStringAsync("https://catfact.ninja/fact").ConfigureAwait(false); + + var doc = JsonDocument.Parse(response); + + + if (!doc.RootElement.TryGetProperty("fact", out var factElement)) + { + return ErrorType.Unknown; + } + + return factElement.ToString(); + } + + public async Task> GetWikiaPageAsync(string target, string query) + { + if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(query)) + { + return ErrorType.InvalidInput; + } + + query = Uri.EscapeDataString(query.Trim()); + target = Uri.EscapeDataString(target.Trim()); + + if (string.IsNullOrEmpty(query)) + { + return ErrorType.InvalidInput; + } + + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + try + { + var res = await http.GetStringAsync($"https://{Uri.EscapeDataString(target)}.fandom.com/api.php" + + "?action=query" + + "&format=json" + + "&list=search" + + $"&srsearch={Uri.EscapeDataString(query)}" + + "&srlimit=1"); + var items = JObject.Parse(res); + var title = items["query"]?["search"]?.FirstOrDefault()?["title"]?.ToString(); + + if (string.IsNullOrWhiteSpace(title)) + { + return ErrorType.NotFound; + } + + var url = $"https://{target}.fandom.com/wiki/{title}"; + + return new WikiaResponse() + { + Url = url, + Title = title, + }; + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting wikia page: {Message}", ex.Message); + return ErrorType.Unknown; + } + } + + private static TypedKey GetDefineKey(string query) + => new TypedKey($"define_{query}"); + + public async Task, ErrorType>> GetDefinitionsAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return ErrorType.InvalidInput; + } + + query = Uri.EscapeDataString(query); + + using var http = _httpFactory.CreateClient(); + string res; + try + { + res = await _c.GetOrAddAsync(GetDefineKey(query), + async () => await http.GetStringAsync( + $"https://api.pearson.com/v2/dictionaries/entries?headword={query}"), + TimeSpan.FromHours(12)); + + var responseModel = JsonConvert.DeserializeObject(res); + + var data = responseModel.Results + .Where(x => x.Senses is not null + && x.Senses.Count > 0 + && x.Senses[0].Definition is not null) + .Select(x => (Sense: x.Senses[0], x.PartOfSpeech)) + .ToList(); + + if (!data.Any()) + { + Log.Warning("Definition not found: {Word}", query); + return ErrorType.NotFound; + } + + + var items = new List(); + + foreach (var d in data) + { + items.Add(new DefineData + { + Definition = d.Sense.Definition is JArray { Count: > 0 } defs + ? defs[0].ToString() + : d.Sense.Definition.ToString(), + Example = d.Sense.Examples is null || d.Sense.Examples.Count == 0 + ? string.Empty + : d.Sense.Examples[0].Text, + WordType = string.IsNullOrWhiteSpace(d.PartOfSpeech) ? "-" : d.PartOfSpeech, + Word = query, + }); + } + + return items.OrderByDescending(x => !string.IsNullOrWhiteSpace(x.Example)).ToList(); + } + catch (Exception ex) + { + Log.Error(ex, "Error retrieving definition data for: {Word}", query); + return ErrorType.Unknown; + } + } + + public async Task ShortenLink(string query) + { + query = query.Trim(); + + if (_cachedShortenedLinks.TryGetValue(query, out var shortLink)) + return shortLink; + + try + { + using var http = _httpFactory.CreateClient(); + using var req = new HttpRequestMessage(HttpMethod.Post, "https://goolnk.com/api/v1/shorten"); + var formData = new MultipartFormDataContent + { + { new StringContent(query), "url" } + }; + req.Content = formData; + + using var res = await http.SendAsync(req); + var content = await res.Content.ReadAsStringAsync(); + var data = JsonConvert.DeserializeObject(content); + + if (!string.IsNullOrWhiteSpace(data?.ResultUrl)) + _cachedShortenedLinks.TryAdd(query, data.ResultUrl); + else + return query; + + shortLink = data.ResultUrl; + } + catch (Exception ex) + { + Log.Error(ex, "Error shortening a link: {Message}", ex.Message); + return null; + } + + return shortLink; + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs new file mode 100644 index 0000000..ed260ae --- /dev/null +++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs @@ -0,0 +1,195 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class StreamNotificationCommands : EllieModule + { + private readonly DbService _db; + + public StreamNotificationCommands(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamAdd(string link) + { + var data = await _service.FollowStream(ctx.Guild.Id, ctx.Channel.Id, link); + if (data is null) + { + await Response().Error(strs.stream_not_added).SendAsync(); + return; + } + + var embed = _service.GetEmbed(ctx.Guild.Id, data); + await Response() + .Embed(embed) + .Text(strs.stream_tracked) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task StreamRemove(int index) + { + if (--index < 0) + return; + + var fs = await _service.UnfollowStreamAsync(ctx.Guild.Id, index); + if (fs is null) + { + await Response().Error(strs.stream_no).SendAsync(); + return; + } + + await Response().Confirm(strs.stream_removed(Format.Bold(fs.Username), fs.Type)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task StreamsClear() + { + await _service.ClearAllStreams(ctx.Guild.Id); + await Response().Confirm(strs.streams_cleared).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task StreamList(int page = 1) + { + if (page-- < 1) + return; + + var allStreams = await _service.GetAllStreamsAsync((SocketGuild)ctx.Guild); + + await Response() + .Paginated() + .Items(allStreams) + .PageSize(12) + .CurrentPage(page) + .Page((elements, cur) => + { + if (elements.Count == 0) + return _sender.CreateEmbed().WithDescription(GetText(strs.streams_none)).WithErrorColor(); + + var eb = _sender.CreateEmbed().WithTitle(GetText(strs.streams_follow_title)).WithOkColor(); + for (var index = 0; index < elements.Count; index++) + { + var elem = elements[index]; + eb.AddField($"**#{index + 1 + (12 * cur)}** {elem.Username.ToLower()}", + $"【{elem.Type}】\n<#{elem.ChannelId}>\n{elem.Message?.TrimTo(50)}", + true); + } + + return eb; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamOffline() + { + var newValue = _service.ToggleStreamOffline(ctx.Guild.Id); + if (newValue) + await Response().Confirm(strs.stream_off_enabled).SendAsync(); + else + await Response().Confirm(strs.stream_off_disabled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamOnlineDelete() + { + var newValue = _service.ToggleStreamOnlineDelete(ctx.Guild.Id); + if (newValue) + await Response().Confirm(strs.stream_online_delete_enabled).SendAsync(); + else + await Response().Confirm(strs.stream_online_delete_disabled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamMessage(int index, [Leftover] string message) + { + if (--index < 0) + return; + + var canMentionEveryone = (ctx.User as IGuildUser)?.GuildPermissions.MentionEveryone ?? true; + if (!canMentionEveryone) + message = message?.SanitizeAllMentions(); + + if (!_service.SetStreamMessage(ctx.Guild.Id, index, message, out var fs)) + { + await Response().Confirm(strs.stream_not_following).SendAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(message)) + await Response().Confirm(strs.stream_message_reset(Format.Bold(fs.Username))).SendAsync(); + else + await Response().Confirm(strs.stream_message_set(Format.Bold(fs.Username))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamMessageAll([Leftover] string message) + { + var canMentionEveryone = (ctx.User as IGuildUser)?.GuildPermissions.MentionEveryone ?? true; + if (!canMentionEveryone) + message = message?.SanitizeAllMentions(); + + var count = _service.SetStreamMessageForAll(ctx.Guild.Id, message); + + if (count == 0) + { + await Response().Confirm(strs.stream_not_following_any).SendAsync(); + return; + } + + await Response().Confirm(strs.stream_message_set_all(count)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task StreamCheck(string url) + { + try + { + var data = await _service.GetStreamDataAsync(url); + if (data is null) + { + await Response().Error(strs.no_channel_found).SendAsync(); + return; + } + + if (data.IsLive) + { + await Response() + .Confirm(strs.streamer_online(Format.Bold(data.Name), + Format.Bold(data.Viewers.ToString()))) + .SendAsync(); + } + else + await Response().Confirm(strs.streamer_offline(data.Name)).SendAsync(); + } + catch + { + await Response().Error(strs.no_channel_found).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs new file mode 100644 index 0000000..2ad4546 --- /dev/null +++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs @@ -0,0 +1,671 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using EllieBot.Modules.Searches.Common; +using EllieBot.Modules.Searches.Common.StreamNotifications; + +namespace EllieBot.Modules.Searches.Services; + +public sealed class StreamNotificationService : INService, IReadyExecutor +{ + private readonly DbService _db; + private readonly IBotStrings _strings; + private readonly Random _rng = new EllieRandom(); + private readonly DiscordSocketClient _client; + private readonly NotifChecker _streamTracker; + + private readonly object _shardLock = new(); + + private readonly Dictionary> _trackCounter = new(); + + private readonly Dictionary>> _shardTrackedStreams; + private readonly ConcurrentHashSet _offlineNotificationServers; + private readonly ConcurrentHashSet _deleteOnOfflineServers; + + private readonly IPubSub _pubSub; + private readonly IMessageSenderService _sender; + private readonly SearchesConfigService _config; + private readonly IReplacementService _repSvc; + + public TypedKey> StreamsOnlineKey { get; } + public TypedKey> StreamsOfflineKey { get; } + + private readonly TypedKey _streamFollowKey; + private readonly TypedKey _streamUnfollowKey; + + public event Func< + FollowedStream.FType, + string, + IReadOnlyCollection<(ulong, ulong)>, + Task> OnlineMessagesSent = static delegate { return Task.CompletedTask; }; + + public StreamNotificationService( + DbService db, + DiscordSocketClient client, + IBotStrings strings, + IBotCredsProvider creds, + IHttpClientFactory httpFactory, + IBot bot, + IPubSub pubSub, + IMessageSenderService sender, + SearchesConfigService config, + IReplacementService repSvc) + { + _db = db; + _client = client; + _strings = strings; + _pubSub = pubSub; + _sender = sender; + _config = config; + _repSvc = repSvc; + + _streamTracker = new(httpFactory, creds); + + StreamsOnlineKey = new("streams.online"); + StreamsOfflineKey = new("streams.offline"); + + _streamFollowKey = new("stream.follow"); + _streamUnfollowKey = new("stream.unfollow"); + + using (var uow = db.GetDbContext()) + { + var ids = client.GetGuildIds(); + var guildConfigs = uow.Set() + .AsQueryable() + .Include(x => x.FollowedStreams) + .Where(x => ids.Contains(x.GuildId)) + .ToList(); + + _offlineNotificationServers = new(guildConfigs + .Where(gc => gc.NotifyStreamOffline) + .Select(x => x.GuildId) + .ToList()); + + _deleteOnOfflineServers = new(guildConfigs + .Where(gc => gc.DeleteStreamOnlineMessage) + .Select(x => x.GuildId) + .ToList()); + + var followedStreams = guildConfigs.SelectMany(x => x.FollowedStreams).ToList(); + + _shardTrackedStreams = followedStreams.GroupBy(x => new + { + x.Type, + Name = x.Username.ToLower() + }) + .ToList() + .ToDictionary( + x => new StreamDataKey(x.Key.Type, x.Key.Name.ToLower()), + x => x.GroupBy(y => y.GuildId) + .ToDictionary(y => y.Key, + y => y.AsEnumerable().ToHashSet())); + + // shard 0 will keep track of when there are no more guilds which track a stream + if (client.ShardId == 0) + { + var allFollowedStreams = uow.Set().AsQueryable().ToList(); + + foreach (var fs in allFollowedStreams) + _streamTracker.AddLastData(fs.CreateKey(), null, false); + + _trackCounter = allFollowedStreams.GroupBy(x => new + { + x.Type, + Name = x.Username.ToLower() + }) + .ToDictionary(x => new StreamDataKey(x.Key.Type, x.Key.Name), + x => x.Select(fs => fs.GuildId).ToHashSet()); + } + } + + _pubSub.Sub(StreamsOfflineKey, HandleStreamsOffline); + _pubSub.Sub(StreamsOnlineKey, HandleStreamsOnline); + + if (client.ShardId == 0) + { + // only shard 0 will run the tracker, + // and then publish updates with redis to other shards + _streamTracker.OnStreamsOffline += OnStreamsOffline; + _streamTracker.OnStreamsOnline += OnStreamsOnline; + _ = _streamTracker.RunAsync(); + + _pubSub.Sub(_streamFollowKey, HandleFollowStream); + _pubSub.Sub(_streamUnfollowKey, HandleUnfollowStream); + } + + bot.JoinedGuild += ClientOnJoinedGuild; + client.LeftGuild += ClientOnLeftGuild; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30)); + while (await timer.WaitForNextTickAsync()) + { + try + { + var errorLimit = TimeSpan.FromHours(12); + var failingStreams = _streamTracker.GetFailingStreams(errorLimit, true).ToList(); + + if (!failingStreams.Any()) + continue; + + var deleteGroups = failingStreams.GroupBy(x => x.Type) + .ToDictionary(x => x.Key, x => x.Select(y => y.Name).ToList()); + + await using var uow = _db.GetDbContext(); + foreach (var kvp in deleteGroups) + { + Log.Information( + "Deleting {StreamCount} {Platform} streams because they've been erroring for more than {ErrorLimit}: {RemovedList}", + kvp.Value.Count, + kvp.Key, + errorLimit, + string.Join(", ", kvp.Value)); + + var toDelete = uow.Set() + .AsQueryable() + .Where(x => x.Type == kvp.Key && kvp.Value.Contains(x.Username)) + .ToList(); + + uow.RemoveRange(toDelete); + await uow.SaveChangesAsync(); + + foreach (var loginToDelete in kvp.Value) + _streamTracker.UntrackStreamByKey(new(kvp.Key, loginToDelete)); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error cleaning up FollowedStreams"); + } + } + } + + /// + /// Handles follow stream pubs to keep the counter up to date. + /// When counter reaches 0, stream is removed from tracking because + /// that means no guilds are subscribed to that stream anymore + /// + private ValueTask HandleFollowStream(FollowStreamPubData info) + { + _streamTracker.AddLastData(info.Key, null, false); + lock (_shardLock) + { + var key = info.Key; + if (_trackCounter.ContainsKey(key)) + _trackCounter[key].Add(info.GuildId); + else + { + _trackCounter[key] = [info.GuildId]; + } + } + + return default; + } + + /// + /// Handles unfollow pubs to keep the counter up to date. + /// When counter reaches 0, stream is removed from tracking because + /// that means no guilds are subscribed to that stream anymore + /// + private ValueTask HandleUnfollowStream(FollowStreamPubData info) + { + lock (_shardLock) + { + var key = info.Key; + if (!_trackCounter.TryGetValue(key, out var set)) + { + // it should've been removed already? + _streamTracker.UntrackStreamByKey(in key); + return default; + } + + set.Remove(info.GuildId); + if (set.Count != 0) + return default; + + _trackCounter.Remove(key); + // if no other guilds are following this stream + // untrack the stream + _streamTracker.UntrackStreamByKey(in key); + } + + return default; + } + + private async ValueTask HandleStreamsOffline(List offlineStreams) + { + foreach (var stream in offlineStreams) + { + var key = stream.CreateKey(); + if (_shardTrackedStreams.TryGetValue(key, out var fss)) + { + await fss + // send offline stream notifications only to guilds which enable it with .stoff + .SelectMany(x => x.Value) + .Where(x => _offlineNotificationServers.Contains(x.GuildId)) + .Select(fs => + { + var ch = _client.GetGuild(fs.GuildId) + ?.GetTextChannel(fs.ChannelId); + + if (ch is null) + return Task.CompletedTask; + + return _sender.Response(ch).Embed(GetEmbed(fs.GuildId, stream)).SendAsync(); + }) + .WhenAll(); + } + } + } + + + private async ValueTask HandleStreamsOnline(List onlineStreams) + { + foreach (var stream in onlineStreams) + { + var key = stream.CreateKey(); + if (_shardTrackedStreams.TryGetValue(key, out var fss)) + { + var messages = await fss.SelectMany(x => x.Value) + .Select(async fs => + { + var textChannel = _client.GetGuild(fs.GuildId) + ?.GetTextChannel(fs.ChannelId); + + if (textChannel is null) + return default; + + var repCtx = new ReplacementContext(guild: textChannel.Guild, + client: _client) + .WithOverride("%platform%", () => fs.Type.ToString()); + + + var message = string.IsNullOrWhiteSpace(fs.Message) + ? "" + : await _repSvc.ReplaceAsync(fs.Message, repCtx); + + var msg = await _sender.Response(textChannel) + .Embed(GetEmbed(fs.GuildId, stream, false)) + .Text(message) + .Sanitize(false) + .SendAsync(); + + // only cache the ids of channel/message pairs + if (_deleteOnOfflineServers.Contains(fs.GuildId)) + return (textChannel.Id, msg.Id); + else + return default; + }) + .WhenAll(); + + + // push online stream messages to redis + // when streams go offline, any server which + // has the online stream message deletion feature + // enabled will have the online messages deleted + try + { + var pairs = messages + .Where(x => x != default) + .Select(x => (x.Item1, x.Item2)) + .ToList(); + + if (pairs.Count > 0) + await OnlineMessagesSent(key.Type, key.Name, pairs); + } + catch + { + } + } + } + } + + private Task OnStreamsOnline(List data) + => _pubSub.Pub(StreamsOnlineKey, data); + + private Task OnStreamsOffline(List data) + => _pubSub.Pub(StreamsOfflineKey, data); + + private Task ClientOnJoinedGuild(GuildConfig guildConfig) + { + using (var uow = _db.GetDbContext()) + { + var gc = uow.Set() + .AsQueryable() + .Include(x => x.FollowedStreams) + .FirstOrDefault(x => x.GuildId == guildConfig.GuildId); + + if (gc is null) + return Task.CompletedTask; + + if (gc.NotifyStreamOffline) + _offlineNotificationServers.Add(gc.GuildId); + + foreach (var followedStream in gc.FollowedStreams) + { + var key = followedStream.CreateKey(); + var streams = GetLocalGuildStreams(key, gc.GuildId); + streams.Add(followedStream); + PublishFollowStream(followedStream); + } + } + + return Task.CompletedTask; + } + + private Task ClientOnLeftGuild(SocketGuild guild) + { + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.FollowedStreams)); + + _offlineNotificationServers.TryRemove(gc.GuildId); + + foreach (var followedStream in gc.FollowedStreams) + { + var streams = GetLocalGuildStreams(followedStream.CreateKey(), guild.Id); + streams.Remove(followedStream); + + PublishUnfollowStream(followedStream); + } + } + + return Task.CompletedTask; + } + + public async Task ClearAllStreams(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FollowedStreams)); + uow.RemoveRange(gc.FollowedStreams); + + foreach (var s in gc.FollowedStreams) + await PublishUnfollowStream(s); + + uow.SaveChanges(); + + return gc.FollowedStreams.Count; + } + + public async Task UnfollowStreamAsync(ulong guildId, int index) + { + FollowedStream fs; + await using (var uow = _db.GetDbContext()) + { + var fss = uow.Set() + .AsQueryable() + .Where(x => x.GuildId == guildId) + .OrderBy(x => x.Id) + .ToList(); + + // out of range + if (fss.Count <= index) + return null; + + fs = fss[index]; + uow.Remove(fs); + + await uow.SaveChangesAsync(); + + // remove from local cache + lock (_shardLock) + { + var key = fs.CreateKey(); + var streams = GetLocalGuildStreams(key, guildId); + streams.Remove(fs); + } + } + + await PublishUnfollowStream(fs); + + return fs; + } + + private void PublishFollowStream(FollowedStream fs) + => _pubSub.Pub(_streamFollowKey, + new() + { + Key = fs.CreateKey(), + GuildId = fs.GuildId + }); + + private Task PublishUnfollowStream(FollowedStream fs) + => _pubSub.Pub(_streamUnfollowKey, + new() + { + Key = fs.CreateKey(), + GuildId = fs.GuildId + }); + + public async Task FollowStream(ulong guildId, ulong channelId, string url) + { + // this will + var data = await _streamTracker.GetStreamDataByUrlAsync(url); + + if (data is null) + return null; + + FollowedStream fs; + await using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FollowedStreams)); + + // add it to the database + fs = new() + { + Type = data.StreamType, + Username = data.UniqueName, + ChannelId = channelId, + GuildId = guildId + }; + + var config = _config.Data; + if (config.FollowedStreams.MaxCount is not -1 + && gc.FollowedStreams.Count >= config.FollowedStreams.MaxCount) + return null; + + gc.FollowedStreams.Add(fs); + await uow.SaveChangesAsync(); + + // add it to the local cache of tracked streams + // this way this shard will know it needs to post a message to discord + // when shard 0 publishes stream status changes for this stream + lock (_shardLock) + { + var key = data.CreateKey(); + var streams = GetLocalGuildStreams(key, guildId); + streams.Add(fs); + } + } + + PublishFollowStream(fs); + + return data; + } + + public EmbedBuilder GetEmbed(ulong guildId, StreamData status, bool showViewers = true) + { + var embed = _sender.CreateEmbed() + .WithTitle(status.Name) + .WithUrl(status.StreamUrl) + .WithDescription(status.StreamUrl) + .AddField(GetText(guildId, strs.status), status.IsLive ? "🟢 Online" : "🔴 Offline", true); + + if (showViewers) + { + embed.AddField(GetText(guildId, strs.viewers), + status.Viewers == 0 && !status.IsLive + ? "-" + : status.Viewers, + true); + } + + if (status.IsLive) + embed = embed.WithOkColor(); + else + embed = embed.WithErrorColor(); + + if (!string.IsNullOrWhiteSpace(status.Title)) + embed.WithAuthor(status.Title); + + if (!string.IsNullOrWhiteSpace(status.Game)) + embed.AddField(GetText(guildId, strs.streaming), status.Game, true); + + if (!string.IsNullOrWhiteSpace(status.AvatarUrl)) + embed.WithThumbnailUrl(status.AvatarUrl); + + if (!string.IsNullOrWhiteSpace(status.Preview)) + embed.WithImageUrl(status.Preview + "?dv=" + _rng.Next()); + + return embed; + } + + private string GetText(ulong guildId, LocStr str) + => _strings.GetText(str, guildId); + + public bool ToggleStreamOffline(ulong guildId) + { + bool newValue; + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + newValue = gc.NotifyStreamOffline = !gc.NotifyStreamOffline; + uow.SaveChanges(); + + if (newValue) + _offlineNotificationServers.Add(guildId); + else + _offlineNotificationServers.TryRemove(guildId); + + return newValue; + } + + public bool ToggleStreamOnlineDelete(ulong guildId) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + var newValue = gc.DeleteStreamOnlineMessage = !gc.DeleteStreamOnlineMessage; + uow.SaveChanges(); + + if (newValue) + _deleteOnOfflineServers.Add(guildId); + else + _deleteOnOfflineServers.TryRemove(guildId); + + return newValue; + } + + public Task GetStreamDataAsync(string url) + => _streamTracker.GetStreamDataByUrlAsync(url); + + private HashSet GetLocalGuildStreams(in StreamDataKey key, ulong guildId) + { + if (_shardTrackedStreams.TryGetValue(key, out var map)) + { + if (map.TryGetValue(guildId, out var set)) + return set; + return map[guildId] = []; + } + + _shardTrackedStreams[key] = new() + { + { guildId, [] } + }; + return _shardTrackedStreams[key][guildId]; + } + + public bool SetStreamMessage( + ulong guildId, + int index, + string message, + out FollowedStream fs) + { + using var uow = _db.GetDbContext(); + var fss = uow.Set().AsQueryable().Where(x => x.GuildId == guildId).OrderBy(x => x.Id).ToList(); + + if (fss.Count <= index) + { + fs = null; + return false; + } + + fs = fss[index]; + fs.Message = message; + lock (_shardLock) + { + var streams = GetLocalGuildStreams(fs.CreateKey(), guildId); + + // message doesn't participate in equality checking + // removing and adding = update + streams.Remove(fs); + streams.Add(fs); + } + + uow.SaveChanges(); + + return true; + } + + public int SetStreamMessageForAll(ulong guildId, string message) + { + using var uow = _db.GetDbContext(); + + var all = uow.Set() + .Where(x => x.GuildId == guildId) + .ToList(); + + if (all.Count == 0) + return 0; + + all.ForEach(x => x.Message = message); + + uow.SaveChanges(); + + lock (_shardLock) + { + foreach (var fs in all) + { + var streams = GetLocalGuildStreams(fs.CreateKey(), guildId); + + // message doesn't participate in equality checking + // removing and adding = update + streams.Remove(fs); + streams.Add(fs); + } + } + + return all.Count; + } + + public sealed class FollowStreamPubData + { + public StreamDataKey Key { get; init; } + public ulong GuildId { get; init; } + } + + public async Task> GetAllStreamsAsync(SocketGuild guild) + { + var allStreams = new List(); + await using var uow = _db.GetDbContext(); + var all = uow.GuildConfigsForId(guild.Id, set => set.Include(gc => gc.FollowedStreams)) + .FollowedStreams + .OrderBy(x => x.Id) + .ToList(); + + for (var index = all.Count - 1; index >= 0; index--) + { + var fs = all[index]; + if (guild.GetTextChannel(fs.ChannelId) is null) + await UnfollowStreamAsync(fs.GuildId, index); + else + allStreams.Insert(0, fs); + } + + return allStreams; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs new file mode 100644 index 0000000..8ea24d6 --- /dev/null +++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs @@ -0,0 +1,99 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using EllieBot.Modules.Searches.Common; + +namespace EllieBot.Modules.Searches.Services; + +public sealed class StreamOnlineMessageDeleterService : INService, IReadyExecutor +{ + private readonly StreamNotificationService _notifService; + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IPubSub _pubSub; + + public StreamOnlineMessageDeleterService( + StreamNotificationService notifService, + DbService db, + IPubSub pubSub, + DiscordSocketClient client) + { + _notifService = notifService; + _db = db; + _client = client; + _pubSub = pubSub; + } + + public async Task OnReadyAsync() + { + _notifService.OnlineMessagesSent += OnOnlineMessagesSent; + + if (_client.ShardId == 0) + await _pubSub.Sub(_notifService.StreamsOfflineKey, OnStreamsOffline); + } + + private async Task OnOnlineMessagesSent( + FollowedStream.FType type, + string name, + IReadOnlyCollection<(ulong, ulong)> pairs) + { + await using var ctx = _db.GetDbContext(); + foreach (var (channelId, messageId) in pairs) + { + await ctx.GetTable() + .InsertAsync(() => new() + { + Name = name, + Type = type, + MessageId = messageId, + ChannelId = channelId, + DateAdded = DateTime.UtcNow, + }); + } + } + + private async ValueTask OnStreamsOffline(List streamDatas) + { + if (_client.ShardId != 0) + return; + + var pairs = await GetMessagesToDelete(streamDatas); + + foreach (var (channelId, messageId) in pairs) + { + try + { + var textChannel = await _client.GetChannelAsync(channelId) as ITextChannel; + if (textChannel is null) + continue; + + await textChannel.DeleteMessageAsync(messageId); + } + catch + { + continue; + } + } + } + + private async Task> GetMessagesToDelete(List streamDatas) + { + await using var ctx = _db.GetDbContext(); + + var toReturn = new List<(ulong, ulong)>(); + foreach (var sd in streamDatas) + { + var key = sd.CreateKey(); + var toDelete = await ctx.GetTable() + .Where(x => (x.Type == key.Type && x.Name == key.Name) + || Sql.DateDiff(Sql.DateParts.Day, x.DateAdded, DateTime.UtcNow) > 1) + .DeleteWithOutputAsync(); + + toReturn.AddRange(toDelete.Select(x => (x.ChannelId, x.MessageId))); + } + + return toReturn; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs b/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs new file mode 100644 index 0000000..6766b6f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public interface ITranslateService +{ + public Task Translate(string source, string target, string text = null); + Task ToggleAtl(ulong guildId, ulong channelId, bool autoDelete); + IEnumerable GetLanguages(); + + Task RegisterUserAsync( + ulong userId, + ulong channelId, + string from, + string to); + + Task UnregisterUser(ulong channelId, ulong userId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Translate/TranslateService.cs b/src/EllieBot/Modules/Searches/Translate/TranslateService.cs new file mode 100644 index 0000000..ab1a463 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Translate/TranslateService.cs @@ -0,0 +1,224 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using System.Net; + +namespace EllieBot.Modules.Searches; + +public sealed class TranslateService : ITranslateService, IExecNoCommand, IReadyExecutor, INService +{ + private readonly IGoogleApiService _google; + private readonly DbService _db; + private readonly IMessageSenderService _sender; + private readonly IBot _bot; + + private readonly ConcurrentDictionary _atcs = new(); + private readonly ConcurrentDictionary> _users = new(); + + public TranslateService( + IGoogleApiService google, + DbService db, + IMessageSenderService sender, + IBot bot) + { + _google = google; + _db = db; + _sender = sender; + _bot = bot; + } + + public async Task OnReadyAsync() + { + List cs; + await using (var ctx = _db.GetDbContext()) + { + var guilds = _bot.AllGuildConfigs.Select(x => x.GuildId).ToList(); + cs = await ctx.Set().Include(x => x.Users) + .Where(x => guilds.Contains(x.GuildId)) + .ToListAsyncEF(); + } + + foreach (var c in cs) + { + _atcs[c.ChannelId] = c.AutoDelete; + _users[c.ChannelId] = + new(c.Users.ToDictionary(x => x.UserId, x => (x.Source.ToLower(), x.Target.ToLower()))); + } + } + + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if (string.IsNullOrWhiteSpace(msg.Content)) + return; + + if (msg is { Channel: ITextChannel tch } um) + { + if (!_atcs.TryGetValue(tch.Id, out var autoDelete)) + return; + + if (!_users.TryGetValue(tch.Id, out var users) || !users.TryGetValue(um.Author.Id, out var langs)) + return; + + var output = await _google.Translate(msg.Content, langs.From, langs.To); + + if (string.IsNullOrWhiteSpace(output) + || msg.Content.Equals(output, StringComparison.InvariantCultureIgnoreCase)) + return; + + var embed = _sender.CreateEmbed().WithOkColor(); + + if (autoDelete) + { + embed.WithAuthor(um.Author.ToString(), um.Author.GetAvatarUrl()) + .AddField(langs.From, um.Content) + .AddField(langs.To, output); + + await _sender.Response(tch).Embed(embed).SendAsync(); + + try + { + await um.DeleteAsync(); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + _atcs.TryUpdate(tch.Id, false, true); + } + + return; + } + + await um.ReplyAsync(embed: embed.AddField(langs.To, output).Build(), allowedMentions: AllowedMentions.None); + } + } + + public async Task Translate(string source, string target, string text = null) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Text is empty or null", nameof(text)); + + var res = await _google.Translate(text, source.ToLowerInvariant(), target.ToLowerInvariant()); + return res.SanitizeMentions(true); + } + + public async Task ToggleAtl(ulong guildId, ulong channelId, bool autoDelete) + { + await using var ctx = _db.GetDbContext(); + + var old = await ctx.Set().ToLinqToDBTable() + .FirstOrDefaultAsyncLinqToDB(x => x.ChannelId == channelId); + + if (old is null) + { + ctx.Set().Add(new() + { + GuildId = guildId, + ChannelId = channelId, + AutoDelete = autoDelete + }); + + await ctx.SaveChangesAsync(); + + _atcs[channelId] = autoDelete; + _users[channelId] = new(); + + return true; + } + + // if autodelete value is different, update the autodelete value + // instead of disabling + if (old.AutoDelete != autoDelete) + { + old.AutoDelete = autoDelete; + await ctx.SaveChangesAsync(); + _atcs[channelId] = autoDelete; + return true; + } + + await ctx.Set().ToLinqToDBTable().DeleteAsync(x => x.ChannelId == channelId); + + await ctx.SaveChangesAsync(); + _atcs.TryRemove(channelId, out _); + _users.TryRemove(channelId, out _); + + return false; + } + + + private void UpdateUser( + ulong channelId, + ulong userId, + string from, + string to) + { + var dict = _users.GetOrAdd(channelId, new ConcurrentDictionary()); + dict[userId] = (from, to); + } + + public async Task RegisterUserAsync( + ulong userId, + ulong channelId, + string from, + string to) + { + if (!_google.Languages.ContainsKey(from) || !_google.Languages.ContainsKey(to)) + return null; + + await using var ctx = _db.GetDbContext(); + var ch = await ctx.Set().GetByChannelId(channelId); + + if (ch is null) + return null; + + var user = ch.Users.FirstOrDefault(x => x.UserId == userId); + + if (user is null) + { + ch.Users.Add(user = new() + { + Source = from, + Target = to, + UserId = userId + }); + + await ctx.SaveChangesAsync(); + + UpdateUser(channelId, userId, from, to); + + return true; + } + + // if it's different from old settings, update + if (user.Source != from || user.Target != to) + { + user.Source = from; + user.Target = to; + + await ctx.SaveChangesAsync(); + + UpdateUser(channelId, userId, from, to); + + return true; + } + + return await UnregisterUser(channelId, userId); + } + + public async Task UnregisterUser(ulong channelId, ulong userId) + { + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set().ToLinqToDBTable() + .DeleteAsync(x => x.UserId == userId && x.Channel.ChannelId == channelId); + + if (_users.TryGetValue(channelId, out var inner)) + inner.TryRemove(userId, out _); + + return rows > 0; + } + + public IEnumerable GetLanguages() + => _google.Languages.GroupBy(x => x.Value).Select(x => $"{x.AsEnumerable().Select(y => y.Key).Join(", ")}"); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs b/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs new file mode 100644 index 0000000..348ca61 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs @@ -0,0 +1,95 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class TranslateCommands : EllieModule + { + public enum AutoDeleteAutoTranslate + { + Del, + Nodel + } + + [Cmd] + public async Task Translate(string fromLang, string toLang, [Leftover] string text = null) + { + try + { + await ctx.Channel.TriggerTypingAsync(); + var translation = await _service.Translate(fromLang, toLang, text); + + var embed = _sender.CreateEmbed().WithOkColor().AddField(fromLang, text).AddField(toLang, translation); + + await Response().Embed(embed).SendAsync(); + } + catch + { + await Response().Error(strs.bad_input_format).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(ChannelPerm.ManageMessages)] + [OwnerOnly] + public async Task AutoTranslate(AutoDeleteAutoTranslate autoDelete = AutoDeleteAutoTranslate.Nodel) + { + var toggle = + await _service.ToggleAtl(ctx.Guild.Id, ctx.Channel.Id, autoDelete == AutoDeleteAutoTranslate.Del); + if (toggle) + await Response().Confirm(strs.atl_started).SendAsync(); + else + await Response().Confirm(strs.atl_stopped).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AutoTransLang() + { + if (await _service.UnregisterUser(ctx.Channel.Id, ctx.User.Id)) + await Response().Confirm(strs.atl_removed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AutoTransLang(string fromLang, string toLang) + { + var succ = await _service.RegisterUserAsync(ctx.User.Id, ctx.Channel.Id, fromLang.ToLower(), toLang.ToLower()); + + if (succ is null) + { + await Response().Error(strs.atl_not_enabled).SendAsync(); + return; + } + + if (succ is false) + { + await Response().Error(strs.invalid_lang).SendAsync(); + return; + } + + await Response().Confirm(strs.atl_set(fromLang, toLang)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Translangs() + { + var langs = _service.GetLanguages().ToList(); + + var eb = _sender.CreateEmbed() + .WithTitle(GetText(strs.supported_languages)) + .WithOkColor(); + + foreach (var chunk in langs.Chunk(15)) + { + eb.AddField("󠀁", chunk.Join("\n"), inline: true); + } + + await Response().Embed(eb).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/XkcdCommands.cs b/src/EllieBot/Modules/Searches/XkcdCommands.cs new file mode 100644 index 0000000..d913a87 --- /dev/null +++ b/src/EllieBot/Modules/Searches/XkcdCommands.cs @@ -0,0 +1,97 @@ +#nullable disable +using Newtonsoft.Json; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class XkcdCommands : EllieModule + { + private const string XKCD_URL = "https://xkcd.com"; + private readonly IHttpClientFactory _httpFactory; + + public XkcdCommands(IHttpClientFactory factory) + => _httpFactory = factory; + + [Cmd] + [Priority(0)] + public async Task Xkcd(string arg = null) + { + if (arg?.ToLowerInvariant().Trim() == "latest") + { + try + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync($"{XKCD_URL}/info.0.json"); + var comic = JsonConvert.DeserializeObject(res); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithImageUrl(comic.ImageLink) + .WithAuthor(comic.Title, "https://xkcd.com/s/919f27.ico", $"{XKCD_URL}/{comic.Num}") + .AddField(GetText(strs.comic_number), comic.Num.ToString(), true) + .AddField(GetText(strs.date), $"{comic.Month}/{comic.Year}", true); + var sent = await Response().Embed(embed).SendAsync(); + + await Task.Delay(10000); + + await sent.ModifyAsync(m => m.Embed = embed.AddField("Alt", comic.Alt).Build()); + } + catch (HttpRequestException) + { + await Response().Error(strs.comic_not_found).SendAsync(); + } + + return; + } + + await Xkcd(new EllieRandom().Next(1, 1750)); + } + + [Cmd] + [Priority(1)] + public async Task Xkcd(int num) + { + if (num < 1) + return; + try + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync($"{XKCD_URL}/{num}/info.0.json"); + + var comic = JsonConvert.DeserializeObject(res); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithImageUrl(comic.ImageLink) + .WithAuthor(comic.Title, "https://xkcd.com/s/919f27.ico", $"{XKCD_URL}/{num}") + .AddField(GetText(strs.comic_number), comic.Num.ToString(), true) + .AddField(GetText(strs.date), $"{comic.Month}/{comic.Year}", true); + + var sent = await Response().Embed(embed).SendAsync(); + + await Task.Delay(10000); + + await sent.ModifyAsync(m => m.Embed = embed.AddField("Alt", comic.Alt).Build()); + } + catch (HttpRequestException) + { + await Response().Error(strs.comic_not_found).SendAsync(); + } + } + } + + public class XkcdComic + { + public int Num { get; set; } + public string Month { get; set; } + public string Year { get; set; } + + [JsonProperty("safe_title")] + public string Title { get; set; } + + [JsonProperty("img")] + public string ImageLink { get; set; } + + public string Alt { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs b/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs new file mode 100644 index 0000000..9b20fee --- /dev/null +++ b/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs @@ -0,0 +1,134 @@ +#nullable disable + +// public class YtTrackService : INService +// { +// private readonly IGoogleApiService _google; +// private readonly IHttpClientFactory httpClientFactory; +// private readonly DiscordSocketClient _client; +// private readonly DbService _db; +// private readonly ConcurrentDictionary>> followedChannels; +// private readonly ConcurrentDictionary _latestPublishes = new ConcurrentDictionary(); +// +// public YtTrackService(IGoogleApiService google, IHttpClientFactory httpClientFactory, DiscordSocketClient client, +// DbService db) +// { +// this._google = google; +// this.httpClientFactory = httpClientFactory; +// this._client = client; +// this._db = db; +// +// if (_client.ShardId == 0) +// { +// _ = CheckLoop(); +// } +// } +// +// public async Task CheckLoop() +// { +// while (true) +// { +// await Task.Delay(10000); +// using (var http = httpClientFactory.CreateClient()) +// { +// await followedChannels.Select(kvp => CheckChannel(kvp.Key, kvp.Value.SelectMany(x => x.Value).ToList())).WhenAll(); +// } +// } +// } +// +// /// +// /// Checks the specified youtube channel, and sends a message to all provided +// /// +// /// Id of the youtube channel +// /// Where to post updates if there is a new update +// private async Task CheckChannel(string youtubeChannelId, List followedChannels) +// { +// var latestVid = (await _google.GetLatestChannelVideosAsync(youtubeChannelId, 1)) +// .FirstOrDefault(); +// if (latestVid is null) +// { +// return; +// } +// +// if (_latestPublishes.TryGetValue(youtubeChannelId, out var latestPub) && latestPub >= latestVid.PublishedAt) +// { +// return; +// } +// _latestPublishes[youtubeChannelId] = latestVid.PublishedAt; +// +// foreach (var chObj in followedChannels) +// { +// var gCh = _client.GetChannel(chObj.ChannelId); +// if (gCh is ITextChannel ch) +// { +// var msg = latestVid.GetVideoUrl(); +// if (!string.IsNullOrWhiteSpace(chObj.UploadMessage)) +// msg = chObj.UploadMessage + Environment.NewLine + msg; +// +// await ch.SendMessageAsync(msg); +// } +// } +// } +// +// /// +// /// Starts posting updates on the specified discord channel when a new video is posted on the specified YouTube channel. +// /// +// /// Id of the discord guild +// /// Id of the discord channel +// /// Id of the youtube channel +// /// Message to post when a new video is uploaded, along with video URL +// /// Whether adding was successful +// public async Task ToggleChannelFollowAsync(ulong guildId, ulong channelId, string ytChannelId, string uploadMessage) +// { +// // to to see if we can get a video from that channel +// var vids = await _google.GetLatestChannelVideosAsync(ytChannelId, 1); +// if (vids.Count == 0) +// return false; +// +// using(var uow = _db.GetDbContext()) +// { +// var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.YtFollowedChannels)); +// +// // see if this yt channel was already followed on this discord channel +// var oldObj = gc.YtFollowedChannels +// .FirstOrDefault(x => x.ChannelId == channelId && x.YtChannelId == ytChannelId); +// +// if(oldObj is not null) +// { +// return false; +// } +// +// // can only add up to 10 tracked channels per server +// if (gc.YtFollowedChannels.Count >= 10) +// { +// return false; +// } +// +// var obj = new YtFollowedChannel +// { +// ChannelId = channelId, +// YtChannelId = ytChannelId, +// UploadMessage = uploadMessage +// }; +// +// // add to database +// gc.YtFollowedChannels.Add(obj); +// +// // add to the local cache: +// +// // get follows on all guilds +// var allGuildFollows = followedChannels.GetOrAdd(ytChannelId, new ConcurrentDictionary>()); +// // add to this guild's follows +// allGuildFollows.AddOrUpdate(guildId, +// new List(), +// (key, old) => +// { +// old.Add(obj); +// return old; +// }); +// +// await uow.SaveChangesAsync(); +// } +// +// return true; +// } +// } \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs b/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs new file mode 100644 index 0000000..2439ad4 --- /dev/null +++ b/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs @@ -0,0 +1,54 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + // [Group] + // public partial class YtTrackCommands : EllieModule + // { + // ; + // [RequireContext(ContextType.Guild)] + // public async Task YtFollow(string ytChannelId, [Leftover] string uploadMessage = null) + // { + // var succ = await _service.ToggleChannelFollowAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage); + // if(succ) + // { + // await Response().Confirm(strs.yt_follow_added).SendAsync(); + // } + // else + // { + // await Response().Confirm(strs.yt_follow_fail).SendAsync(); + // } + // } + // + // [EllieCommand, Usage, Description, Aliases] + // [RequireContext(ContextType.Guild)] + // public async Task YtTrackRm(int index) + // { + // //var succ = await _service.ToggleChannelTrackingAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage); + // //if (succ) + // //{ + // // await Response().Confirm(strs.yt_track_added).SendAsync(); + // //} + // //else + // //{ + // // await Response().Confirm(strs.yt_track_fail).SendAsync(); + // //} + // } + // + // [EllieCommand, Usage, Description, Aliases] + // [RequireContext(ContextType.Guild)] + // public async Task YtTrackList() + // { + // //var succ = await _service.ToggleChannelTrackingAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage); + // //if (succ) + // //{ + // // await Response().Confirm(strs.yt_track_added).SendAsync(); + // //} + // //else + // //{ + // // await Response().Confirm(strs.yt_track_fail).SendAsync(); + // //} + // } + // } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs b/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs new file mode 100644 index 0000000..e8ab960 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs @@ -0,0 +1,12 @@ +#nullable disable +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Searches; + +public static class AtlExtensions +{ + public static Task GetByChannelId(this IQueryable set, ulong channelId) + => set.Include(x => x.Users).FirstOrDefaultAsyncEF(x => x.ChannelId == channelId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs b/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs new file mode 100644 index 0000000..b34fb36 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public enum ImgSearchEngine +{ + Google, + Searx, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs new file mode 100644 index 0000000..8cb0227 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs @@ -0,0 +1,85 @@ +using Cloneable; +using EllieBot.Common.Yml; + +namespace EllieBot.Modules.Searches; + +[Cloneable] +public partial class SearchesConfig : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 3; + + [Comment(""" + Which engine should .search command + 'google_scrape' - default. Scrapes the webpage for results. May break. Requires no api keys. + 'google' - official google api. Requires googleApiKey and google.searchId set in creds.yml + 'searx' - requires at least one searx instance specified in the 'searxInstances' property below + """)] + public WebSearchEngine WebSearchEngine { get; set; } = WebSearchEngine.Google_Scrape; + + [Comment(""" + Which engine should .image command use + 'google'- official google api. googleApiKey and google.imageSearchId set in creds.yml + 'searx' requires at least one searx instance specified in the 'searxInstances' property below + """)] + public ImgSearchEngine ImgSearchEngine { get; set; } = ImgSearchEngine.Google; + + + [Comment(""" + Which search provider will be used for the `.youtube` and `.q` commands. + + - `ytDataApiv3` - uses google's official youtube data api. Requires `GoogleApiKey` set in creds and youtube data api enabled in developers console + + - `ytdl` - default, uses youtube-dl. Requires `youtube-dl` to be installed and it's path added to env variables. Slow. + + - `ytdlp` - recommended easy, uses `yt-dlp`. Requires `yt-dlp` to be installed and it's path added to env variables + + - `invidious` - recommended advanced, uses invidious api. Requires at least one invidious instance specified in the `invidiousInstances` property + """)] + public YoutubeSearcher YtProvider { get; set; } = YoutubeSearcher.Ytdlp; + + [Comment(""" + Set the searx instance urls in case you want to use 'searx' for either img or web search. + Ellie will use a random one for each request. + Use a fully qualified url. Example: `https://my-searx-instance.mydomain.com` + Instances specified must support 'format=json' query parameter. + - In case you're running your own searx instance, set + + search: + formats: + - json + + in 'searxng/settings.yml' on your server + + - If you're using a public instance, make sure that the instance you're using supports it (they usually don't) + """)] + public List SearxInstances { get; set; } = new List(); + + [Comment(""" + Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search + Ellie will use a random one for each request. + Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com + + Instances specified must have api available. + You check that by opening an api endpoint in your browser. For example: https://my-invidious-instance.mydomain.com/api/v1/trending + """)] + public List InvidiousInstances { get; set; } = new List(); + + [Comment("Maximum number of followed streams per server")] + public FollowedStreamConfig FollowedStreams { get; set; } = new FollowedStreamConfig(); +} + +public sealed class FollowedStreamConfig +{ + [Comment("Maximum number of streams that each server can follow. -1 for infinite")] + public int MaxCount { get; set; } = 10; +} + +public enum YoutubeSearcher +{ + YtDataApiv3, + Ytdl, + Ytdlp, + Invid, + Invidious = 3 +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs new file mode 100644 index 0000000..432d116 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs @@ -0,0 +1,66 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Modules.Searches; + +public class SearchesConfigService : ConfigServiceBase +{ + private static string FILE_PATH = "data/searches.yml"; + private static readonly TypedKey _changeKey = new("config.searches.updated"); + + public override string Name + => "searches"; + + public SearchesConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("webEngine", + sc => sc.WebSearchEngine, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + + AddParsedProp("imgEngine", + sc => sc.ImgSearchEngine, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + + AddParsedProp("ytProvider", + sc => sc.YtProvider, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + + AddParsedProp("followedStreams.maxCount", + sc => sc.FollowedStreams.MaxCount, + int.TryParse, + ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 1) + { + ModifyConfig(c => + { + c.Version = 1; + c.WebSearchEngine = WebSearchEngine.Google_Scrape; + }); + } + + if (data.Version < 2) + { + ModifyConfig(c => + { + c.Version = 2; + }); + } + + if (data.Version < 3) + { + ModifyConfig(c => + { + c.Version = 3; + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs b/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs new file mode 100644 index 0000000..e924f03 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs @@ -0,0 +1,9 @@ +// ReSharper disable InconsistentNaming +namespace EllieBot.Modules.Searches; + +public enum WebSearchEngine +{ + Google, + Google_Scrape, + Searx, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/CryptoData.cs b/src/EllieBot/Modules/Searches/_common/CryptoData.cs new file mode 100644 index 0000000..6600b59 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/CryptoData.cs @@ -0,0 +1,66 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class CryptoResponse +{ + public List Data { get; set; } +} + +public class CmcQuote +{ + [JsonPropertyName("price")] + public double Price { get; set; } + + [JsonPropertyName("volume_24h")] + public double Volume24h { get; set; } + + // [JsonPropertyName("volume_change_24h")] + // public double VolumeChange24h { get; set; } + // + // [JsonPropertyName("percent_change_1h")] + // public double PercentChange1h { get; set; } + + [JsonPropertyName("percent_change_24h")] + public double PercentChange24h { get; set; } + + [JsonPropertyName("percent_change_7d")] + public double PercentChange7d { get; set; } + + [JsonPropertyName("market_cap")] + public double MarketCap { get; set; } + + [JsonPropertyName("market_cap_dominance")] + public double MarketCapDominance { get; set; } +} + +public class CmcResponseData +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("symbol")] + public string Symbol { get; set; } + + [JsonPropertyName("slug")] + public string Slug { get; set; } + + [JsonPropertyName("cmc_rank")] + public int CmcRank { get; set; } + + [JsonPropertyName("circulating_supply")] + public double? CirculatingSupply { get; set; } + + [JsonPropertyName("total_supply")] + public double? TotalSupply { get; set; } + + [JsonPropertyName("max_supply")] + public double? MaxSupply { get; set; } + + [JsonPropertyName("quote")] + public Dictionary Quote { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/DefineData.cs b/src/EllieBot/Modules/Searches/_common/DefineData.cs new file mode 100644 index 0000000..2698d50 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/DefineData.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Searches.Services; + +public sealed class DefineData +{ + public required string Definition { get; init; } + public required string Example { get; init; } + public required string WordType { get; init; } + public required string Word { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/DefineModel.cs b/src/EllieBot/Modules/Searches/_common/DefineModel.cs new file mode 100644 index 0000000..a0e2018 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/DefineModel.cs @@ -0,0 +1,43 @@ +#nullable disable +using Newtonsoft.Json; + +namespace EllieBot.Modules.Searches.Common; + +public class Audio +{ + public string Url { get; set; } +} + +public class Example +{ + public List