Added Searches module
This commit is contained in:
parent
c4ba5e5593
commit
a6939b2220
131 changed files with 8370 additions and 0 deletions
41
src/EllieBot/Modules/Searches/Anime/AnimeResult.cs
Normal file
41
src/EllieBot/Modules/Searches/Anime/AnimeResult.cs
Normal file
|
@ -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)] + "...";
|
||||||
|
}
|
77
src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs
Normal file
77
src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs
Normal file
|
@ -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<AnimeSearchService>
|
||||||
|
{
|
||||||
|
[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("<br>",
|
||||||
|
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("<br>",
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
79
src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs
Normal file
79
src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs
Normal file
|
@ -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<AnimeResult> GetAnimeData(string query)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
throw new ArgumentNullException(nameof(query));
|
||||||
|
|
||||||
|
TypedKey<AnimeResult> GetKey(string link)
|
||||||
|
=> new TypedKey<AnimeResult>($"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<AnimeResult>(link);
|
||||||
|
|
||||||
|
await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MangaResult> GetMangaData(string query)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
throw new ArgumentNullException(nameof(query));
|
||||||
|
|
||||||
|
TypedKey<MangaResult> GetKey(string link)
|
||||||
|
=> new TypedKey<MangaResult>($"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<MangaResult>(link);
|
||||||
|
|
||||||
|
await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
src/EllieBot/Modules/Searches/Anime/MangaResult.cs
Normal file
40
src/EllieBot/Modules/Searches/Anime/MangaResult.cs
Normal file
|
@ -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)] + "...";
|
||||||
|
}
|
231
src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs
Normal file
231
src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs
Normal file
|
@ -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<CryptoService>
|
||||||
|
{
|
||||||
|
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 ? "+" : "";
|
||||||
|
}
|
||||||
|
}
|
266
src/EllieBot/Modules/Searches/Crypto/CryptoService.cs
Normal file
266
src/EllieBot/Modules/Searches/Crypto/CryptoService.cs
Normal file
|
@ -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<PointF>();
|
||||||
|
|
||||||
|
Span<PointF> 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<PointF>();
|
||||||
|
|
||||||
|
return points.Slice(0, cnt).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SixLabors.ImageSharp.Image<Rgba32> GenerateSparklineChart(PointF[] points, bool up)
|
||||||
|
{
|
||||||
|
const int width = 164;
|
||||||
|
const int height = 48;
|
||||||
|
|
||||||
|
var img = new Image<Rgba32>(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<List<CmcResponseData>?> 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<CryptoResponse>(
|
||||||
|
"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<byte[]> GetSparklineKey(int id)
|
||||||
|
=> new($"crypto:sparkline:{id}");
|
||||||
|
|
||||||
|
public async Task<Stream?> 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<PointF[]> 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<IReadOnlyCollection<GeckoCoinsResult>> GetTopCoinsKey()
|
||||||
|
=> new($"crypto:top_coins");
|
||||||
|
|
||||||
|
public async Task<IReadOnlyCollection<GeckoCoinsResult>?> GetTopCoins(int page)
|
||||||
|
{
|
||||||
|
if (page >= 25)
|
||||||
|
page = 24;
|
||||||
|
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
|
||||||
|
http.AddFakeHeaders();
|
||||||
|
|
||||||
|
var result = await _cache.GetOrAddAsync<IReadOnlyCollection<GeckoCoinsResult>>(GetTopCoinsKey(),
|
||||||
|
async () => await http.GetFromJsonAsync<List<GeckoCoinsResult>>(
|
||||||
|
"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; }
|
||||||
|
}
|
112
src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs
Normal file
112
src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs
Normal file
|
@ -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<StockData?> 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<IReadOnlyCollection<SymbolData>> 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<YahooFinanceSearchResponse>(res);
|
||||||
|
|
||||||
|
if (data is null or { Items: null })
|
||||||
|
return Array.Empty<SymbolData>();
|
||||||
|
|
||||||
|
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<IReadOnlyCollection<CandleData>> 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<YahooFinanceCandleData>().ToArray();
|
||||||
|
|
||||||
|
return records
|
||||||
|
.Map(static x => new CandleData(x.Open, x.Close, x.High, x.Low, x.Volume));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All data required to draw a candle
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="IsGreen">Whether the candle is green</param>
|
||||||
|
/// <param name="BodyRect">Rectangle for the body</param>
|
||||||
|
/// <param name="High">High line point</param>
|
||||||
|
/// <param name="Low">Low line point</param>
|
||||||
|
public record CandleDrawingData(bool IsGreen, RectangleF BodyRect, PointF High, PointF Low);
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public interface IStockChartDrawingService
|
||||||
|
{
|
||||||
|
Task<ImageData?> GenerateSparklineAsync(IReadOnlyCollection<CandleData> series);
|
||||||
|
Task<ImageData?> GenerateCombinedChartAsync(IReadOnlyCollection<CandleData> series);
|
||||||
|
Task<ImageData?> GenerateCandleChartAsync(IReadOnlyCollection<CandleData> series);
|
||||||
|
}
|
|
@ -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<CandleData> 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<Rgba32> CreateCanvasInternal()
|
||||||
|
=> new Image<Rgba32>(WIDTH, HEIGHT, _backgroundColor);
|
||||||
|
|
||||||
|
private CandleDrawingData[] GetChartDrawingDataInternal(IReadOnlyCollection<CandleData> 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<Rgba32> 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<Rgba32> image, IReadOnlyCollection<CandleData> 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<ImageData?> GenerateSparklineAsync(IReadOnlyCollection<CandleData> series)
|
||||||
|
{
|
||||||
|
if (series.Count == 0)
|
||||||
|
return Task.FromResult<ImageData?>(default);
|
||||||
|
|
||||||
|
using var image = CreateCanvasInternal();
|
||||||
|
|
||||||
|
var points = GetSparklinePointsInternal(series);
|
||||||
|
|
||||||
|
image.Mutate(ctx =>
|
||||||
|
{
|
||||||
|
ctx.DrawLine(_sparklineColor, 2, points);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.FromResult<ImageData?>(new("png", image.ToStream()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ImageData?> GenerateCombinedChartAsync(IReadOnlyCollection<CandleData> series)
|
||||||
|
{
|
||||||
|
if (series.Count == 0)
|
||||||
|
return Task.FromResult<ImageData?>(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<ImageData?>(new("png", image.ToStream()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ImageData?> GenerateCandleChartAsync(IReadOnlyCollection<CandleData> series)
|
||||||
|
{
|
||||||
|
if (series.Count == 0)
|
||||||
|
return Task.FromResult<ImageData?>(default);
|
||||||
|
|
||||||
|
using var image = CreateCanvasInternal();
|
||||||
|
|
||||||
|
DrawLineGuides(image, series);
|
||||||
|
|
||||||
|
var drawData = GetChartDrawingDataInternal(series);
|
||||||
|
DrawChartData(image, drawData);
|
||||||
|
|
||||||
|
return Task.FromResult<ImageData?>(new("png", image.ToStream()));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public interface IStockDataService
|
||||||
|
{
|
||||||
|
public Task<StockData?> GetStockDataAsync(string symbol);
|
||||||
|
Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query);
|
||||||
|
Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query);
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public record CandleData(
|
||||||
|
decimal Open,
|
||||||
|
decimal Close,
|
||||||
|
decimal High,
|
||||||
|
decimal Low,
|
||||||
|
long Volume);
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public record ImageData(string Extension, Stream FileData) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
=> FileData.DisposeAsync();
|
||||||
|
}
|
|
@ -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<ResultModel> Result { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public object Error { get; set; }
|
||||||
|
}
|
15
src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs
Normal file
15
src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs
Normal file
|
@ -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; }
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public record SymbolData(string Symbol, string Description);
|
|
@ -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; }
|
||||||
|
}
|
|
@ -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<string> SuggestionMeta { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("hiConf")]
|
||||||
|
public bool HiConf { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public List<YahooFinanceSearchResponseItem> Items { get; set; }
|
||||||
|
}
|
|
@ -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; }
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public class YahooQueryModel
|
||||||
|
{
|
||||||
|
[JsonPropertyName("quoteResponse")]
|
||||||
|
public QuoteResponse QuoteResponse { get; set; } = null!;
|
||||||
|
}
|
148
src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs
Normal file
148
src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs
Normal file
|
@ -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<FeedsService>
|
||||||
|
{
|
||||||
|
private static readonly Regex _ytChannelRegex =
|
||||||
|
new(@"youtube\.com\/(?:c\/|channel\/|user\/)?(?<channelid>[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
309
src/EllieBot/Modules/Searches/Feeds/FeedsService.cs
Normal file
309
src/EllieBot/Modules/Searches/Feeds/FeedsService.cs
Normal file
|
@ -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<string, List<FeedSub>> _subs;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly IMessageSenderService _sender;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, DateTime> _lastPosts = new();
|
||||||
|
private readonly Dictionary<string, uint> _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<GuildConfig>()
|
||||||
|
.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<uint> AddError(string url, List<int> 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<FeedSub>()
|
||||||
|
.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<EmbedBuilder> TrackFeeds()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var allSendTasks = new List<Task>(_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<Task>();
|
||||||
|
|
||||||
|
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<FeedSub> 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,
|
||||||
|
}
|
53
src/EllieBot/Modules/Searches/JokeCommands.cs
Normal file
53
src/EllieBot/Modules/Searches/JokeCommands.cs
Normal file
|
@ -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<SearchesService>
|
||||||
|
{
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
124
src/EllieBot/Modules/Searches/Osu/OsuCommands.cs
Normal file
124
src/EllieBot/Modules/Searches/Osu/OsuCommands.cs
Normal file
|
@ -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<OsuService>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
227
src/EllieBot/Modules/Searches/Osu/OsuService.cs
Normal file
227
src/EllieBot/Modules/Searches/Osu/OsuService.cs
Normal file
|
@ -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<OsuUserData> 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<List<OsuUserData>>(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<GatariUserStatsResponse>(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<GatariUserResponse>(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<List<OsuUserBests>>(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<List<OsuMapData>>(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;
|
||||||
|
}
|
74
src/EllieBot/Modules/Searches/PokemonSearchCommands.cs
Normal file
74
src/EllieBot/Modules/Searches/PokemonSearchCommands.cs
Normal file
|
@ -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<SearchesService>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
src/EllieBot/Modules/Searches/Religious/Common/BibleVerse.cs
Normal file
13
src/EllieBot/Modules/Searches/Religious/Common/BibleVerse.cs
Normal file
|
@ -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; }
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public class BibleVerses
|
||||||
|
{
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public BibleVerse[]? Verses { get; set; }
|
||||||
|
}
|
19
src/EllieBot/Modules/Searches/Religious/Common/QuranAyah.cs
Normal file
19
src/EllieBot/Modules/Searches/Religious/Common/QuranAyah.cs
Normal file
|
@ -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; }
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public sealed class QuranResponse<T>
|
||||||
|
{
|
||||||
|
[JsonPropertyName("code")]
|
||||||
|
public required int Code { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public required string Status { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
public required T[] Data { get; set; }
|
||||||
|
}
|
|
@ -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<OneOf<BibleVerse, Error<string>>> GetBibleVerseAsync(string book, string chapterAndVerse)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(book) || string.IsNullOrWhiteSpace(chapterAndVerse))
|
||||||
|
return new Error<string>("Invalid input.");
|
||||||
|
|
||||||
|
|
||||||
|
book = Uri.EscapeDataString(book);
|
||||||
|
chapterAndVerse = Uri.EscapeDataString(chapterAndVerse);
|
||||||
|
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var res = await http.GetFromJsonAsync<BibleVerses>($"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<string>(res?.Error ?? "No verse found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Verses[0];
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
return new Error<string>("No verse found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OneOf<QuranResponse<QuranAyah>, Error<LocStr>>> GetQuranVerseAsync(string ayah)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ayah))
|
||||||
|
return new Error<LocStr>(strs.invalid_input);
|
||||||
|
|
||||||
|
ayah = Uri.EscapeDataString(ayah);
|
||||||
|
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
var res = await http.GetFromJsonAsync<QuranResponse<QuranAyah>>(
|
||||||
|
$"https://api.alquran.cloud/v1/ayah/{ayah}/editions/en.asad,ar.alafasy");
|
||||||
|
|
||||||
|
if (res is null or not { Code: 200 })
|
||||||
|
{
|
||||||
|
return new Error<LocStr>(strs.not_found);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
60
src/EllieBot/Modules/Searches/Religious/ReligiousCommands.cs
Normal file
60
src/EllieBot/Modules/Searches/Religious/ReligiousCommands.cs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public partial class Searches
|
||||||
|
{
|
||||||
|
public partial class ReligiousCommands : EllieModule<ReligiousApiService>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
|
@ -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<ISearchResultEntry> ISearchResult.Entries
|
||||||
|
=> Entries ?? Array.Empty<OfficialGoogleSearchResultEntry>();
|
||||||
|
|
||||||
|
[JsonPropertyName("searchInformation")]
|
||||||
|
public GoogleSearchResultInformation Info { get; init; } = null!;
|
||||||
|
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public IReadOnlyCollection<OfficialGoogleSearchResultEntry>? Entries { get; init; }
|
||||||
|
}
|
|
@ -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!;
|
||||||
|
}
|
|
@ -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<IImageSearchResultEntry> IImageSearchResult.Entries
|
||||||
|
=> Entries ?? Array.Empty<GoogleImageResultEntry>();
|
||||||
|
|
||||||
|
[JsonPropertyName("searchInformation")]
|
||||||
|
public GoogleSearchResultInformation Info { get; init; } = null!;
|
||||||
|
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public IReadOnlyCollection<GoogleImageResultEntry>? Entries { get; init; }
|
||||||
|
}
|
|
@ -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!;
|
||||||
|
}
|
|
@ -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!;
|
||||||
|
}
|
|
@ -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<GoogleImageResult?> 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<GoogleImageResult>(stream);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ITask<GoogleCustomSearchResult?> 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<GoogleCustomSearchResult>(stream);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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!;
|
||||||
|
}
|
|
@ -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<ISearchResult?> 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, <nobr> 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<IImageSearchResult?> SearchImagesAsync(string query)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace EllieBot.Modules.Searches.GoogleScrape;
|
||||||
|
|
||||||
|
public class PlainGoogleScrapeSearchResult : ISearchResult
|
||||||
|
{
|
||||||
|
public required string? Answer { get; init; }
|
||||||
|
public required IReadOnlyCollection<ISearchResultEntry> Entries { get; init; }
|
||||||
|
public required ISearchResultInformation Info { get; init; }
|
||||||
|
}
|
|
@ -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!;
|
||||||
|
}
|
|
@ -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!;
|
||||||
|
}
|
13
src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs
Normal file
13
src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public interface IImageSearchResult
|
||||||
|
{
|
||||||
|
ISearchResultInformation Info { get; }
|
||||||
|
|
||||||
|
IReadOnlyCollection<IImageSearchResultEntry> Entries { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IImageSearchResultEntry
|
||||||
|
{
|
||||||
|
string Link { get; }
|
||||||
|
}
|
8
src/EllieBot/Modules/Searches/Search/ISearchResult.cs
Normal file
8
src/EllieBot/Modules/Searches/Search/ISearchResult.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public interface ISearchResult
|
||||||
|
{
|
||||||
|
string? Answer { get; }
|
||||||
|
IReadOnlyCollection<ISearchResultEntry> Entries { get; }
|
||||||
|
ISearchResultInformation Info { get; }
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public interface ISearchResultEntry
|
||||||
|
{
|
||||||
|
string Title { get; }
|
||||||
|
string Url { get; }
|
||||||
|
string DisplayUrl { get; }
|
||||||
|
string? Description { get; }
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public interface ISearchResultInformation
|
||||||
|
{
|
||||||
|
string TotalResults { get; }
|
||||||
|
string SearchTime { get; }
|
||||||
|
}
|
9
src/EllieBot/Modules/Searches/Search/ISearchService.cs
Normal file
9
src/EllieBot/Modules/Searches/Search/ISearchService.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
using MorseCode.ITask;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public interface ISearchService
|
||||||
|
{
|
||||||
|
ITask<ISearchResult?> SearchAsync(string? query);
|
||||||
|
ITask<IImageSearchResult?> SearchImagesAsync(string query);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
205
src/EllieBot/Modules/Searches/Search/SearchCommands.cs
Normal file
205
src/EllieBot/Modules/Searches/Search/SearchCommands.cs
Normal file
|
@ -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<EmbedBuilder>(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<string> 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<VideoInfo?> 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();
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
using MorseCode.ITask;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public abstract class SearchServiceBase : ISearchService
|
||||||
|
{
|
||||||
|
public abstract ITask<ISearchResult?> SearchAsync(string? query);
|
||||||
|
public abstract ITask<IImageSearchResult?> SearchImagesAsync(string query);
|
||||||
|
}
|
|
@ -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<IImageSearchResultEntry> Entries
|
||||||
|
=> Results;
|
||||||
|
|
||||||
|
[JsonPropertyName("results")]
|
||||||
|
public List<SearxImageSearchResultEntry> Results { get; set; } = new List<SearxImageSearchResultEntry>();
|
||||||
|
|
||||||
|
[JsonPropertyName("query")]
|
||||||
|
public string Query { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonPropertyName("number_of_results")]
|
||||||
|
public double NumberOfResults { get; set; }
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
30
src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs
Normal file
30
src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs
Normal file
|
@ -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<SearxUrlData> Urls { get; } = new List<SearxUrlData>();
|
||||||
|
//
|
||||||
|
// [JsonPropertyName("engine")]
|
||||||
|
// public string Engine { get; set; }
|
||||||
|
//
|
||||||
|
// [JsonPropertyName("engines")]
|
||||||
|
// public List<string> Engines { get; } = new List<string>();
|
||||||
|
//
|
||||||
|
// [JsonPropertyName("attributes")]
|
||||||
|
// public List<SearxSearchAttribute> Attributes { get; } = new List<SearxSearchAttribute>();
|
||||||
|
// }
|
|
@ -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; }
|
||||||
|
}
|
|
@ -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<SearxSearchResultEntry> Results { get; set; } = new List<SearxSearchResultEntry>();
|
||||||
|
|
||||||
|
[JsonPropertyName("answers")]
|
||||||
|
public List<string> Answers { get; set; } = new List<string>();
|
||||||
|
//
|
||||||
|
// [JsonPropertyName("corrections")]
|
||||||
|
// public List<object> Corrections { get; } = new List<object>();
|
||||||
|
|
||||||
|
// [JsonPropertyName("infoboxes")]
|
||||||
|
// public List<InfoboxModel> Infoboxes { get; } = new List<InfoboxModel>();
|
||||||
|
//
|
||||||
|
// [JsonPropertyName("suggestions")]
|
||||||
|
// public List<string> Suggestions { get; } = new List<string>();
|
||||||
|
|
||||||
|
// [JsonPropertyName("unresponsive_engines")]
|
||||||
|
// public List<object> UnresponsiveEngines { get; } = new List<object>();
|
||||||
|
|
||||||
|
|
||||||
|
public string SearchTime { get; set; } = null!;
|
||||||
|
|
||||||
|
public IReadOnlyCollection<ISearchResultEntry> Entries
|
||||||
|
=> Results;
|
||||||
|
|
||||||
|
public ISearchResultInformation Info
|
||||||
|
=> new SearxSearchResultInformation()
|
||||||
|
{
|
||||||
|
SearchTime = SearchTime,
|
||||||
|
TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture)
|
||||||
|
};
|
||||||
|
|
||||||
|
public string? Answer
|
||||||
|
=> Answers.FirstOrDefault();
|
||||||
|
}
|
|
@ -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<string> ParsedUrl { get; } = new List<string>();
|
||||||
|
//
|
||||||
|
// [JsonPropertyName("template")]
|
||||||
|
// public string Template { get; set; }
|
||||||
|
//
|
||||||
|
// [JsonPropertyName("engines")]
|
||||||
|
// public List<string> Engines { get; } = new List<string>();
|
||||||
|
//
|
||||||
|
// [JsonPropertyName("positions")]
|
||||||
|
// public List<int> Positions { get; } = new List<int>();
|
||||||
|
//
|
||||||
|
// [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; }
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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<SearxSearchResult> 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<SearxSearchResult>(res);
|
||||||
|
|
||||||
|
if (dat is null)
|
||||||
|
return new SearxSearchResult();
|
||||||
|
|
||||||
|
dat.SearchTime = elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture);
|
||||||
|
return dat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ITask<SearxImageSearchResult> 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<SearxImageSearchResult>(res);
|
||||||
|
|
||||||
|
if (dat is null)
|
||||||
|
return new SearxImageSearchResult();
|
||||||
|
|
||||||
|
dat.SearchTime = elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture);
|
||||||
|
return dat;
|
||||||
|
}
|
||||||
|
}
|
15
src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs
Normal file
15
src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs
Normal file
|
@ -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; }
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace EllieBot.Modules.Searches.Youtube;
|
||||||
|
|
||||||
|
public interface IYoutubeSearchService
|
||||||
|
{
|
||||||
|
Task<VideoInfo?> SearchAsync(string query);
|
||||||
|
}
|
|
@ -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<InvidiousThumbnail> 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<InvidiousThumbnail> Thumbnails { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("adaptiveFormats")]
|
||||||
|
public required List<InvidiousAdaptiveFormat> 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<InvidiousVideoResponse> Videos { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class InvidiousThumbnail
|
||||||
|
{
|
||||||
|
[JsonPropertyName("url")]
|
||||||
|
public required string Url { get; init; }
|
||||||
|
}
|
|
@ -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<VideoInfo?> 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<List<InvidiousSearchResponse>>(
|
||||||
|
url);
|
||||||
|
|
||||||
|
if (res is null or { Count: 0 })
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new VideoInfo(res[0].VideoId);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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; }
|
||||||
|
}
|
|
@ -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<VideoInfo?> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace EllieBot.Modules.Searches.Youtube;
|
||||||
|
|
||||||
|
public sealed class YtdlYoutubeSearchService : YoutubedlxServiceBase, INService
|
||||||
|
{
|
||||||
|
public override async Task<VideoInfo?> SearchAsync(string query)
|
||||||
|
=> await InternalGetInfoAsync(query, false);
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace EllieBot.Modules.Searches.Youtube;
|
||||||
|
|
||||||
|
public sealed class YtdlpYoutubeSearchService : YoutubedlxServiceBase, INService
|
||||||
|
{
|
||||||
|
public override async Task<VideoInfo?> SearchAsync(string query)
|
||||||
|
=> await InternalGetInfoAsync(query, true);
|
||||||
|
}
|
|
@ -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<VideoInfo?> 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<VideoInfo?> SearchAsync(string query);
|
||||||
|
}
|
461
src/EllieBot/Modules/Searches/Searches.cs
Normal file
461
src/EllieBot/Modules/Searches/Searches.cs
Normal file
|
@ -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<SearchesService>
|
||||||
|
{
|
||||||
|
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<UrbanResponse>(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<IUserMessage> 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<Rgba32>(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<bool> ValidateQuery([MaybeNullWhen(false)] string query)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
await Response().Error(strs.specify_search_params).SendAsync();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
616
src/EllieBot/Modules/Searches/SearchesService.cs
Normal file
616
src/EllieBot/Modules/Searches/SearchesService.cs
Normal file
|
@ -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<WoWJoke> WowJokes { get; } = [];
|
||||||
|
public List<MagicItem> 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<string> _yomamaJokes;
|
||||||
|
|
||||||
|
private readonly object _yomamaLock = new();
|
||||||
|
private int yomamaJokeIndex;
|
||||||
|
private readonly ConcurrentDictionary<string, string> _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<List<WoWJoke>>(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<List<MagicItem>>(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<WeatherData> 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<WeatherData> 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<WeatherData>(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<LocationIqResponse[]>(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<TimeZoneResult>(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<string> 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<string> 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<MtgData> 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<MtgData[]> GetMtgCardFactory(string search)
|
||||||
|
{
|
||||||
|
async Task<MtgData> 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 = "<url can't be found>"; }
|
||||||
|
|
||||||
|
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<MtgResponse>(response);
|
||||||
|
if (responseObject is null)
|
||||||
|
return Array.Empty<MtgData>();
|
||||||
|
|
||||||
|
var cards = responseObject.Cards.Take(5).ToArray();
|
||||||
|
if (cards.Length == 0)
|
||||||
|
return Array.Empty<MtgData>();
|
||||||
|
|
||||||
|
return await cards.Select(GetMtgDataAsync).WhenAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HearthstoneCardData> GetHearthstoneCardDataAsync(string name)
|
||||||
|
{
|
||||||
|
name = name.ToLowerInvariant();
|
||||||
|
return await _c.GetOrAddAsync($"hearthstone:{name}",
|
||||||
|
() => HearthstoneCardDataFactory(name),
|
||||||
|
TimeSpan.FromDays(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HearthstoneCardData> 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<HearthstoneCardData[]>(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<OmdbMovie> GetMovieDataAsync(string name)
|
||||||
|
{
|
||||||
|
name = name.Trim().ToLowerInvariant();
|
||||||
|
return await _c.GetOrAddAsync(new($"movie:{name}"),
|
||||||
|
() => GetMovieDataFactory(name),
|
||||||
|
TimeSpan.FromDays(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OmdbMovie> 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<OmdbMovie>(res);
|
||||||
|
if (movie?.Title is null)
|
||||||
|
return null;
|
||||||
|
movie.Poster = await _google.ShortenUrl(movie.Poster);
|
||||||
|
return movie;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> 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<SteamGameId>()
|
||||||
|
}
|
||||||
|
})!
|
||||||
|
.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<OneOf<WikipediaReply, ErrorType>> 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<WikipediaApiModel>(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<OneOf<string, ErrorType>> 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<OneOf<WikiaResponse, ErrorType>> 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<string> GetDefineKey(string query)
|
||||||
|
=> new TypedKey<string>($"define_{query}");
|
||||||
|
|
||||||
|
public async Task<OneOf<List<DefineData>, 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<DefineModel>(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<DefineData>();
|
||||||
|
|
||||||
|
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<string> 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<ShortenData>(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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<StreamNotificationService>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<StreamDataKey, HashSet<ulong>> _trackCounter = new();
|
||||||
|
|
||||||
|
private readonly Dictionary<StreamDataKey, Dictionary<ulong, HashSet<FollowedStream>>> _shardTrackedStreams;
|
||||||
|
private readonly ConcurrentHashSet<ulong> _offlineNotificationServers;
|
||||||
|
private readonly ConcurrentHashSet<ulong> _deleteOnOfflineServers;
|
||||||
|
|
||||||
|
private readonly IPubSub _pubSub;
|
||||||
|
private readonly IMessageSenderService _sender;
|
||||||
|
private readonly SearchesConfigService _config;
|
||||||
|
private readonly IReplacementService _repSvc;
|
||||||
|
|
||||||
|
public TypedKey<List<StreamData>> StreamsOnlineKey { get; }
|
||||||
|
public TypedKey<List<StreamData>> StreamsOfflineKey { get; }
|
||||||
|
|
||||||
|
private readonly TypedKey<FollowStreamPubData> _streamFollowKey;
|
||||||
|
private readonly TypedKey<FollowStreamPubData> _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<GuildConfig>()
|
||||||
|
.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<FollowedStream>().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<FollowedStream>()
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// </summary>
|
||||||
|
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<StreamData> 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<StreamData> 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<StreamData> data)
|
||||||
|
=> _pubSub.Pub(StreamsOnlineKey, data);
|
||||||
|
|
||||||
|
private Task OnStreamsOffline(List<StreamData> data)
|
||||||
|
=> _pubSub.Pub(StreamsOfflineKey, data);
|
||||||
|
|
||||||
|
private Task ClientOnJoinedGuild(GuildConfig guildConfig)
|
||||||
|
{
|
||||||
|
using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
var gc = uow.Set<GuildConfig>()
|
||||||
|
.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<int> 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<FollowedStream> UnfollowStreamAsync(ulong guildId, int index)
|
||||||
|
{
|
||||||
|
FollowedStream fs;
|
||||||
|
await using (var uow = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
var fss = uow.Set<FollowedStream>()
|
||||||
|
.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<StreamData> 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<StreamData> GetStreamDataAsync(string url)
|
||||||
|
=> _streamTracker.GetStreamDataByUrlAsync(url);
|
||||||
|
|
||||||
|
private HashSet<FollowedStream> 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<FollowedStream>().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<FollowedStream>()
|
||||||
|
.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<List<FollowedStream>> GetAllStreamsAsync(SocketGuild guild)
|
||||||
|
{
|
||||||
|
var allStreams = new List<FollowedStream>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<StreamOnlineMessage>()
|
||||||
|
.InsertAsync(() => new()
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Type = type,
|
||||||
|
MessageId = messageId,
|
||||||
|
ChannelId = channelId,
|
||||||
|
DateAdded = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask OnStreamsOffline(List<StreamData> 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<IEnumerable<(ulong, ulong)>> GetMessagesToDelete(List<StreamData> 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<StreamOnlineMessage>()
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
17
src/EllieBot/Modules/Searches/Translate/ITranslateService.cs
Normal file
17
src/EllieBot/Modules/Searches/Translate/ITranslateService.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public interface ITranslateService
|
||||||
|
{
|
||||||
|
public Task<string> Translate(string source, string target, string text = null);
|
||||||
|
Task<bool> ToggleAtl(ulong guildId, ulong channelId, bool autoDelete);
|
||||||
|
IEnumerable<string> GetLanguages();
|
||||||
|
|
||||||
|
Task<bool?> RegisterUserAsync(
|
||||||
|
ulong userId,
|
||||||
|
ulong channelId,
|
||||||
|
string from,
|
||||||
|
string to);
|
||||||
|
|
||||||
|
Task<bool> UnregisterUser(ulong channelId, ulong userId);
|
||||||
|
}
|
224
src/EllieBot/Modules/Searches/Translate/TranslateService.cs
Normal file
224
src/EllieBot/Modules/Searches/Translate/TranslateService.cs
Normal file
|
@ -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<ulong, bool> _atcs = new();
|
||||||
|
private readonly ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, (string From, string To)>> _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<AutoTranslateChannel> cs;
|
||||||
|
await using (var ctx = _db.GetDbContext())
|
||||||
|
{
|
||||||
|
var guilds = _bot.AllGuildConfigs.Select(x => x.GuildId).ToList();
|
||||||
|
cs = await ctx.Set<AutoTranslateChannel>().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<string> 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<bool> ToggleAtl(ulong guildId, ulong channelId, bool autoDelete)
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
|
||||||
|
var old = await ctx.Set<AutoTranslateChannel>().ToLinqToDBTable()
|
||||||
|
.FirstOrDefaultAsyncLinqToDB(x => x.ChannelId == channelId);
|
||||||
|
|
||||||
|
if (old is null)
|
||||||
|
{
|
||||||
|
ctx.Set<AutoTranslateChannel>().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<AutoTranslateChannel>().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<ulong, (string, string)>());
|
||||||
|
dict[userId] = (from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool?> 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<AutoTranslateChannel>().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<bool> UnregisterUser(ulong channelId, ulong userId)
|
||||||
|
{
|
||||||
|
await using var ctx = _db.GetDbContext();
|
||||||
|
var rows = await ctx.Set<AutoTranslateUser>().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<string> GetLanguages()
|
||||||
|
=> _google.Languages.GroupBy(x => x.Value).Select(x => $"{x.AsEnumerable().Select(y => y.Key).Join(", ")}");
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public partial class Searches
|
||||||
|
{
|
||||||
|
[Group]
|
||||||
|
public partial class TranslateCommands : EllieModule<ITranslateService>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
97
src/EllieBot/Modules/Searches/XkcdCommands.cs
Normal file
97
src/EllieBot/Modules/Searches/XkcdCommands.cs
Normal file
|
@ -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<XkcdComic>(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<XkcdComic>(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; }
|
||||||
|
}
|
||||||
|
}
|
134
src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs
Normal file
134
src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs
Normal file
|
@ -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<string, ConcurrentDictionary<ulong, List<YtFollowedChannel>>> followedChannels;
|
||||||
|
// private readonly ConcurrentDictionary<string, DateTime> _latestPublishes = new ConcurrentDictionary<string, DateTime>();
|
||||||
|
//
|
||||||
|
// 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();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /// <summary>
|
||||||
|
// /// Checks the specified youtube channel, and sends a message to all provided
|
||||||
|
// /// </summary>
|
||||||
|
// /// <param name="youtubeChannelId">Id of the youtube channel</param>
|
||||||
|
// /// <param name="followedChannels">Where to post updates if there is a new update</param>
|
||||||
|
// private async Task CheckChannel(string youtubeChannelId, List<YtFollowedChannel> 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);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /// <summary>
|
||||||
|
// /// Starts posting updates on the specified discord channel when a new video is posted on the specified YouTube channel.
|
||||||
|
// /// </summary>
|
||||||
|
// /// <param name="guildId">Id of the discord guild</param>
|
||||||
|
// /// <param name="channelId">Id of the discord channel</param>
|
||||||
|
// /// <param name="ytChannelId">Id of the youtube channel</param>
|
||||||
|
// /// <param name="uploadMessage">Message to post when a new video is uploaded, along with video URL</param>
|
||||||
|
// /// <returns>Whether adding was successful</returns>
|
||||||
|
// public async Task<bool> 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<ulong, List<YtFollowedChannel>>());
|
||||||
|
// // add to this guild's follows
|
||||||
|
// allGuildFollows.AddOrUpdate(guildId,
|
||||||
|
// new List<YtFollowedChannel>(),
|
||||||
|
// (key, old) =>
|
||||||
|
// {
|
||||||
|
// old.Add(obj);
|
||||||
|
// return old;
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// await uow.SaveChangesAsync();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
// }
|
|
@ -0,0 +1,54 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public partial class Searches
|
||||||
|
{
|
||||||
|
// [Group]
|
||||||
|
// public partial class YtTrackCommands : EllieModule<YtTrackService>
|
||||||
|
// {
|
||||||
|
// ;
|
||||||
|
// [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();
|
||||||
|
// //}
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
12
src/EllieBot/Modules/Searches/_common/AtlExtensions.cs
Normal file
12
src/EllieBot/Modules/Searches/_common/AtlExtensions.cs
Normal file
|
@ -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<AutoTranslateChannel> GetByChannelId(this IQueryable<AutoTranslateChannel> set, ulong channelId)
|
||||||
|
=> set.Include(x => x.Users).FirstOrDefaultAsyncEF(x => x.ChannelId == channelId);
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public enum ImgSearchEngine
|
||||||
|
{
|
||||||
|
Google,
|
||||||
|
Searx,
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
using Cloneable;
|
||||||
|
using EllieBot.Common.Yml;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
[Cloneable]
|
||||||
|
public partial class SearchesConfig : ICloneable<SearchesConfig>
|
||||||
|
{
|
||||||
|
[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<string> SearxInstances { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
[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<string> InvidiousInstances { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
[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
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
using EllieBot.Common.Configs;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public class SearchesConfigService : ConfigServiceBase<SearchesConfig>
|
||||||
|
{
|
||||||
|
private static string FILE_PATH = "data/searches.yml";
|
||||||
|
private static readonly TypedKey<SearchesConfig> _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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
// ReSharper disable InconsistentNaming
|
||||||
|
namespace EllieBot.Modules.Searches;
|
||||||
|
|
||||||
|
public enum WebSearchEngine
|
||||||
|
{
|
||||||
|
Google,
|
||||||
|
Google_Scrape,
|
||||||
|
Searx,
|
||||||
|
}
|
66
src/EllieBot/Modules/Searches/_common/CryptoData.cs
Normal file
66
src/EllieBot/Modules/Searches/_common/CryptoData.cs
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public class CryptoResponse
|
||||||
|
{
|
||||||
|
public List<CmcResponseData> 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<string, CmcQuote> Quote { get; set; }
|
||||||
|
}
|
10
src/EllieBot/Modules/Searches/_common/DefineData.cs
Normal file
10
src/EllieBot/Modules/Searches/_common/DefineData.cs
Normal file
|
@ -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; }
|
||||||
|
}
|
43
src/EllieBot/Modules/Searches/_common/DefineModel.cs
Normal file
43
src/EllieBot/Modules/Searches/_common/DefineModel.cs
Normal file
|
@ -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<Audio> Audio { get; set; }
|
||||||
|
public string Text { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GramaticalInfo
|
||||||
|
{
|
||||||
|
public string Type { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Sens
|
||||||
|
{
|
||||||
|
public object Definition { get; set; }
|
||||||
|
public List<Example> Examples { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("gramatical_info")]
|
||||||
|
public GramaticalInfo GramaticalInfo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Result
|
||||||
|
{
|
||||||
|
[JsonProperty("part_of_speech")]
|
||||||
|
public string PartOfSpeech { get; set; }
|
||||||
|
|
||||||
|
public List<Sens> Senses { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DefineModel
|
||||||
|
{
|
||||||
|
public List<Result> Results { get; set; }
|
||||||
|
}
|
24
src/EllieBot/Modules/Searches/_common/E621Object.cs
Normal file
24
src/EllieBot/Modules/Searches/_common/E621Object.cs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public class E621Object
|
||||||
|
{
|
||||||
|
public FileData File { get; set; }
|
||||||
|
public TagData Tags { get; set; }
|
||||||
|
public ScoreData Score { get; set; }
|
||||||
|
|
||||||
|
public class FileData
|
||||||
|
{
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TagData
|
||||||
|
{
|
||||||
|
public string[] General { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ScoreData
|
||||||
|
{
|
||||||
|
public string Total { get; set; }
|
||||||
|
}
|
||||||
|
}
|
9
src/EllieBot/Modules/Searches/_common/ErrorType.cs
Normal file
9
src/EllieBot/Modules/Searches/_common/ErrorType.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
namespace EllieBot.Modules.Searches.Services;
|
||||||
|
|
||||||
|
public enum ErrorType
|
||||||
|
{
|
||||||
|
InvalidInput,
|
||||||
|
NotFound,
|
||||||
|
Unknown,
|
||||||
|
ApiKeyMissing
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Searches.Common.Exceptions;
|
||||||
|
|
||||||
|
public class StreamNotFoundException : Exception
|
||||||
|
{
|
||||||
|
public StreamNotFoundException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public StreamNotFoundException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public StreamNotFoundException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
9
src/EllieBot/Modules/Searches/_common/Extensions.cs
Normal file
9
src/EllieBot/Modules/Searches/_common/Extensions.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
using EllieBot.Db.Models;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public static class Extensions
|
||||||
|
{
|
||||||
|
public static StreamDataKey CreateKey(this FollowedStream fs)
|
||||||
|
=> new(fs.Type, fs.Username.ToLower());
|
||||||
|
}
|
44
src/EllieBot/Modules/Searches/_common/Gallery.cs
Normal file
44
src/EllieBot/Modules/Searches/_common/Gallery.cs
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public sealed class Tag
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Gallery
|
||||||
|
{
|
||||||
|
public uint Id { get; }
|
||||||
|
public string Url { get; }
|
||||||
|
public string FullTitle { get; }
|
||||||
|
public string Title { get; }
|
||||||
|
public string Thumbnail { get; }
|
||||||
|
public int PageCount { get; }
|
||||||
|
public int Likes { get; }
|
||||||
|
public DateTime UploadedAt { get; }
|
||||||
|
public Tag[] Tags { get; }
|
||||||
|
|
||||||
|
|
||||||
|
public Gallery(
|
||||||
|
uint id,
|
||||||
|
string url,
|
||||||
|
string fullTitle,
|
||||||
|
string title,
|
||||||
|
string thumbnail,
|
||||||
|
int pageCount,
|
||||||
|
int likes,
|
||||||
|
DateTime uploadedAt,
|
||||||
|
Tag[] tags)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Url = url;
|
||||||
|
FullTitle = fullTitle;
|
||||||
|
Title = title;
|
||||||
|
Thumbnail = thumbnail;
|
||||||
|
PageCount = pageCount;
|
||||||
|
Likes = likes;
|
||||||
|
UploadedAt = uploadedAt;
|
||||||
|
Tags = tags;
|
||||||
|
}
|
||||||
|
}
|
52
src/EllieBot/Modules/Searches/_common/GatariUserResponse.cs
Normal file
52
src/EllieBot/Modules/Searches/_common/GatariUserResponse.cs
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
#nullable disable
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public class GatariUserData
|
||||||
|
{
|
||||||
|
[JsonProperty("abbr")]
|
||||||
|
public object Abbr { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("clanid")]
|
||||||
|
public object Clanid { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("country")]
|
||||||
|
public string Country { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("favourite_mode")]
|
||||||
|
public int FavouriteMode { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("followers_count")]
|
||||||
|
public int FollowersCount { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("id")]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("latest_activity")]
|
||||||
|
public int LatestActivity { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("play_style")]
|
||||||
|
public int PlayStyle { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("privileges")]
|
||||||
|
public int Privileges { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("registered_on")]
|
||||||
|
public int RegisteredOn { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("username")]
|
||||||
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("username_aka")]
|
||||||
|
public string UsernameAka { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GatariUserResponse
|
||||||
|
{
|
||||||
|
[JsonProperty("code")]
|
||||||
|
public int Code { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("users")]
|
||||||
|
public List<GatariUserData> Users { get; set; }
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
#nullable disable
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public class GatariUserStats
|
||||||
|
{
|
||||||
|
[JsonProperty("a_count")]
|
||||||
|
public int ACount { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("avg_accuracy")]
|
||||||
|
public double AvgAccuracy { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("avg_hits_play")]
|
||||||
|
public double AvgHitsPlay { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("country_rank")]
|
||||||
|
public int CountryRank { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("id")]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("level")]
|
||||||
|
public int Level { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("level_progress")]
|
||||||
|
public int LevelProgress { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("max_combo")]
|
||||||
|
public int MaxCombo { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("playcount")]
|
||||||
|
public int Playcount { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("playtime")]
|
||||||
|
public int Playtime { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("pp")]
|
||||||
|
public int Pp { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("rank")]
|
||||||
|
public int? Rank { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("ranked_score")]
|
||||||
|
public int RankedScore { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("replays_watched")]
|
||||||
|
public int ReplaysWatched { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("s_count")]
|
||||||
|
public int SCount { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("sh_count")]
|
||||||
|
public int ShCount { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("total_hits")]
|
||||||
|
public int TotalHits { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("total_score")]
|
||||||
|
public long TotalScore { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("x_count")]
|
||||||
|
public int XCount { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("xh_count")]
|
||||||
|
public int XhCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GatariUserStatsResponse
|
||||||
|
{
|
||||||
|
[JsonProperty("code")]
|
||||||
|
public int Code { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("stats")]
|
||||||
|
public GatariUserStats Stats { get; set; }
|
||||||
|
}
|
16
src/EllieBot/Modules/Searches/_common/GoogleSearchResult.cs
Normal file
16
src/EllieBot/Modules/Searches/_common/GoogleSearchResult.cs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public sealed class GoogleSearchResult
|
||||||
|
{
|
||||||
|
public string Title { get; }
|
||||||
|
public string Link { get; }
|
||||||
|
public string Text { get; }
|
||||||
|
|
||||||
|
public GoogleSearchResult(string title, string link, string text)
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Link = link;
|
||||||
|
Text = text;
|
||||||
|
}
|
||||||
|
}
|
13
src/EllieBot/Modules/Searches/_common/HearthstoneCardData.cs
Normal file
13
src/EllieBot/Modules/Searches/_common/HearthstoneCardData.cs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public class HearthstoneCardData
|
||||||
|
{
|
||||||
|
public string Text { get; set; }
|
||||||
|
public string Flavor { get; set; }
|
||||||
|
public bool Collectible { get; set; }
|
||||||
|
|
||||||
|
public string Img { get; set; }
|
||||||
|
public string ImgGold { get; set; }
|
||||||
|
public string PlayerClass { get; set; }
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
#nullable disable
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public class LowerCaseNamingPolicy : JsonNamingPolicy
|
||||||
|
{
|
||||||
|
public static LowerCaseNamingPolicy Default = new();
|
||||||
|
|
||||||
|
public override string ConvertName(string name)
|
||||||
|
=> name.ToLower();
|
||||||
|
}
|
8
src/EllieBot/Modules/Searches/_common/MagicItem.cs
Normal file
8
src/EllieBot/Modules/Searches/_common/MagicItem.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public class MagicItem
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
}
|
26
src/EllieBot/Modules/Searches/_common/MtgData.cs
Normal file
26
src/EllieBot/Modules/Searches/_common/MtgData.cs
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
#nullable disable
|
||||||
|
namespace EllieBot.Modules.Searches.Common;
|
||||||
|
|
||||||
|
public class MtgData
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
public string ImageUrl { get; set; }
|
||||||
|
public string StoreUrl { get; set; }
|
||||||
|
public string Types { get; set; }
|
||||||
|
public string ManaCost { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MtgResponse
|
||||||
|
{
|
||||||
|
public List<Data> Cards { get; set; }
|
||||||
|
|
||||||
|
public class Data
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string ManaCost { get; set; }
|
||||||
|
public string Text { get; set; }
|
||||||
|
public List<string> Types { get; set; }
|
||||||
|
public string ImageUrl { get; set; }
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue