Finished Ellie.Bot.Modules.Searches
This commit is contained in:
parent
ea246517d6
commit
b8f2da8c3d
90 changed files with 7566 additions and 1 deletions
2
TODO.md
2
TODO.md
|
@ -1,6 +1,6 @@
|
|||
# List of things to do
|
||||
|
||||
- Finish Ellie.Bot.Modules.Searches
|
||||
- ~~Finish Ellie.Bot.Modules.Searches~~ Finished
|
||||
- Start and finish Ellie.Bot.Modules.Administration
|
||||
- Start and finish Ellie.Bot.Modules.Utility
|
||||
- Start and finish Ellie.Bot.Modules.Music
|
||||
|
|
15
src/Ellie.Bot.Db/Models/punish/PunishmentAction.cs
Normal file
15
src/Ellie.Bot.Db/Models/punish/PunishmentAction.cs
Normal file
|
@ -0,0 +1,15 @@
|
|||
namespace Ellie.Services.Database.Models;
|
||||
|
||||
public enum PunishmentAction
|
||||
{
|
||||
Mute,
|
||||
Kick,
|
||||
Ban,
|
||||
Softban,
|
||||
RemoveRoles,
|
||||
ChatMute,
|
||||
VoiceMute,
|
||||
AddRole,
|
||||
Warn,
|
||||
TimeOut
|
||||
}
|
41
src/Ellie.Bot.Modules.Searches/Anime/AnimeResult.cs
Normal file
41
src/Ellie.Bot.Modules.Searches/Anime/AnimeResult.cs
Normal file
|
@ -0,0 +1,41 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.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)] + "...";
|
||||
}
|
204
src/Ellie.Bot.Modules.Searches/Anime/AnimeSearchCommands.cs
Normal file
204
src/Ellie.Bot.Modules.Searches/Anime/AnimeSearchCommands.cs
Normal file
|
@ -0,0 +1,204 @@
|
|||
#nullable disable
|
||||
using AngleSharp;
|
||||
using AngleSharp.Html.Dom;
|
||||
using Ellie.Modules.Searches.Services;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public partial class Searches
|
||||
{
|
||||
[Group]
|
||||
public partial class AnimeSearchCommands : EllieModule<AnimeSearchService>
|
||||
{
|
||||
// [NadekoCommand, Aliases]
|
||||
// public async Task Novel([Leftover] string query)
|
||||
// {
|
||||
// if (string.IsNullOrWhiteSpace(query))
|
||||
// return;
|
||||
//
|
||||
// var novelData = await _service.GetNovelData(query);
|
||||
//
|
||||
// if (novelData is null)
|
||||
// {
|
||||
// await ReplyErrorLocalizedAsync(strs.failed_finding_novel);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// var embed = _eb.Create()
|
||||
// .WithOkColor()
|
||||
// .WithDescription(novelData.Description.Replace("<br>", Environment.NewLine, StringComparison.InvariantCulture))
|
||||
// .WithTitle(novelData.Title)
|
||||
// .WithUrl(novelData.Link)
|
||||
// .WithImageUrl(novelData.ImageUrl)
|
||||
// .AddField(GetText(strs.authors), string.Join("\n", novelData.Authors), true)
|
||||
// .AddField(GetText(strs.status), novelData.Status, true)
|
||||
// .AddField(GetText(strs.genres), string.Join(" ", novelData.Genres.Any() ? novelData.Genres : new[] { "none" }), true)
|
||||
// .WithFooter($"{GetText(strs.score)} {novelData.Score}");
|
||||
//
|
||||
// await ctx.Channel.EmbedAsync(embed);
|
||||
// }
|
||||
|
||||
[Cmd]
|
||||
[Priority(0)]
|
||||
public async Task Mal([Leftover] string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return;
|
||||
|
||||
var fullQueryLink = "https://myanimelist.net/profile/" + name;
|
||||
|
||||
var config = Configuration.Default.WithDefaultLoader();
|
||||
using var document = await BrowsingContext.New(config).OpenAsync(fullQueryLink);
|
||||
var imageElem =
|
||||
document.QuerySelector(
|
||||
"body > div#myanimelist > div.wrapper > div#contentWrapper > div#content > div.content-container > div.container-left > div.user-profile > div.user-image > img");
|
||||
var imageUrl = ((IHtmlImageElement)imageElem)?.Source
|
||||
?? "http://icecream.me/uploads/870b03f36b59cc16ebfe314ef2dde781.png";
|
||||
|
||||
var stats = document
|
||||
.QuerySelectorAll(
|
||||
"body > div#myanimelist > div.wrapper > div#contentWrapper > div#content > div.content-container > div.container-right > div#statistics > div.user-statistics-stats > div.stats > div.clearfix > ul.stats-status > li > span")
|
||||
.Select(x => x.InnerHtml)
|
||||
.ToList();
|
||||
|
||||
var favorites = document.QuerySelectorAll("div.user-favorites > div.di-tc");
|
||||
|
||||
var favAnime = GetText(strs.anime_no_fav);
|
||||
if (favorites.Length > 0 && favorites[0].QuerySelector("p") is null)
|
||||
{
|
||||
favAnime = string.Join("\n",
|
||||
favorites[0]
|
||||
.QuerySelectorAll("ul > li > div.di-tc.va-t > a")
|
||||
.Shuffle()
|
||||
.Take(3)
|
||||
.Select(x =>
|
||||
{
|
||||
var elem = (IHtmlAnchorElement)x;
|
||||
return $"[{elem.InnerHtml}]({elem.Href})";
|
||||
}));
|
||||
}
|
||||
|
||||
var info = document.QuerySelectorAll("ul.user-status:nth-child(3) > li.clearfix")
|
||||
.Select(x => Tuple.Create(x.Children[0].InnerHtml, x.Children[1].InnerHtml))
|
||||
.ToList();
|
||||
|
||||
var daysAndMean = document.QuerySelectorAll("div.anime:nth-child(1) > div:nth-child(2) > div")
|
||||
.Select(x => x.TextContent.Split(':').Select(y => y.Trim()).ToArray())
|
||||
.ToArray();
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.mal_profile(name)))
|
||||
.AddField("💚 " + GetText(strs.watching), stats[0], true)
|
||||
.AddField("💙 " + GetText(strs.completed), stats[1], true);
|
||||
if (info.Count < 3)
|
||||
embed.AddField("💛 " + GetText(strs.on_hold), stats[2], true);
|
||||
embed.AddField("💔 " + GetText(strs.dropped), stats[3], true)
|
||||
.AddField("⚪ " + GetText(strs.plan_to_watch), stats[4], true)
|
||||
.AddField("🕐 " + daysAndMean[0][0], daysAndMean[0][1], true)
|
||||
.AddField("📊 " + daysAndMean[1][0], daysAndMean[1][1], true)
|
||||
.AddField(MalInfoToEmoji(info[0].Item1) + " " + info[0].Item1, info[0].Item2.TrimTo(20), true)
|
||||
.AddField(MalInfoToEmoji(info[1].Item1) + " " + info[1].Item1, info[1].Item2.TrimTo(20), true);
|
||||
if (info.Count > 2)
|
||||
embed.AddField(MalInfoToEmoji(info[2].Item1) + " " + info[2].Item1, info[2].Item2.TrimTo(20), true);
|
||||
|
||||
embed.WithDescription($@"
|
||||
** https://myanimelist.net/animelist/{name} **
|
||||
|
||||
**{GetText(strs.top_3_fav_anime)}**
|
||||
{favAnime}")
|
||||
.WithUrl(fullQueryLink)
|
||||
.WithImageUrl(imageUrl);
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
private static string MalInfoToEmoji(string info)
|
||||
{
|
||||
info = info.Trim().ToLowerInvariant();
|
||||
switch (info)
|
||||
{
|
||||
case "gender":
|
||||
return "🚁";
|
||||
case "location":
|
||||
return "🗺";
|
||||
case "last online":
|
||||
return "👥";
|
||||
case "birthday":
|
||||
return "📆";
|
||||
default:
|
||||
return "❔";
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public Task Mal(IGuildUser usr)
|
||||
=> Mal(usr.Username);
|
||||
|
||||
[Cmd]
|
||||
public async Task Anime([Leftover] string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return;
|
||||
|
||||
var animeData = await _service.GetAnimeData(query);
|
||||
|
||||
if (animeData is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.failed_finding_anime);
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = _eb.Create()
|
||||
.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 : new[] { "none" }),
|
||||
true)
|
||||
.WithFooter($"{GetText(strs.score)} {animeData.AverageScore} / 100");
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[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 ReplyErrorLocalizedAsync(strs.failed_finding_manga);
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = _eb.Create()
|
||||
.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 : new[] { "none" }),
|
||||
true)
|
||||
.WithFooter($"{GetText(strs.score)} {mangaData.AverageScore} / 100");
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
}
|
||||
}
|
79
src/Ellie.Bot.Modules.Searches/Anime/AnimeSearchService.cs
Normal file
79
src/Ellie.Bot.Modules.Searches/Anime/AnimeSearchService.cs
Normal file
|
@ -0,0 +1,79 @@
|
|||
#nullable disable
|
||||
using Ellie.Modules.Searches.Common;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Ellie.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/Ellie.Bot.Modules.Searches/Anime/MangaResult.cs
Normal file
40
src/Ellie.Bot.Modules.Searches/Anime/MangaResult.cs
Normal file
|
@ -0,0 +1,40 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.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)] + "...";
|
||||
}
|
196
src/Ellie.Bot.Modules.Searches/Crypto/CryptoCommands.cs
Normal file
196
src/Ellie.Bot.Modules.Searches/Crypto/CryptoCommands.cs
Normal file
|
@ -0,0 +1,196 @@
|
|||
#nullable disable
|
||||
using Ellie.Modules.Searches.Services;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Ellie.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 ReplyErrorLocalizedAsync(strs.not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
var symbol = symbols.First();
|
||||
var promptEmbed = _eb.Create()
|
||||
.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 ReplyErrorLocalizedAsync(strs.not_found);
|
||||
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 sign50 = stock.Change50d >= 0
|
||||
? "\\🔼"
|
||||
: "\\🔻";
|
||||
|
||||
var change50 = (stock.Change50d).ToString("P1", Culture);
|
||||
|
||||
var sign200 = stock.Change200d >= 0
|
||||
? "\\🔼"
|
||||
: "\\🔻";
|
||||
|
||||
var change200 = (stock.Change200d).ToString("P1", Culture);
|
||||
|
||||
var price = stock.Price.ToString("C2", localCulture);
|
||||
|
||||
var eb = _eb.Create()
|
||||
.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.ToString("C0", localCulture), 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 ctx.Channel.EmbedAsync(eb);
|
||||
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 = _eb.Create()
|
||||
.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 ReplyErrorLocalizedAsync(strs.crypto_not_found);
|
||||
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 = _eb.Create()
|
||||
.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());
|
||||
}
|
||||
}
|
||||
}
|
216
src/Ellie.Bot.Modules.Searches/Crypto/CryptoService.cs
Normal file
216
src/Ellie.Bot.Modules.Searches/Crypto/CryptoService.cs
Normal file
|
@ -0,0 +1,216 @@
|
|||
#nullable enable
|
||||
using Ellie.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.Xml;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
using StringExtensions = Ellie.Extensions.StringExtensions;
|
||||
|
||||
|
||||
namespace Ellie.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.DrawLines(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: StringExtensions.LevenshteinDistance(elem.Name.ToUpperInvariant(), 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("nadeko: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;
|
||||
}
|
||||
}
|
103
src/Ellie.Bot.Modules.Searches/Crypto/DefaultStockDataService.cs
Normal file
103
src/Ellie.Bot.Modules.Searches/Crypto/DefaultStockDataService.cs
Normal file
|
@ -0,0 +1,103 @@
|
|||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public 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 data = await http.GetFromJsonAsync<YahooQueryModel>(
|
||||
$"https://query1.finance.yahoo.com/v7/finance/quote?symbols={query}");
|
||||
|
||||
if (data is null)
|
||||
return default;
|
||||
|
||||
var symbol = data.QuoteResponse.Result.FirstOrDefault();
|
||||
|
||||
if (symbol is null)
|
||||
return default;
|
||||
|
||||
return new()
|
||||
{
|
||||
Name = symbol.LongName,
|
||||
Symbol = symbol.Symbol,
|
||||
Price = symbol.RegularMarketPrice,
|
||||
Close = symbol.RegularMarketPreviousClose,
|
||||
MarketCap = symbol.MarketCap,
|
||||
Change50d = symbol.FiftyDayAverageChangePercent,
|
||||
Change200d = symbol.TwoHundredDayAverageChangePercent,
|
||||
DailyVolume = symbol.AverageDailyVolume10Day,
|
||||
Exchange = symbol.FullExchangeName
|
||||
};
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Log.Warning(ex, "Error getting stock data: {ErrorMessage}", ex.Message);
|
||||
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)
|
||||
{
|
||||
PrepareHeaderForMatch = args => args.Header.Humanize(LetterCasing.Title)
|
||||
};
|
||||
|
||||
// todo replace .ToTimestamp() and remove google protobuf dependency
|
||||
// todo this needs testing
|
||||
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 Ellie.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 Ellie.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,202 @@
|
|||
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 Ellie.Modules.Searches;
|
||||
|
||||
public 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");
|
||||
|
||||
public 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)
|
||||
DrawLineExtensions.DrawLines(ctx,
|
||||
data.IsGreen
|
||||
? _greenBrush
|
||||
: _redBrush,
|
||||
1,
|
||||
data.High,
|
||||
data.Low);
|
||||
|
||||
|
||||
foreach (var data in drawData)
|
||||
FillRectangleExtensions.Fill(ctx,
|
||||
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.DrawLines(_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.DrawLines(_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.DrawLines(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 Ellie.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,13 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public class FinnHubSearchResponse
|
||||
{
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
public List<FinnHubSearchResult> Result { get; set; }
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public class FinnHubSearchResult
|
||||
{
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonPropertyName("displaySymbol")]
|
||||
public string DisplaySymbol { get; set; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public string Symbol { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
// using System.Net.Http.Json;
|
||||
//
|
||||
// namespace Ellie.Modules.Searches;
|
||||
//
|
||||
// public sealed class PolygonApiClient : IDisposable
|
||||
// {
|
||||
// private const string BASE_URL = "https://api.polygon.io/v3";
|
||||
//
|
||||
// private readonly HttpClient _httpClient;
|
||||
// private readonly string _apiKey;
|
||||
//
|
||||
// public PolygonApiClient(HttpClient httpClient, string apiKey)
|
||||
// {
|
||||
// _httpClient = httpClient;
|
||||
// _apiKey = apiKey;
|
||||
// }
|
||||
//
|
||||
// public async Task<IReadOnlyCollection<PolygonTickerData>> TickersAsync(string? ticker = null, string? query = null)
|
||||
// {
|
||||
// if (string.IsNullOrWhiteSpace(query))
|
||||
// query = null;
|
||||
//
|
||||
// if(query is not null)
|
||||
// query = Uri.EscapeDataString(query);
|
||||
//
|
||||
// var requestString = $"{BASE_URL}/reference/tickers"
|
||||
// + "?type=CS"
|
||||
// + "&active=true"
|
||||
// + "&order=asc"
|
||||
// + "&limit=1000"
|
||||
// + $"&apiKey={_apiKey}";
|
||||
//
|
||||
// if (!string.IsNullOrWhiteSpace(ticker))
|
||||
// requestString += $"&ticker={ticker}";
|
||||
//
|
||||
// if (!string.IsNullOrWhiteSpace(query))
|
||||
// requestString += $"&search={query}";
|
||||
//
|
||||
//
|
||||
// var response = await _httpClient.GetFromJsonAsync<PolygonTickerResponse>(requestString);
|
||||
//
|
||||
// if (response is null)
|
||||
// return Array.Empty<PolygonTickerData>();
|
||||
//
|
||||
// return response.Results;
|
||||
// }
|
||||
//
|
||||
// // public async Task<PolygonTickerDetailsV3> TickerDetailsV3Async(string ticker)
|
||||
// // {
|
||||
// // return new();
|
||||
// // }
|
||||
//
|
||||
// public void Dispose()
|
||||
// => _httpClient.Dispose();
|
||||
// }
|
|
@ -0,0 +1,26 @@
|
|||
// namespace Ellie.Modules.Searches;
|
||||
//
|
||||
// public sealed class PolygonStockDataService : IStockDataService
|
||||
// {
|
||||
// private readonly IHttpClientFactory _httpClientFactory;
|
||||
// private readonly IBotCredsProvider _credsProvider;
|
||||
//
|
||||
// public PolygonStockDataService(IHttpClientFactory httpClientFactory, IBotCredsProvider credsProvider)
|
||||
// {
|
||||
// _httpClientFactory = httpClientFactory;
|
||||
// _credsProvider = credsProvider;
|
||||
// }
|
||||
//
|
||||
// public async Task<IReadOnlyCollection<StockData>> GetStockDataAsync(string? query = null)
|
||||
// {
|
||||
// using var httpClient = _httpClientFactory.CreateClient();
|
||||
// using var client = new PolygonApiClient(httpClient, string.Empty);
|
||||
// var data = await client.TickersAsync(query: query);
|
||||
//
|
||||
// return data.Map(static x => new StockData()
|
||||
// {
|
||||
// Name = x.Name,
|
||||
// Ticker = x.Ticker,
|
||||
// });
|
||||
// }
|
||||
// }
|
|
@ -0,0 +1,43 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public class PolygonTickerData
|
||||
{
|
||||
[JsonPropertyName("ticker")]
|
||||
public string Ticker { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("market")]
|
||||
public string Market { get; set; }
|
||||
|
||||
[JsonPropertyName("locale")]
|
||||
public string Locale { get; set; }
|
||||
|
||||
[JsonPropertyName("primary_exchange")]
|
||||
public string PrimaryExchange { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonPropertyName("active")]
|
||||
public bool Active { get; set; }
|
||||
|
||||
[JsonPropertyName("currency_name")]
|
||||
public string CurrencyName { get; set; }
|
||||
|
||||
[JsonPropertyName("cik")]
|
||||
public string Cik { get; set; }
|
||||
|
||||
[JsonPropertyName("composite_figi")]
|
||||
public string CompositeFigi { get; set; }
|
||||
|
||||
[JsonPropertyName("share_class_figi")]
|
||||
public string ShareClassFigi { get; set; }
|
||||
|
||||
[JsonPropertyName("last_updated_utc")]
|
||||
public DateTime LastUpdatedUtc { get; set; }
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public class PolygonTickerResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public List<PolygonTickerData> Results { get; set; }
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public record CandleData
|
||||
(
|
||||
decimal Open,
|
||||
decimal Close,
|
||||
decimal High,
|
||||
decimal Low,
|
||||
long Volume);
|
|
@ -0,0 +1,7 @@
|
|||
namespace Ellie.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 Ellie.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/Ellie.Bot.Modules.Searches/Crypto/_common/StockData.cs
Normal file
15
src/Ellie.Bot.Modules.Searches/Crypto/_common/StockData.cs
Normal file
|
@ -0,0 +1,15 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public class StockData
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Symbol { get; set; }
|
||||
public double Price { get; set; }
|
||||
public long 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 Ellie.Modules.Searches;
|
||||
|
||||
public record SymbolData(string Symbol, string Description);
|
|
@ -0,0 +1,12 @@
|
|||
namespace Ellie.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 Ellie.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 Ellie.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 Ellie.Modules.Searches;
|
||||
|
||||
public class YahooQueryModel
|
||||
{
|
||||
[JsonPropertyName("quoteResponse")]
|
||||
public QuoteResponse QuoteResponse { get; set; } = null;
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Ellie.Bot.Common\Ellie.Bot.Common.csproj"/>
|
||||
<ProjectReference Include="..\Ellie.Bot.Db\Ellie.Bot.Db.csproj"/>
|
||||
|
||||
<ProjectReference Include="..\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj" OutputItemType="Analyzer"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.0.1"/>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0"/>
|
||||
<PackageReference Include="CsvHelper" Version="30.0.1"/>
|
||||
<PackageReference Include="MorseCode.ITask" Version="2.0.3"/>
|
||||
|
||||
<!-- Stream Notifications -->
|
||||
<PackageReference Include="TwitchLib.Api" Version="3.4.1"/>
|
||||
|
||||
<!-- Feeds, ... ? -->
|
||||
<PackageReference Include="CodeHollow.FeedReader" Version="1.2.6"/>
|
||||
|
||||
<!-- .hs, nothing else -->
|
||||
<PackageReference Include="Html2Markdown" Version="5.1.0.703"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
</Project>
|
115
src/Ellie.Bot.Modules.Searches/Feeds/FeedCommands.cs
Normal file
115
src/Ellie.Bot.Modules.Searches/Feeds/FeedCommands.cs
Normal file
|
@ -0,0 +1,115 @@
|
|||
#nullable disable
|
||||
using CodeHollow.FeedReader;
|
||||
using Ellie.Modules.Searches.Services;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Ellie.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)]
|
||||
public Task YtUploadNotif(string url, ITextChannel channel = null, [Leftover] string message = null)
|
||||
{
|
||||
var m = _ytChannelRegex.Match(url);
|
||||
if (!m.Success)
|
||||
return ReplyErrorLocalizedAsync(strs.invalid_input);
|
||||
|
||||
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)]
|
||||
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 ReplyErrorLocalizedAsync(strs.feed_invalid_url);
|
||||
return;
|
||||
}
|
||||
|
||||
channel ??= (ITextChannel)ctx.Channel;
|
||||
try
|
||||
{
|
||||
await FeedReader.ReadAsync(url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Information(ex, "Unable to get feeds from that url");
|
||||
await ReplyErrorLocalizedAsync(strs.feed_cant_parse);
|
||||
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 ReplyConfirmLocalizedAsync(strs.feed_added);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result == FeedAddResult.Duplicate)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.feed_duplicate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result == FeedAddResult.LimitReached)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.feed_limit_reached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
public async Task FeedRemove(int index)
|
||||
{
|
||||
if (_service.RemoveFeed(ctx.Guild.Id, --index))
|
||||
await ReplyConfirmLocalizedAsync(strs.feed_removed);
|
||||
else
|
||||
await ReplyErrorLocalizedAsync(strs.feed_out_of_range);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
public async Task FeedList()
|
||||
{
|
||||
var feeds = _service.GetFeeds(ctx.Guild.Id);
|
||||
|
||||
if (!feeds.Any())
|
||||
{
|
||||
await ctx.Channel.EmbedAsync(_eb.Create().WithOkColor().WithDescription(GetText(strs.feed_no_feed)));
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(0,
|
||||
cur =>
|
||||
{
|
||||
var embed = _eb.Create().WithOkColor();
|
||||
var i = 0;
|
||||
var fs = string.Join("\n",
|
||||
feeds.Skip(cur * 10).Take(10).Select(x => $"`{(cur * 10) + ++i}.` <#{x.ChannelId}> {x.Url}"));
|
||||
|
||||
return embed.WithDescription(fs);
|
||||
},
|
||||
feeds.Count,
|
||||
10);
|
||||
}
|
||||
}
|
||||
}
|
280
src/Ellie.Bot.Modules.Searches/Feeds/FeedsService.cs
Normal file
280
src/Ellie.Bot.Modules.Searches/Feeds/FeedsService.cs
Normal file
|
@ -0,0 +1,280 @@
|
|||
#nullable disable
|
||||
using CodeHollow.FeedReader;
|
||||
using CodeHollow.FeedReader.Feeds;
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Ellie.Db;
|
||||
using Ellie.Services.Database.Models;
|
||||
|
||||
namespace Ellie.Modules.Searches.Services;
|
||||
|
||||
public class FeedsService : IEService
|
||||
{
|
||||
private readonly DbService _db;
|
||||
private readonly ConcurrentDictionary<string, List<FeedSub>> _subs;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
|
||||
private readonly ConcurrentDictionary<string, DateTime> _lastPosts = new();
|
||||
private readonly Dictionary<string, uint> _errorCounters = new();
|
||||
|
||||
public FeedsService(
|
||||
IBot bot,
|
||||
DbService db,
|
||||
DiscordSocketClient client,
|
||||
IEmbedBuilderService eb)
|
||||
{
|
||||
_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;
|
||||
_eb = eb;
|
||||
|
||||
_ = 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 = feed
|
||||
.Items.Select(item => (Item: item,
|
||||
LastUpdate: item.PublishingDate?.ToUniversalTime()
|
||||
?? (item.SpecificItem as AtomFeedItem)?.UpdatedDate?.ToUniversalTime()))
|
||||
.Where(data => data.LastUpdate is not null)
|
||||
.Select(data => (data.Item, LastUpdate: (DateTime)data.LastUpdate))
|
||||
.OrderByDescending(data => data.LastUpdate)
|
||||
.Reverse() // start from the oldest
|
||||
.ToList();
|
||||
|
||||
if (!_lastPosts.TryGetValue(kvp.Key, out var lastFeedUpdate))
|
||||
{
|
||||
lastFeedUpdate = _lastPosts[kvp.Key] =
|
||||
items.Any() ? items[items.Count - 1].LastUpdate : DateTime.UtcNow;
|
||||
}
|
||||
|
||||
foreach (var (feedItem, itemUpdateDate) in items)
|
||||
{
|
||||
if (itemUpdateDate <= lastFeedUpdate)
|
||||
continue;
|
||||
|
||||
var embed = _eb.Create().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));
|
||||
|
||||
//send the created embed to all subscribed channels
|
||||
var feedSendTasks = kvp.Value
|
||||
.Where(x => x.GuildConfig is not null)
|
||||
.Select(x => _client.GetGuild(x.GuildConfig.GuildId)
|
||||
?.GetTextChannel(x.ChannelId)
|
||||
?.EmbedAsync(embed, x.Message))
|
||||
.Where(x => x is not null);
|
||||
|
||||
allSendTasks.Add(feedSendTasks.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()
|
||||
};
|
||||
|
||||
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(),
|
||||
new List<FeedSub>
|
||||
{
|
||||
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(),
|
||||
new List<FeedSub>(),
|
||||
(_, old) =>
|
||||
{
|
||||
old.Remove(toRemove);
|
||||
return old;
|
||||
});
|
||||
uow.Remove(toRemove);
|
||||
uow.SaveChanges();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public enum FeedAddResult
|
||||
{
|
||||
Success,
|
||||
LimitReached,
|
||||
Invalid,
|
||||
Duplicate,
|
||||
}
|
32
src/Ellie.Bot.Modules.Searches/GlobalUsings.cs
Normal file
32
src/Ellie.Bot.Modules.Searches/GlobalUsings.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
// // global using System.Collections.Concurrent;
|
||||
global using NonBlocking;
|
||||
//
|
||||
// // packages
|
||||
global using Serilog;
|
||||
global using Humanizer;
|
||||
global using Newtonsoft;
|
||||
//
|
||||
// // ellie
|
||||
// global using Ellie;
|
||||
global using Ellie.Services;
|
||||
global using Ellise.Common; // new project
|
||||
global using Ellie.Common; // old + ellie specific things
|
||||
global using Ellie.Common.Attributes;
|
||||
global using Ellie.Extensions;
|
||||
// global using Ellie.Marmalade;
|
||||
|
||||
// discord
|
||||
global using Discord;
|
||||
global using Discord.Commands;
|
||||
global using Discord.Net;
|
||||
global using Discord.WebSocket;
|
||||
|
||||
// aliases
|
||||
global using GuildPerm = Discord.GuildPermission;
|
||||
global using ChannelPerm = Discord.ChannelPermission;
|
||||
global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute;
|
||||
global using LeftoverAttribute = Discord.Commands.RemainderAttribute;
|
||||
global using TypeReaderResult = Ellie.Common.TypeReaders.TypeReaderResult;
|
||||
|
||||
// non-essential
|
||||
// global using JetBrains.Annotations;
|
53
src/Ellie.Bot.Modules.Searches/JokeCommands.cs
Normal file
53
src/Ellie.Bot.Modules.Searches/JokeCommands.cs
Normal file
|
@ -0,0 +1,53 @@
|
|||
#nullable disable
|
||||
using Ellie.Modules.Searches.Services;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public partial class Searches
|
||||
{
|
||||
[Group]
|
||||
public class JokeCommands : EllieModule<SearchesService>
|
||||
{
|
||||
[Cmd]
|
||||
public async Task Yomama()
|
||||
=> await SendConfirmAsync(await _service.GetYomamaJoke());
|
||||
|
||||
[Cmd]
|
||||
public async Task Randjoke()
|
||||
{
|
||||
var (setup, punchline) = await _service.GetRandomJoke();
|
||||
await SendConfirmAsync(setup, punchline);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task ChuckNorris()
|
||||
=> await SendConfirmAsync(await _service.GetChuckNorrisJoke());
|
||||
|
||||
[Cmd]
|
||||
public async Task WowJoke()
|
||||
{
|
||||
if (!_service.WowJokes.Any())
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.jokes_not_loaded);
|
||||
return;
|
||||
}
|
||||
|
||||
var joke = _service.WowJokes[new EllieRandom().Next(0, _service.WowJokes.Count)];
|
||||
await SendConfirmAsync(joke.Question, joke.Answer);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task MagicItem()
|
||||
{
|
||||
if (!_service.MagicItems.Any())
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.magicitems_not_loaded);
|
||||
return;
|
||||
}
|
||||
|
||||
var item = _service.MagicItems[new EllieRandom().Next(0, _service.MagicItems.Count)];
|
||||
|
||||
await SendConfirmAsync("✨" + item.Name, item.Description);
|
||||
}
|
||||
}
|
||||
}
|
96
src/Ellie.Bot.Modules.Searches/MemegenCommands.cs
Normal file
96
src/Ellie.Bot.Modules.Searches/MemegenCommands.cs
Normal file
|
@ -0,0 +1,96 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public partial class Searches
|
||||
{
|
||||
[Group]
|
||||
public partial class MemegenCommands : EllieModule
|
||||
{
|
||||
private static readonly ImmutableDictionary<char, string> _map = new Dictionary<char, string>
|
||||
{
|
||||
{ '?', "~q" },
|
||||
{ '%', "~p" },
|
||||
{ '#', "~h" },
|
||||
{ '/', "~s" },
|
||||
{ ' ', "-" },
|
||||
{ '-', "--" },
|
||||
{ '_', "__" },
|
||||
{ '"', "''" }
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public MemegenCommands(IHttpClientFactory factory)
|
||||
=> _httpFactory = factory;
|
||||
|
||||
[Cmd]
|
||||
public async Task Memelist(int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return;
|
||||
|
||||
using var http = _httpFactory.CreateClient("memelist");
|
||||
using var res = await http.GetAsync("https://api.memegen.link/templates/");
|
||||
|
||||
var rawJson = await res.Content.ReadAsStringAsync();
|
||||
|
||||
var data = JsonConvert.DeserializeObject<List<MemegenTemplate>>(rawJson)!;
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(page,
|
||||
curPage =>
|
||||
{
|
||||
var templates = string.Empty;
|
||||
foreach (var template in data.Skip(curPage * 15).Take(15))
|
||||
templates += $"**{template.Name}:**\n key: `{template.Id}`\n";
|
||||
var embed = _eb.Create().WithOkColor().WithDescription(templates);
|
||||
|
||||
return embed;
|
||||
},
|
||||
data.Count,
|
||||
15);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Memegen(string meme, [Leftover] string memeText = null)
|
||||
{
|
||||
var memeUrl = $"http://api.memegen.link/{meme}";
|
||||
if (!string.IsNullOrWhiteSpace(memeText))
|
||||
{
|
||||
var memeTextArray = memeText.Split(';');
|
||||
foreach (var text in memeTextArray)
|
||||
{
|
||||
var newText = Replace(text);
|
||||
memeUrl += $"/{newText}";
|
||||
}
|
||||
}
|
||||
|
||||
memeUrl += ".png";
|
||||
await ctx.Channel.SendMessageAsync(memeUrl);
|
||||
}
|
||||
|
||||
private static string Replace(string input)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var c in input)
|
||||
{
|
||||
if (_map.TryGetValue(c, out var tmp))
|
||||
sb.Append(tmp);
|
||||
else
|
||||
sb.Append(c);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private class MemegenTemplate
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Id { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
297
src/Ellie.Bot.Modules.Searches/OsuCommands.cs
Normal file
297
src/Ellie.Bot.Modules.Searches/OsuCommands.cs
Normal file
|
@ -0,0 +1,297 @@
|
|||
#nullable disable
|
||||
using Ellie.Modules.Searches.Common;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public partial class Searches
|
||||
{
|
||||
[Group]
|
||||
public partial class OsuCommands : EllieModule
|
||||
{
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public OsuCommands(IBotCredentials creds, IHttpClientFactory factory)
|
||||
{
|
||||
_creds = creds;
|
||||
_httpFactory = factory;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Osu(string user, [Leftover] string mode = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user))
|
||||
return;
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var modeNumber = string.IsNullOrWhiteSpace(mode) ? 0 : ResolveGameMode(mode);
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_creds.OsuApiKey))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.osu_api_key);
|
||||
return;
|
||||
}
|
||||
|
||||
var smode = ResolveGameMode(modeNumber);
|
||||
var userReq = $"https://osu.ppy.sh/api/get_user?k={_creds.OsuApiKey}&u={user}&m={modeNumber}";
|
||||
var userResString = await http.GetStringAsync(userReq);
|
||||
var objs = JsonConvert.DeserializeObject<List<OsuUserData>>(userResString);
|
||||
|
||||
if (objs.Count == 0)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.osu_user_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
var obj = objs[0];
|
||||
var userId = obj.UserId;
|
||||
|
||||
await ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.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));
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.osu_user_not_found);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.osu_failed);
|
||||
Log.Warning(ex, "Osu command failed");
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Gatari(string user, [Leftover] string mode = null)
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var modeNumber = string.IsNullOrWhiteSpace(mode) ? 0 : ResolveGameMode(mode);
|
||||
|
||||
var modeStr = ResolveGameMode(modeNumber);
|
||||
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)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.osu_user_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
var embed = _eb.Create()
|
||||
.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 ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Osu5(string user, [Leftover] string mode = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_creds.OsuApiKey))
|
||||
{
|
||||
await SendErrorAsync("An osu! API key is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(user))
|
||||
{
|
||||
await SendErrorAsync("Please provide a username.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var m = 0;
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
m = 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);
|
||||
});
|
||||
|
||||
var eb = _eb.Create().WithOkColor().WithTitle($"Top 5 plays for {user}");
|
||||
|
||||
var mapData = await mapTasks.WhenAll();
|
||||
foreach (var (title, desc) in mapData.Where(x => x != default))
|
||||
eb.AddField(title, desc);
|
||||
|
||||
await ctx.Channel.EmbedAsync(eb);
|
||||
}
|
||||
|
||||
//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);
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
|
||||
private 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";
|
||||
}
|
||||
}
|
||||
|
||||
//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;
|
||||
}
|
||||
}
|
311
src/Ellie.Bot.Modules.Searches/PathOfExileCommands.cs
Normal file
311
src/Ellie.Bot.Modules.Searches/PathOfExileCommands.cs
Normal file
|
@ -0,0 +1,311 @@
|
|||
#nullable disable
|
||||
using Ellie.Modules.Searches.Common;
|
||||
using Ellie.Modules.Searches.Services;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public partial class Searches
|
||||
{
|
||||
[Group]
|
||||
public partial class PathOfExileCommands : EllieModule<SearchesService>
|
||||
{
|
||||
private const string POE_URL = "https://www.pathofexile.com/character-window/get-characters?accountName=";
|
||||
private const string PON_URL = "http://poe.ninja/api/Data/GetCurrencyOverview?league=";
|
||||
private const string POGS_URL = "http://pathofexile.gamepedia.com/api.php?action=opensearch&search=";
|
||||
|
||||
private const string POG_URL =
|
||||
"https://pathofexile.gamepedia.com/api.php?action=browsebysubject&format=json&subject=";
|
||||
|
||||
private const string POGI_URL =
|
||||
"https://pathofexile.gamepedia.com/api.php?action=query&prop=imageinfo&iiprop=url&format=json&titles=File:";
|
||||
|
||||
private const string PROFILE_URL = "https://www.pathofexile.com/account/view-profile/";
|
||||
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
private Dictionary<string, string> currencyDictionary = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "Chaos Orb", "Chaos Orb" },
|
||||
{ "Orb of Alchemy", "Orb of Alchemy" },
|
||||
{ "Jeweller's Orb", "Jeweller's Orb" },
|
||||
{ "Exalted Orb", "Exalted Orb" },
|
||||
{ "Mirror of Kalandra", "Mirror of Kalandra" },
|
||||
{ "Vaal Orb", "Vaal Orb" },
|
||||
{ "Orb of Alteration", "Orb of Alteration" },
|
||||
{ "Orb of Scouring", "Orb of Scouring" },
|
||||
{ "Divine Orb", "Divine Orb" },
|
||||
{ "Orb of Annulment", "Orb of Annulment" },
|
||||
{ "Master Cartographer's Sextant", "Master Cartographer's Sextant" },
|
||||
{ "Journeyman Cartographer's Sextant", "Journeyman Cartographer's Sextant" },
|
||||
{ "Apprentice Cartographer's Sextant", "Apprentice Cartographer's Sextant" },
|
||||
{ "Blessed Orb", "Blessed Orb" },
|
||||
{ "Orb of Regret", "Orb of Regret" },
|
||||
{ "Gemcutter's Prism", "Gemcutter's Prism" },
|
||||
{ "Glassblower's Bauble", "Glassblower's Bauble" },
|
||||
{ "Orb of Fusing", "Orb of Fusing" },
|
||||
{ "Cartographer's Chisel", "Cartographer's Chisel" },
|
||||
{ "Chromatic Orb", "Chromatic Orb" },
|
||||
{ "Orb of Augmentation", "Orb of Augmentation" },
|
||||
{ "Blacksmith's Whetstone", "Blacksmith's Whetstone" },
|
||||
{ "Orb of Transmutation", "Orb of Transmutation" },
|
||||
{ "Armourer's Scrap", "Armourer's Scrap" },
|
||||
{ "Scroll of Wisdom", "Scroll of Wisdom" },
|
||||
{ "Regal Orb", "Regal Orb" },
|
||||
{ "Chaos", "Chaos Orb" },
|
||||
{ "Alch", "Orb of Alchemy" },
|
||||
{ "Alchs", "Orb of Alchemy" },
|
||||
{ "Jews", "Jeweller's Orb" },
|
||||
{ "Jeweller", "Jeweller's Orb" },
|
||||
{ "Jewellers", "Jeweller's Orb" },
|
||||
{ "Jeweller's", "Jeweller's Orb" },
|
||||
{ "X", "Exalted Orb" },
|
||||
{ "Ex", "Exalted Orb" },
|
||||
{ "Exalt", "Exalted Orb" },
|
||||
{ "Exalts", "Exalted Orb" },
|
||||
{ "Mirror", "Mirror of Kalandra" },
|
||||
{ "Mirrors", "Mirror of Kalandra" },
|
||||
{ "Vaal", "Vaal Orb" },
|
||||
{ "Alt", "Orb of Alteration" },
|
||||
{ "Alts", "Orb of Alteration" },
|
||||
{ "Scour", "Orb of Scouring" },
|
||||
{ "Scours", "Orb of Scouring" },
|
||||
{ "Divine", "Divine Orb" },
|
||||
{ "Annul", "Orb of Annulment" },
|
||||
{ "Annulment", "Orb of Annulment" },
|
||||
{ "Master Sextant", "Master Cartographer's Sextant" },
|
||||
{ "Journeyman Sextant", "Journeyman Cartographer's Sextant" },
|
||||
{ "Apprentice Sextant", "Apprentice Cartographer's Sextant" },
|
||||
{ "Blessed", "Blessed Orb" },
|
||||
{ "Regret", "Orb of Regret" },
|
||||
{ "Regrets", "Orb of Regret" },
|
||||
{ "Gcp", "Gemcutter's Prism" },
|
||||
{ "Glassblowers", "Glassblower's Bauble" },
|
||||
{ "Glassblower's", "Glassblower's Bauble" },
|
||||
{ "Fusing", "Orb of Fusing" },
|
||||
{ "Fuses", "Orb of Fusing" },
|
||||
{ "Fuse", "Orb of Fusing" },
|
||||
{ "Chisel", "Cartographer's Chisel" },
|
||||
{ "Chisels", "Cartographer's Chisel" },
|
||||
{ "Chance", "Orb of Chance" },
|
||||
{ "Chances", "Orb of Chance" },
|
||||
{ "Chrome", "Chromatic Orb" },
|
||||
{ "Chromes", "Chromatic Orb" },
|
||||
{ "Aug", "Orb of Augmentation" },
|
||||
{ "Augmentation", "Orb of Augmentation" },
|
||||
{ "Augment", "Orb of Augmentation" },
|
||||
{ "Augments", "Orb of Augmentation" },
|
||||
{ "Whetstone", "Blacksmith's Whetstone" },
|
||||
{ "Whetstones", "Blacksmith's Whetstone" },
|
||||
{ "Transmute", "Orb of Transmutation" },
|
||||
{ "Transmutes", "Orb of Transmutation" },
|
||||
{ "Armourers", "Armourer's Scrap" },
|
||||
{ "Armourer's", "Armourer's Scrap" },
|
||||
{ "Wisdom Scroll", "Scroll of Wisdom" },
|
||||
{ "Wisdom Scrolls", "Scroll of Wisdom" },
|
||||
{ "Regal", "Regal Orb" },
|
||||
{ "Regals", "Regal Orb" }
|
||||
};
|
||||
|
||||
public PathOfExileCommands(IHttpClientFactory httpFactory)
|
||||
=> _httpFactory = httpFactory;
|
||||
|
||||
[Cmd]
|
||||
public async Task PathOfExile(string usr, string league = "", int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(usr))
|
||||
{
|
||||
await SendErrorAsync("Please provide an account name.");
|
||||
return;
|
||||
}
|
||||
|
||||
var characters = new List<Account>();
|
||||
|
||||
try
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var res = await http.GetStringAsync($"{POE_URL}{usr}");
|
||||
characters = JsonConvert.DeserializeObject<List<Account>>(res);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var embed = _eb.Create().WithDescription(GetText(strs.account_not_found)).WithErrorColor();
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(league))
|
||||
characters.RemoveAll(c => c.League != league);
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(page,
|
||||
curPage =>
|
||||
{
|
||||
var embed = _eb.Create()
|
||||
.WithAuthor($"Characters on {usr}'s account",
|
||||
"https://web.poecdn.com/image/favicon/ogimage.png",
|
||||
$"{PROFILE_URL}{usr}")
|
||||
.WithOkColor();
|
||||
|
||||
var tempList = characters.Skip(curPage * 9).Take(9).ToList();
|
||||
|
||||
if (characters.Count == 0)
|
||||
return embed.WithDescription("This account has no characters.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"```{"#",-5}{"Character Name",-23}{"League",-10}{"Class",-13}{"Level",-3}");
|
||||
for (var i = 0; i < tempList.Count; i++)
|
||||
{
|
||||
var character = tempList[i];
|
||||
|
||||
sb.AppendLine(
|
||||
$"#{i + 1 + (curPage * 9),-4}{character.Name,-23}{ShortLeagueName(character.League),-10}{character.Class,-13}{character.Level,-3}");
|
||||
}
|
||||
|
||||
sb.AppendLine("```");
|
||||
embed.WithDescription(sb.ToString());
|
||||
|
||||
return embed;
|
||||
},
|
||||
characters.Count,
|
||||
9);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task PathOfExileLeagues()
|
||||
{
|
||||
var leagues = new List<Leagues>();
|
||||
|
||||
try
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var res = await http.GetStringAsync("http://api.pathofexile.com/leagues?type=main&compact=1");
|
||||
leagues = JsonConvert.DeserializeObject<List<Leagues>>(res);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var eembed = _eb.Create().WithDescription(GetText(strs.leagues_not_found)).WithErrorColor();
|
||||
|
||||
await ctx.Channel.EmbedAsync(eembed);
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithAuthor("Path of Exile Leagues",
|
||||
"https://web.poecdn.com/image/favicon/ogimage.png",
|
||||
"https://www.pathofexile.com")
|
||||
.WithOkColor();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"```{"#",-5}{"League Name",-23}");
|
||||
for (var i = 0; i < leagues.Count; i++)
|
||||
{
|
||||
var league = leagues[i];
|
||||
|
||||
sb.AppendLine($"#{i + 1,-4}{league.Id,-23}");
|
||||
}
|
||||
|
||||
sb.AppendLine("```");
|
||||
|
||||
embed.WithDescription(sb.ToString());
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task PathOfExileCurrency(
|
||||
string leagueName,
|
||||
string currencyName,
|
||||
string convertName = "Chaos Orb")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(leagueName))
|
||||
{
|
||||
await SendErrorAsync("Please provide league name.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(currencyName))
|
||||
{
|
||||
await SendErrorAsync("Please provide currency name.");
|
||||
return;
|
||||
}
|
||||
|
||||
var cleanCurrency = ShortCurrencyName(currencyName);
|
||||
var cleanConvert = ShortCurrencyName(convertName);
|
||||
|
||||
try
|
||||
{
|
||||
var res = $"{PON_URL}{leagueName}";
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var obj = JObject.Parse(await http.GetStringAsync(res));
|
||||
|
||||
var chaosEquivalent = 0.0F;
|
||||
var conversionEquivalent = 0.0F;
|
||||
|
||||
// poe.ninja API does not include a "chaosEquivalent" property for Chaos Orbs.
|
||||
if (cleanCurrency == "Chaos Orb")
|
||||
chaosEquivalent = 1.0F;
|
||||
else
|
||||
{
|
||||
var currencyInput = obj["lines"]
|
||||
.Values<JObject>()
|
||||
.Where(i => i["currencyTypeName"].Value<string>() == cleanCurrency)
|
||||
.FirstOrDefault();
|
||||
chaosEquivalent = float.Parse(currencyInput["chaosEquivalent"].ToString(),
|
||||
CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (cleanConvert == "Chaos Orb")
|
||||
conversionEquivalent = 1.0F;
|
||||
else
|
||||
{
|
||||
var currencyOutput = obj["lines"]
|
||||
.Values<JObject>()
|
||||
.Where(i => i["currencyTypeName"].Value<string>() == cleanConvert)
|
||||
.FirstOrDefault();
|
||||
conversionEquivalent = float.Parse(currencyOutput["chaosEquivalent"].ToString(),
|
||||
CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithAuthor($"{leagueName} Currency Exchange",
|
||||
"https://web.poecdn.com/image/favicon/ogimage.png",
|
||||
"http://poe.ninja")
|
||||
.AddField("Currency Type", cleanCurrency, true)
|
||||
.AddField($"{cleanConvert} Equivalent", chaosEquivalent / conversionEquivalent, true)
|
||||
.WithOkColor();
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var embed = _eb.Create().WithDescription(GetText(strs.ninja_not_found)).WithErrorColor();
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
}
|
||||
|
||||
private string ShortCurrencyName(string str)
|
||||
{
|
||||
if (currencyDictionary.ContainsValue(str))
|
||||
return str;
|
||||
|
||||
var currency = currencyDictionary[str];
|
||||
|
||||
return currency;
|
||||
}
|
||||
|
||||
private static string ShortLeagueName(string str)
|
||||
{
|
||||
var league = str.Replace("Hardcore", "HC", StringComparison.InvariantCultureIgnoreCase);
|
||||
|
||||
return league;
|
||||
}
|
||||
}
|
||||
}
|
71
src/Ellie.Bot.Modules.Searches/PlaceCommands.cs
Normal file
71
src/Ellie.Bot.Modules.Searches/PlaceCommands.cs
Normal file
|
@ -0,0 +1,71 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public partial class Searches
|
||||
{
|
||||
[Group]
|
||||
public partial class PlaceCommands : EllieModule
|
||||
{
|
||||
public enum PlaceType
|
||||
{
|
||||
Cage, //http://www.placecage.com
|
||||
Steven, //http://www.stevensegallery.com
|
||||
Beard, //http://placebeard.it
|
||||
Fill, //http://www.fillmurray.com
|
||||
Bear, //https://www.placebear.com
|
||||
Kitten, //http://placekitten.com
|
||||
Bacon, //http://baconmockup.com
|
||||
Xoart //http://xoart.link
|
||||
}
|
||||
|
||||
private static readonly string _typesStr = string.Join(", ", Enum.GetNames<PlaceType>());
|
||||
|
||||
[Cmd]
|
||||
public async Task Placelist()
|
||||
=> await SendConfirmAsync(GetText(strs.list_of_place_tags(prefix)), _typesStr);
|
||||
|
||||
[Cmd]
|
||||
public async Task Place(PlaceType placeType, uint width = 0, uint height = 0)
|
||||
{
|
||||
var url = string.Empty;
|
||||
switch (placeType)
|
||||
{
|
||||
case PlaceType.Cage:
|
||||
url = "http://www.placecage.com";
|
||||
break;
|
||||
case PlaceType.Steven:
|
||||
url = "http://www.stevensegallery.com";
|
||||
break;
|
||||
case PlaceType.Beard:
|
||||
url = "http://placebeard.it";
|
||||
break;
|
||||
case PlaceType.Fill:
|
||||
url = "http://www.fillmurray.com";
|
||||
break;
|
||||
case PlaceType.Bear:
|
||||
url = "https://www.placebear.com";
|
||||
break;
|
||||
case PlaceType.Kitten:
|
||||
url = "http://placekitten.com";
|
||||
break;
|
||||
case PlaceType.Bacon:
|
||||
url = "http://baconmockup.com";
|
||||
break;
|
||||
case PlaceType.Xoart:
|
||||
url = "http://xoart.link";
|
||||
break;
|
||||
}
|
||||
|
||||
var rng = new EllieRandom();
|
||||
if (width is <= 0 or > 1000)
|
||||
width = (uint)rng.Next(250, 850);
|
||||
|
||||
if (height is <= 0 or > 1000)
|
||||
height = (uint)rng.Next(250, 850);
|
||||
|
||||
url += $"/{width}/{height}";
|
||||
|
||||
await ctx.Channel.SendMessageAsync(url);
|
||||
}
|
||||
}
|
||||
}
|
74
src/Ellie.Bot.Modules.Searches/PokemonSearchCommands.cs
Normal file
74
src/Ellie.Bot.Modules.Searches/PokemonSearchCommands.cs
Normal file
|
@ -0,0 +1,74 @@
|
|||
#nullable disable
|
||||
using Ellie.Modules.Searches.Services;
|
||||
|
||||
namespace Ellie.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 ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.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));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await ReplyErrorLocalizedAsync(strs.pokemon_none);
|
||||
}
|
||||
|
||||
[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 ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.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));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await ReplyErrorLocalizedAsync(strs.pokemon_ability_none);
|
||||
}
|
||||
}
|
||||
}
|
612
src/Ellie.Bot.Modules.Searches/Searches.cs
Normal file
612
src/Ellie.Bot.Modules.Searches/Searches.cs
Normal file
|
@ -0,0 +1,612 @@
|
|||
#nullable disable
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Ellie.Modules.Searches.Common;
|
||||
using Ellie.Modules.Searches.Services;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public partial class Searches : EllieModule<SearchesService>
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, string> _cachedShortenedLinks = new();
|
||||
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 Rip([Leftover] IGuildUser usr)
|
||||
{
|
||||
var av = usr.RealAvatarUrl();
|
||||
await using var picStream = await _service.GetRipPictureAsync(usr.Nickname ?? usr.Username, av);
|
||||
await ctx.Channel.SendFileAsync(picStream,
|
||||
"rip.png",
|
||||
$"Rip {Format.Bold(usr.ToString())} \n\t- " + Format.Italics(ctx.User.ToString()));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Weather([Leftover] string query)
|
||||
{
|
||||
if (!await ValidateQuery(query))
|
||||
return;
|
||||
|
||||
var embed = _eb.Create();
|
||||
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 ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
LocStr errorKey;
|
||||
switch (err)
|
||||
{
|
||||
case TimeErrors.ApiKeyMissing:
|
||||
errorKey = strs.api_key_missing;
|
||||
break;
|
||||
case TimeErrors.InvalidInput:
|
||||
errorKey = strs.invalid_input;
|
||||
break;
|
||||
case TimeErrors.NotFound:
|
||||
errorKey = strs.not_found;
|
||||
break;
|
||||
default:
|
||||
errorKey = strs.error_occured;
|
||||
break;
|
||||
}
|
||||
|
||||
await ReplyErrorLocalizedAsync(errorKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(data.TimeZoneName))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.timezone_db_api_key);
|
||||
return;
|
||||
}
|
||||
|
||||
var eb = _eb.Create()
|
||||
.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 ctx.Channel.SendMessageAsync(embed: eb.Build());
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Movie([Leftover] string query = null)
|
||||
{
|
||||
if (!await ValidateQuery(query))
|
||||
return;
|
||||
|
||||
await ctx.Channel.TriggerTypingAsync();
|
||||
|
||||
var movie = await _service.GetMovieDataAsync(query);
|
||||
if (movie is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.imdb_fail);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.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(movie.Poster));
|
||||
}
|
||||
|
||||
[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 ctx.Channel.EmbedAsync(_eb.Create().WithOkColor().WithImageUrl(url));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Lmgtfy([Leftover] string ffs = null)
|
||||
{
|
||||
if (!await ValidateQuery(ffs))
|
||||
return;
|
||||
|
||||
var shortenedUrl = await _google.ShortenUrl($"https://letmegooglethat.com/?q={Uri.EscapeDataString(ffs)}");
|
||||
await SendConfirmAsync($"<{shortenedUrl}>");
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Shorten([Leftover] string query)
|
||||
{
|
||||
if (!await ValidateQuery(query))
|
||||
return;
|
||||
|
||||
query = query.Trim();
|
||||
if (!_cachedShortenedLinks.TryGetValue(query, out var 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;
|
||||
|
||||
shortLink = data.ResultUrl;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error shortening a link: {Message}", ex.Message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.WithOkColor()
|
||||
.AddField(GetText(strs.original_url), $"<{query}>")
|
||||
.AddField(GetText(strs.short_url), $"<{shortLink}>"));
|
||||
}
|
||||
|
||||
[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 ReplyErrorLocalizedAsync(strs.card_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = _eb.Create()
|
||||
.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 ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Hearthstone([Leftover] string name)
|
||||
{
|
||||
if (!await ValidateQuery(name))
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_creds.RapidApiKey))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.mashape_api_missing);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Channel.TriggerTypingAsync();
|
||||
var card = await _service.GetHearthstoneCardDataAsync(name);
|
||||
|
||||
if (card is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.card_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = _eb.Create().WithOkColor().WithImageUrl(card.Img);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(card.Flavor))
|
||||
embed.WithDescription(card.Flavor);
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task UrbanDict([Leftover] string query = null)
|
||||
{
|
||||
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)}");
|
||||
try
|
||||
{
|
||||
var items = JsonConvert.DeserializeObject<UrbanResponse>(res).List;
|
||||
if (items.Any())
|
||||
{
|
||||
await ctx.SendPaginatedConfirmAsync(0,
|
||||
p =>
|
||||
{
|
||||
var item = items[p];
|
||||
return _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithUrl(item.Permalink)
|
||||
.WithTitle(item.Word)
|
||||
.WithDescription(item.Definition);
|
||||
},
|
||||
items.Length,
|
||||
1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
await ReplyErrorLocalizedAsync(strs.ud_error);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Define([Leftover] string word)
|
||||
{
|
||||
if (!await ValidateQuery(word))
|
||||
return;
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
string res;
|
||||
try
|
||||
{
|
||||
res = await _cache.GetOrCreateAsync($"define_{word}",
|
||||
e =>
|
||||
{
|
||||
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12);
|
||||
return http.GetStringAsync("https://api.pearson.com/v2/dictionaries/entries?headword="
|
||||
+ WebUtility.UrlEncode(word));
|
||||
});
|
||||
|
||||
var data = JsonConvert.DeserializeObject<DefineModel>(res);
|
||||
|
||||
var datas = data.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 (!datas.Any())
|
||||
{
|
||||
Log.Warning("Definition not found: {Word}", word);
|
||||
await ReplyErrorLocalizedAsync(strs.define_unknown);
|
||||
}
|
||||
|
||||
|
||||
var col = datas.Select(x => (
|
||||
Definition: x.Sense.Definition is string
|
||||
? x.Sense.Definition.ToString()
|
||||
: ((JArray)JToken.Parse(x.Sense.Definition.ToString())).First.ToString(),
|
||||
Example: x.Sense.Examples is null || x.Sense.Examples.Count == 0
|
||||
? string.Empty
|
||||
: x.Sense.Examples[0].Text, Word: word,
|
||||
WordType: string.IsNullOrWhiteSpace(x.PartOfSpeech) ? "-" : x.PartOfSpeech))
|
||||
.ToList();
|
||||
|
||||
Log.Information("Sending {Count} definition for: {Word}", col.Count, word);
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(0,
|
||||
page =>
|
||||
{
|
||||
var model = col.Skip(page).First();
|
||||
var embed = _eb.Create()
|
||||
.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;
|
||||
},
|
||||
col.Count,
|
||||
1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error retrieving definition data for: {Word}", word);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Catfact()
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var response = await http.GetStringAsync("https://catfact.ninja/fact");
|
||||
|
||||
var fact = JObject.Parse(response)["fact"].ToString();
|
||||
await SendConfirmAsync("🐈" + GetText(strs.catfact), fact);
|
||||
}
|
||||
|
||||
//done in 3.0
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Revav([Leftover] IGuildUser usr = null)
|
||||
{
|
||||
if (usr is null)
|
||||
usr = (IGuildUser)ctx.User;
|
||||
|
||||
var av = usr.RealAvatarUrl();
|
||||
await SendConfirmAsync($"https://images.google.com/searchbyimage?image_url={av}");
|
||||
}
|
||||
|
||||
//done in 3.0
|
||||
[Cmd]
|
||||
public async Task Revimg([Leftover] string imageLink = null)
|
||||
{
|
||||
imageLink = imageLink?.Trim() ?? "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(imageLink))
|
||||
return;
|
||||
|
||||
await SendConfirmAsync($"https://images.google.com/searchbyimage?image_url={imageLink}");
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Wiki([Leftover] string query = null)
|
||||
{
|
||||
query = query?.Trim();
|
||||
|
||||
if (!await ValidateQuery(query))
|
||||
return;
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var result = 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));
|
||||
var data = JsonConvert.DeserializeObject<WikipediaApiModel>(result);
|
||||
if (data.Query.Pages[0].Missing || string.IsNullOrWhiteSpace(data.Query.Pages[0].FullUrl))
|
||||
await ReplyErrorLocalizedAsync(strs.wiki_page_not_found);
|
||||
else
|
||||
await ctx.Channel.SendMessageAsync(data.Query.Pages[0].FullUrl);
|
||||
}
|
||||
|
||||
[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;
|
||||
img.Mutate(m => m.FillPolygon(colorObjects[i], new(x, 0), new(x + 50, 0), new(x + 50, 50), new(x, 50)));
|
||||
}
|
||||
|
||||
await using var ms = img.ToStream();
|
||||
await ctx.Channel.SendFileAsync(ms, "colors.png");
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Avatar([Leftover] IGuildUser usr = null)
|
||||
{
|
||||
if (usr is null)
|
||||
usr = (IGuildUser)ctx.User;
|
||||
|
||||
var avatarUrl = usr.RealAvatarUrl(2048);
|
||||
|
||||
await ctx.Channel.EmbedAsync(
|
||||
_eb.Create()
|
||||
.WithOkColor()
|
||||
.AddField("Username", usr.ToString())
|
||||
.AddField("Avatar Url", avatarUrl)
|
||||
.WithThumbnailUrl(avatarUrl.ToString()),
|
||||
ctx.User.Mention);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Wikia(string target, [Leftover] string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.wikia_input_error);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Channel.TriggerTypingAsync();
|
||||
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))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.wikia_error);
|
||||
return;
|
||||
}
|
||||
|
||||
var url = Uri.EscapeDataString($"https://{target}.fandom.com/wiki/{title}");
|
||||
var response = $@"`{GetText(strs.title)}` {title.SanitizeMentions()}
|
||||
`{GetText(strs.url)}:` {url}";
|
||||
await ctx.Channel.SendMessageAsync(response);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.wikia_error);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Bible(string book, string chapterAndVerse)
|
||||
{
|
||||
var obj = new BibleVerses();
|
||||
try
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var res = await http.GetStringAsync($"https://bible-api.com/{book} {chapterAndVerse}");
|
||||
|
||||
obj = JsonConvert.DeserializeObject<BibleVerses>(res);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
if (obj.Error is not null || obj.Verses is null || obj.Verses.Length == 0)
|
||||
await SendErrorAsync(obj.Error ?? "No verse found.");
|
||||
else
|
||||
{
|
||||
var v = obj.Verses[0];
|
||||
await ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle($"{v.BookName} {v.Chapter}:{v.Verse}")
|
||||
.WithDescription(v.Text));
|
||||
}
|
||||
}
|
||||
|
||||
[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 ReplyErrorLocalizedAsync(strs.not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
//var embed = _eb.Create()
|
||||
// .WithOkColor()
|
||||
// .WithDescription(gameData.ShortDescription)
|
||||
// .WithTitle(gameData.Name)
|
||||
// .WithUrl(gameData.Link)
|
||||
// .WithImageUrl(gameData.HeaderImage)
|
||||
// .AddField(GetText(strs.genres), gameData.TotalEpisodes.ToString(), true)
|
||||
// .AddField(GetText(strs.price), gameData.IsFree ? GetText(strs.FREE) : game, true)
|
||||
// .AddField(GetText(strs.links), gameData.GetGenresString(), true)
|
||||
// .WithFooter(GetText(strs.recommendations(gameData.TotalRecommendations)));
|
||||
await ctx.Channel.SendMessageAsync($"https://store.steampowered.com/app/{appId}");
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateQuery([MaybeNullWhen(false)] string query)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
return true;
|
||||
|
||||
await ErrorLocalizedAsync(strs.specify_search_params);
|
||||
return false;
|
||||
}
|
||||
|
||||
public class ShortenData
|
||||
{
|
||||
[JsonProperty("result_url")] public string ResultUrl { get; set; }
|
||||
}
|
||||
}
|
468
src/Ellie.Bot.Modules.Searches/SearchesService.cs
Normal file
468
src/Ellie.Bot.Modules.Searches/SearchesService.cs
Normal file
|
@ -0,0 +1,468 @@
|
|||
#nullable disable
|
||||
using Html2Markdown;
|
||||
using Ellie.Modules.Searches.Common;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SixLabors.Fonts;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace Ellie.Modules.Searches.Services;
|
||||
|
||||
public class SearchesService : IEService
|
||||
{
|
||||
public enum ImageTag
|
||||
{
|
||||
Food,
|
||||
Dogs,
|
||||
Cats,
|
||||
Birds
|
||||
}
|
||||
|
||||
public List<WoWJoke> WowJokes { get; } = new();
|
||||
public List<MagicItem> MagicItems { get; } = new();
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly IGoogleApiService _google;
|
||||
private readonly IImageCache _imgs;
|
||||
private readonly IBotCache _c;
|
||||
private readonly FontProvider _fonts;
|
||||
private readonly IBotCredsProvider _creds;
|
||||
private readonly EllieRandom _rng;
|
||||
private readonly List<string> _yomamaJokes;
|
||||
|
||||
private readonly object _yomamaLock = new();
|
||||
private int yomamaJokeIndex;
|
||||
|
||||
public SearchesService(
|
||||
IGoogleApiService google,
|
||||
IImageCache images,
|
||||
IBotCache c,
|
||||
IHttpClientFactory factory,
|
||||
FontProvider fonts,
|
||||
IBotCredsProvider creds)
|
||||
{
|
||||
_httpFactory = factory;
|
||||
_google = google;
|
||||
_imgs = images;
|
||||
_c = c;
|
||||
_fonts = fonts;
|
||||
_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 = new();
|
||||
Log.Warning("data/yomama.txt is missing. .yomama command won't work");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Stream> GetRipPictureAsync(string text, Uri imgUrl)
|
||||
=> (await GetRipPictureFactory(text, imgUrl)).ToStream();
|
||||
|
||||
private void DrawAvatar(Image bg, Image avatarImage)
|
||||
=> bg.Mutate(x => x.Grayscale().DrawImage(avatarImage, new(83, 139), new GraphicsOptions()));
|
||||
|
||||
public async Task<byte[]> GetRipPictureFactory(string text, Uri avatarUrl)
|
||||
{
|
||||
using var bg = Image.Load<Rgba32>(await _imgs.GetRipBgAsync());
|
||||
var result = await _c.GetImageDataAsync(avatarUrl);
|
||||
if (!result.TryPickT0(out var data, out _))
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
data = await http.GetByteArrayAsync(avatarUrl);
|
||||
using (var avatarImg = Image.Load<Rgba32>(data))
|
||||
{
|
||||
avatarImg.Mutate(x => x.Resize(85, 85).ApplyRoundedCorners(42));
|
||||
await using var avStream = await avatarImg.ToStreamAsync();
|
||||
data = avStream.ToArray();
|
||||
DrawAvatar(bg, avatarImg);
|
||||
}
|
||||
|
||||
await _c.SetImageDataAsync(avatarUrl, data);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var avatarImg = Image.Load<Rgba32>(data);
|
||||
DrawAvatar(bg, avatarImg);
|
||||
}
|
||||
|
||||
bg.Mutate(x => x.DrawText(
|
||||
new TextOptions(_fonts.RipFont)
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
FallbackFontFamilies = _fonts.FallBackFonts,
|
||||
Origin = new(bg.Width / 2, 225),
|
||||
},
|
||||
text,
|
||||
Color.Black));
|
||||
|
||||
//flowa
|
||||
using (var flowers = Image.Load(await _imgs.GetRipOverlayAsync()))
|
||||
{
|
||||
bg.Mutate(x => x.DrawImage(flowers, new(0, 0), new GraphicsOptions()));
|
||||
}
|
||||
|
||||
await using var stream = bg.ToStream();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public async Task<WeatherData> GetWeatherDataAsync(string query)
|
||||
{
|
||||
query = query.Trim().ToLowerInvariant();
|
||||
|
||||
return await _c.GetOrAddAsync(new($"nadeko_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), TimeErrors?)> GetTimeDataAsync(string arg)
|
||||
=> GetTimeDataFactory(arg);
|
||||
|
||||
//return _cache.GetOrAddCachedDataAsync($"nadeko_time_{arg}",
|
||||
// GetTimeDataFactory,
|
||||
// arg,
|
||||
// TimeSpan.FromMinutes(1));
|
||||
private async Task<((string Address, DateTime Time, string TimeZoneName), TimeErrors?)> GetTimeDataFactory(
|
||||
string query)
|
||||
{
|
||||
query = query.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(query))
|
||||
return (default, TimeErrors.InvalidInput);
|
||||
|
||||
|
||||
var locIqKey = _creds.GetCreds().LocationIqApiKey;
|
||||
var tzDbKey = _creds.GetCreds().TimezoneDbApiKey;
|
||||
if (string.IsNullOrWhiteSpace(locIqKey) || string.IsNullOrWhiteSpace(tzDbKey))
|
||||
return (default, TimeErrors.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, TimeErrors.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, TimeErrors.NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
public string GetRandomImageUrl(ImageTag tag)
|
||||
{
|
||||
var subpath = tag.ToString().ToLowerInvariant();
|
||||
|
||||
int max;
|
||||
switch (tag)
|
||||
{
|
||||
case ImageTag.Food:
|
||||
max = 773;
|
||||
break;
|
||||
case ImageTag.Dogs:
|
||||
max = 750;
|
||||
break;
|
||||
case ImageTag.Cats:
|
||||
max = 773;
|
||||
break;
|
||||
case ImageTag.Birds:
|
||||
max = 578;
|
||||
break;
|
||||
default:
|
||||
max = 100;
|
||||
break;
|
||||
}
|
||||
|
||||
return $"https://nadeko-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(string.Format("https://omdbapi.nadeko.bot/"
|
||||
+ "?t={0}"
|
||||
+ "&y="
|
||||
+ "&plot=full"
|
||||
+ "&r=json",
|
||||
name.Trim().Replace(' ', '+')));
|
||||
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];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Ellie.Db;
|
||||
using Ellie.Db.Models;
|
||||
using Ellie.Modules.Searches.Services;
|
||||
|
||||
namespace Ellie.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 ReplyErrorLocalizedAsync(strs.stream_not_added);
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = _service.GetEmbed(ctx.Guild.Id, data);
|
||||
await ctx.Channel.EmbedAsync(embed, GetText(strs.stream_tracked));
|
||||
}
|
||||
|
||||
[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 ReplyErrorLocalizedAsync(strs.stream_no);
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.stream_removed(Format.Bold(fs.Username), fs.Type));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task StreamsClear()
|
||||
{
|
||||
await _service.ClearAllStreams(ctx.Guild.Id);
|
||||
await ReplyConfirmLocalizedAsync(strs.streams_cleared);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task StreamList(int page = 1)
|
||||
{
|
||||
if (page-- < 1)
|
||||
return;
|
||||
|
||||
var streams = new List<FollowedStream>();
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var all = uow.GuildConfigsForId(ctx.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 (((SocketGuild)ctx.Guild).GetTextChannel(fs.ChannelId) is null)
|
||||
await _service.UnfollowStreamAsync(fs.GuildId, index);
|
||||
else
|
||||
streams.Insert(0, fs);
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(page,
|
||||
cur =>
|
||||
{
|
||||
var elements = streams
|
||||
.Skip(cur * 12)
|
||||
.Take(12)
|
||||
.ToList();
|
||||
|
||||
if (elements.Count == 0)
|
||||
return _eb.Create().WithDescription(GetText(strs.streams_none)).WithErrorColor();
|
||||
|
||||
var eb = _eb.Create().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;
|
||||
},
|
||||
streams.Count,
|
||||
12);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
public async Task StreamOffline()
|
||||
{
|
||||
var newValue = _service.ToggleStreamOffline(ctx.Guild.Id);
|
||||
if (newValue)
|
||||
await ReplyConfirmLocalizedAsync(strs.stream_off_enabled);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.stream_off_disabled);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
public async Task StreamOnlineDelete()
|
||||
{
|
||||
var newValue = _service.ToggleStreamOnlineDelete(ctx.Guild.Id);
|
||||
if (newValue)
|
||||
await ReplyConfirmLocalizedAsync(strs.stream_online_delete_enabled);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.stream_online_delete_disabled);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
public async Task StreamMessage(int index, [Leftover] string message)
|
||||
{
|
||||
if (--index < 0)
|
||||
return;
|
||||
|
||||
if (!_service.SetStreamMessage(ctx.Guild.Id, index, message, out var fs))
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.stream_not_following);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
await ReplyConfirmLocalizedAsync(strs.stream_message_reset(Format.Bold(fs.Username)));
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.stream_message_set(Format.Bold(fs.Username)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
public async Task StreamMessageAll([Leftover] string message)
|
||||
{
|
||||
var count = _service.SetStreamMessageForAll(ctx.Guild.Id, message);
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.stream_not_following_any);
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.stream_message_set_all(count));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task StreamCheck(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = await _service.GetStreamDataAsync(url);
|
||||
if (data is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_channel_found);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.IsLive)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.streamer_online(Format.Bold(data.Name),
|
||||
Format.Bold(data.Viewers.ToString())));
|
||||
}
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.streamer_offline(data.Name));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_channel_found);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,617 @@
|
|||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Ellie.Common.ModuleBehaviors;
|
||||
using Ellie.Db;
|
||||
using Ellie.Db.Models;
|
||||
using Ellie.Modules.Searches.Common;
|
||||
using Ellie.Modules.Searches.Common.StreamNotifications;
|
||||
using Ellie.Services.Database.Models;
|
||||
|
||||
namespace Ellie.Modules.Searches.Services;
|
||||
|
||||
public sealed class StreamNotificationService : IEService, 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 IEmbedBuilderService _eb;
|
||||
|
||||
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,
|
||||
IEmbedBuilderService eb)
|
||||
{
|
||||
_db = db;
|
||||
_client = client;
|
||||
_strings = strings;
|
||||
_pubSub = pubSub;
|
||||
_eb = eb;
|
||||
|
||||
_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] = new()
|
||||
{
|
||||
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 => _client.GetGuild(fs.GuildId)
|
||||
?.GetTextChannel(fs.ChannelId)
|
||||
?.EmbedAsync(GetEmbed(fs.GuildId, stream)))
|
||||
.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 rep = new ReplacementBuilder().WithOverride("%user%", () => fs.Username)
|
||||
.WithOverride("%platform%", () => fs.Type.ToString())
|
||||
.Build();
|
||||
|
||||
var message = string.IsNullOrWhiteSpace(fs.Message) ? "" : rep.Replace(fs.Message);
|
||||
|
||||
var msg = await textChannel.EmbedAsync(GetEmbed(fs.GuildId, stream, false), message);
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
if (gc.FollowedStreams.Count >= 10)
|
||||
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 IEmbedBuilder GetEmbed(ulong guildId, StreamData status, bool showViewers = true)
|
||||
{
|
||||
var embed = _eb.Create()
|
||||
.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] = new();
|
||||
}
|
||||
|
||||
_shardTrackedStreams[key] = new()
|
||||
{
|
||||
{ guildId, new() }
|
||||
};
|
||||
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>().ToList();
|
||||
|
||||
if (all.Count == 0)
|
||||
return 0;
|
||||
|
||||
all.ForEach(x => x.Message = message);
|
||||
|
||||
uow.SaveChanges();
|
||||
|
||||
return all.Count;
|
||||
}
|
||||
|
||||
public sealed class FollowStreamPubData
|
||||
{
|
||||
public StreamDataKey Key { get; init; }
|
||||
public ulong GuildId { get; init; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Ellie.Common.ModuleBehaviors;
|
||||
using Ellie.Db.Models;
|
||||
using Ellie.Modules.Searches.Common;
|
||||
|
||||
namespace Ellie.Modules.Searches.Services;
|
||||
|
||||
public sealed class StreamOnlineMessageDeleterService : IEService, 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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
#nullable disable
|
||||
namespace Ellie.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/Ellie.Bot.Modules.Searches/Translate/TranslateService.cs
Normal file
224
src/Ellie.Bot.Modules.Searches/Translate/TranslateService.cs
Normal file
|
@ -0,0 +1,224 @@
|
|||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Ellie.Common.ModuleBehaviors;
|
||||
using Ellie.Services.Database.Models;
|
||||
using System.Net;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public sealed class TranslateService : ITranslateService, IExecNoCommand, IReadyExecutor, IEService
|
||||
{
|
||||
private readonly IGoogleApiService _google;
|
||||
private readonly DbService _db;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
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,
|
||||
IEmbedBuilderService eb,
|
||||
IBot bot)
|
||||
{
|
||||
_google = google;
|
||||
_db = db;
|
||||
_eb = eb;
|
||||
_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 = _eb.Create().WithOkColor();
|
||||
|
||||
if (autoDelete)
|
||||
{
|
||||
embed.WithAuthor(um.Author.ToString(), um.Author.GetAvatarUrl())
|
||||
.AddField(langs.From, um.Content)
|
||||
.AddField(langs.To, output);
|
||||
|
||||
await tch.EmbedAsync(embed);
|
||||
|
||||
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, target);
|
||||
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,96 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public partial class Searches
|
||||
{
|
||||
[Group]
|
||||
public partial class TranslateCommands : EllieModule<ITranslateService>
|
||||
{
|
||||
public enum AutoDeleteAutoTranslate
|
||||
{
|
||||
Del,
|
||||
Nodel
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Translate(string from, string to, [Leftover] string text = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ctx.Channel.TriggerTypingAsync();
|
||||
var translation = await _service.Translate(from, to, text);
|
||||
|
||||
var embed = _eb.Create(ctx).WithOkColor().AddField(from, text).AddField(to, translation);
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.bad_input_format);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.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 ReplyConfirmLocalizedAsync(strs.atl_started);
|
||||
else
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.atl_stopped);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task AutoTransLang()
|
||||
{
|
||||
if (await _service.UnregisterUser(ctx.Channel.Id, ctx.User.Id))
|
||||
await ReplyConfirmLocalizedAsync(strs.atl_removed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task AutoTransLang(string from, string to)
|
||||
{
|
||||
var succ = await _service.RegisterUserAsync(ctx.User.Id, ctx.Channel.Id, from.ToLower(), to.ToLower());
|
||||
|
||||
if (succ is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.atl_not_enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
if (succ is false)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.invalid_lang);
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.atl_set(from, to));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Translangs()
|
||||
{
|
||||
var langs = _service.GetLanguages().ToList();
|
||||
|
||||
var eb = _eb.Create()
|
||||
.WithTitle(GetText(strs.supported_languages))
|
||||
.WithOkColor();
|
||||
|
||||
foreach (var chunk in langs.Chunk(15))
|
||||
{
|
||||
eb.AddField("", chunk.Join("\n"), isInline: true);
|
||||
}
|
||||
|
||||
await ctx.Channel.EmbedAsync(eb);
|
||||
}
|
||||
}
|
||||
}
|
97
src/Ellie.Bot.Modules.Searches/XkcdCommands.cs
Normal file
97
src/Ellie.Bot.Modules.Searches/XkcdCommands.cs
Normal file
|
@ -0,0 +1,97 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.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 = _eb.Create()
|
||||
.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 ctx.Channel.EmbedAsync(embed);
|
||||
|
||||
await Task.Delay(10000);
|
||||
|
||||
await sent.ModifyAsync(m => m.Embed = embed.AddField("Alt", comic.Alt).Build());
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.comic_not_found);
|
||||
}
|
||||
|
||||
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 = _eb.Create()
|
||||
.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 ctx.Channel.EmbedAsync(embed);
|
||||
|
||||
await Task.Delay(10000);
|
||||
|
||||
await sent.ModifyAsync(m => m.Embed = embed.AddField("Alt", comic.Alt).Build());
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.comic_not_found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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/Ellie.Bot.Modules.Searches/YoutubeTrack/YtTrackService.cs
Normal file
134
src/Ellie.Bot.Modules.Searches/YoutubeTrack/YtTrackService.cs
Normal file
|
@ -0,0 +1,134 @@
|
|||
#nullable disable
|
||||
|
||||
// public class YtTrackService : IEService
|
||||
// {
|
||||
// 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 Ellie.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 ReplyConfirmLocalizedAsync(strs.yt_follow_added);
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// await ReplyConfirmLocalizedAsync(strs.yt_follow_fail);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// [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 ReplyConfirmLocalizedAsync(strs.yt_track_added);
|
||||
// //}
|
||||
// //else
|
||||
// //{
|
||||
// // await ReplyConfirmLocalizedAsync(strs.yt_track_fail);
|
||||
// //}
|
||||
// }
|
||||
//
|
||||
// [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 ReplyConfirmLocalizedAsync(strs.yt_track_added);
|
||||
// //}
|
||||
// //else
|
||||
// //{
|
||||
// // await ReplyConfirmLocalizedAsync(strs.yt_track_fail);
|
||||
// //}
|
||||
// }
|
||||
// }
|
||||
}
|
12
src/Ellie.Bot.Modules.Searches/_common/AltExtensions.cs
Normal file
12
src/Ellie.Bot.Modules.Searches/_common/AltExtensions.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
#nullable disable
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Ellie.Services.Database.Models;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public static class AltExtensions
|
||||
{
|
||||
public static Task<AutoTranslateChannel> GetByChannelId(this IQueryable<AutoTranslateChannel> set, ulong channelId)
|
||||
=> set.Include(x => x.Users).FirstOrDefaultAsyncEF(x => x.ChannelId == channelId);
|
||||
}
|
21
src/Ellie.Bot.Modules.Searches/_common/BibleVerses.cs
Normal file
21
src/Ellie.Bot.Modules.Searches/_common/BibleVerses.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
// todo replace newtonsoft with json.text
|
||||
public class BibleVerses
|
||||
{
|
||||
public string Error { get; set; }
|
||||
public BibleVerse[] Verses { get; set; }
|
||||
}
|
||||
|
||||
public class BibleVerse
|
||||
{
|
||||
[JsonProperty("book_name")]
|
||||
public string BookName { get; set; }
|
||||
|
||||
public int Chapter { get; set; }
|
||||
public int Verse { get; set; }
|
||||
public string Text { get; set; }
|
||||
}
|
66
src/Ellie.Bot.Modules.Searches/_common/CryptoData.cs
Normal file
66
src/Ellie.Bot.Modules.Searches/_common/CryptoData.cs
Normal file
|
@ -0,0 +1,66 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.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; }
|
||||
}
|
43
src/Ellie.Bot.Modules.Searches/_common/DefineModel.cs
Normal file
43
src/Ellie.Bot.Modules.Searches/_common/DefineModel.cs
Normal file
|
@ -0,0 +1,43 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.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/Ellie.Bot.Modules.Searches/_common/E621Object.cs
Normal file
24
src/Ellie.Bot.Modules.Searches/_common/E621Object.cs
Normal file
|
@ -0,0 +1,24 @@
|
|||
#nullable disable
|
||||
namespace Ellie.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/Ellie.Bot.Modules.Searches/_common/Extensions.cs
Normal file
9
src/Ellie.Bot.Modules.Searches/_common/Extensions.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
using Ellie.Db.Models;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
public static StreamDataKey CreateKey(this FollowedStream fs)
|
||||
=> new(fs.Type, fs.Username.ToLower());
|
||||
}
|
44
src/Ellie.Bot.Modules.Searches/_common/Gallery.cs
Normal file
44
src/Ellie.Bot.Modules.Searches/_common/Gallery.cs
Normal file
|
@ -0,0 +1,44 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Bot.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/Ellie.Bot.Modules.Searches/_common/GatariUserResponse.cs
Normal file
52
src/Ellie.Bot.Modules.Searches/_common/GatariUserResponse.cs
Normal file
|
@ -0,0 +1,52 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class UserData
|
||||
{
|
||||
[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<UserData> Users { get; set; }
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class UserStats
|
||||
{
|
||||
[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 UserStats Stats { get; set; }
|
||||
}
|
16
src/Ellie.Bot.Modules.Searches/_common/GoogleSearchResult.cs
Normal file
16
src/Ellie.Bot.Modules.Searches/_common/GoogleSearchResult.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
#nullable disable
|
||||
namespace Ellie.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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
#nullable disable
|
||||
namespace Ellie.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 Ellie.Modules.Searches.Common;
|
||||
|
||||
public class LowerCaseNamingPolicy : JsonNamingPolicy
|
||||
{
|
||||
public static LowerCaseNamingPolicy Default = new();
|
||||
|
||||
public override string ConvertName(string name)
|
||||
=> name.ToLower();
|
||||
}
|
8
src/Ellie.Bot.Modules.Searches/_common/MagicItem.cs
Normal file
8
src/Ellie.Bot.Modules.Searches/_common/MagicItem.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class MagicItem
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
}
|
26
src/Ellie.Bot.Modules.Searches/_common/MtgData.cs
Normal file
26
src/Ellie.Bot.Modules.Searches/_common/MtgData.cs
Normal file
|
@ -0,0 +1,26 @@
|
|||
#nullable disable
|
||||
namespace Ellie.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; }
|
||||
}
|
||||
}
|
14
src/Ellie.Bot.Modules.Searches/_common/NovelData.cs
Normal file
14
src/Ellie.Bot.Modules.Searches/_common/NovelData.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class NovelResult
|
||||
{
|
||||
public string Description { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Link { get; set; }
|
||||
public string ImageUrl { get; set; }
|
||||
public string[] Authors { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string[] Genres { get; set; }
|
||||
public string Score { get; set; }
|
||||
}
|
13
src/Ellie.Bot.Modules.Searches/_common/OmdbMovie.cs
Normal file
13
src/Ellie.Bot.Modules.Searches/_common/OmdbMovie.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class OmdbMovie
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string Year { get; set; }
|
||||
public string ImdbRating { get; set; }
|
||||
public string ImdbId { get; set; }
|
||||
public string Genre { get; set; }
|
||||
public string Plot { get; set; }
|
||||
public string Poster { get; set; }
|
||||
}
|
70
src/Ellie.Bot.Modules.Searches/_common/OsuUserData.cs
Normal file
70
src/Ellie.Bot.Modules.Searches/_common/OsuUserData.cs
Normal file
|
@ -0,0 +1,70 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class OsuUserData
|
||||
{
|
||||
[JsonProperty("user_id")]
|
||||
public string UserId { get; set; }
|
||||
|
||||
[JsonProperty("username")]
|
||||
public string Username { get; set; }
|
||||
|
||||
[JsonProperty("join_date")]
|
||||
public string JoinDate { get; set; }
|
||||
|
||||
[JsonProperty("count300")]
|
||||
public string Count300 { get; set; }
|
||||
|
||||
[JsonProperty("count100")]
|
||||
public string Count100 { get; set; }
|
||||
|
||||
[JsonProperty("count50")]
|
||||
public string Count50 { get; set; }
|
||||
|
||||
[JsonProperty("playcount")]
|
||||
public string Playcount { get; set; }
|
||||
|
||||
[JsonProperty("ranked_score")]
|
||||
public string RankedScore { get; set; }
|
||||
|
||||
[JsonProperty("total_score")]
|
||||
public string TotalScore { get; set; }
|
||||
|
||||
[JsonProperty("pp_rank")]
|
||||
public string PpRank { get; set; }
|
||||
|
||||
[JsonProperty("level")]
|
||||
public double Level { get; set; }
|
||||
|
||||
[JsonProperty("pp_raw")]
|
||||
public double PpRaw { get; set; }
|
||||
|
||||
[JsonProperty("accuracy")]
|
||||
public double Accuracy { get; set; }
|
||||
|
||||
[JsonProperty("count_rank_ss")]
|
||||
public string CountRankSs { get; set; }
|
||||
|
||||
[JsonProperty("count_rank_ssh")]
|
||||
public string CountRankSsh { get; set; }
|
||||
|
||||
[JsonProperty("count_rank_s")]
|
||||
public string CountRankS { get; set; }
|
||||
|
||||
[JsonProperty("count_rank_sh")]
|
||||
public string CountRankSh { get; set; }
|
||||
|
||||
[JsonProperty("count_rank_a")]
|
||||
public string CountRankA { get; set; }
|
||||
|
||||
[JsonProperty("country")]
|
||||
public string Country { get; set; }
|
||||
|
||||
[JsonProperty("total_seconds_played")]
|
||||
public string TotalSecondsPlayed { get; set; }
|
||||
|
||||
[JsonProperty("pp_country_rank")]
|
||||
public string PpCountryRank { get; set; }
|
||||
}
|
40
src/Ellie.Bot.Modules.Searches/_common/PathOfExileModels.cs
Normal file
40
src/Ellie.Bot.Modules.Searches/_common/PathOfExileModels.cs
Normal file
|
@ -0,0 +1,40 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class Account
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("league")]
|
||||
public string League { get; set; }
|
||||
|
||||
[JsonProperty("classId")]
|
||||
public int ClassId { get; set; }
|
||||
|
||||
[JsonProperty("ascendancyClass")]
|
||||
public int AscendancyClass { get; set; }
|
||||
|
||||
[JsonProperty("class")]
|
||||
public string Class { get; set; }
|
||||
|
||||
[JsonProperty("level")]
|
||||
public int Level { get; set; }
|
||||
}
|
||||
|
||||
public class Leagues
|
||||
{
|
||||
[JsonProperty("id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
[JsonProperty("url")]
|
||||
public string Url { get; set; }
|
||||
|
||||
[JsonProperty("startAt")]
|
||||
public DateTime StartAt { get; set; }
|
||||
|
||||
[JsonProperty("endAt")]
|
||||
public object EndAt { get; set; }
|
||||
}
|
35
src/Ellie.Bot.Modules.Searches/_common/SteamGameId.cs
Normal file
35
src/Ellie.Bot.Modules.Searches/_common/SteamGameId.cs
Normal file
|
@ -0,0 +1,35 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public class SteamGameId
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("appid")]
|
||||
public int AppId { get; set; }
|
||||
}
|
||||
|
||||
public class SteamGameData
|
||||
{
|
||||
public string ShortDescription { get; set; }
|
||||
|
||||
public class Container
|
||||
{
|
||||
[JsonProperty("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonProperty("data")]
|
||||
public SteamGameData Data { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public enum TimeErrors
|
||||
{
|
||||
InvalidInput,
|
||||
ApiKeyMissing,
|
||||
NotFound,
|
||||
Unknown
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
|
||||
public class HelixStreamsResponse
|
||||
{
|
||||
public class PaginationData
|
||||
{
|
||||
[JsonPropertyName("cursor")]
|
||||
public string Cursor { get; set; }
|
||||
}
|
||||
|
||||
public class StreamData
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
[JsonPropertyName("user_id")]
|
||||
public string UserId { get; set; }
|
||||
|
||||
[JsonPropertyName("user_login")]
|
||||
public string UserLogin { get; set; }
|
||||
|
||||
[JsonPropertyName("user_name")]
|
||||
public string UserName { get; set; }
|
||||
|
||||
[JsonPropertyName("game_id")]
|
||||
public string GameId { get; set; }
|
||||
|
||||
[JsonPropertyName("game_name")]
|
||||
public string GameName { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[JsonPropertyName("viewer_count")]
|
||||
public int ViewerCount { get; set; }
|
||||
|
||||
[JsonPropertyName("started_at")]
|
||||
public DateTime StartedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("language")]
|
||||
public string Language { get; set; }
|
||||
|
||||
[JsonPropertyName("thumbnail_url")]
|
||||
public string ThumbnailUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("tag_ids")]
|
||||
public List<string> TagIds { get; set; }
|
||||
|
||||
[JsonPropertyName("is_mature")]
|
||||
public bool IsMature { get; set; }
|
||||
}
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
public List<StreamData> Data { get; set; }
|
||||
|
||||
[JsonPropertyName("pagination")]
|
||||
public PaginationData Pagination { get; set; }
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
|
||||
public class HelixUsersResponse
|
||||
{
|
||||
public class User
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
[JsonPropertyName("login")]
|
||||
public string Login { get; set; }
|
||||
|
||||
[JsonPropertyName("display_name")]
|
||||
public string DisplayName { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonPropertyName("broadcaster_type")]
|
||||
public string BroadcasterType { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonPropertyName("profile_image_url")]
|
||||
public string ProfileImageUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("offline_image_url")]
|
||||
public string OfflineImageUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("view_count")]
|
||||
public int ViewCount { get; set; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string Email { get; set; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
public List<User> Data { get; set; }
|
||||
}
|
|
@ -0,0 +1,157 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class PicartoChannelResponse
|
||||
{
|
||||
[JsonProperty("user_id")]
|
||||
public int UserId { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("avatar")]
|
||||
public string Avatar { get; set; }
|
||||
|
||||
[JsonProperty("online")]
|
||||
public bool Online { get; set; }
|
||||
|
||||
[JsonProperty("viewers")]
|
||||
public int Viewers { get; set; }
|
||||
|
||||
[JsonProperty("viewers_total")]
|
||||
public int ViewersTotal { get; set; }
|
||||
|
||||
[JsonProperty("thumbnails")]
|
||||
public Thumbnails Thumbnails { get; set; }
|
||||
|
||||
[JsonProperty("followers")]
|
||||
public int Followers { get; set; }
|
||||
|
||||
[JsonProperty("subscribers")]
|
||||
public int Subscribers { get; set; }
|
||||
|
||||
[JsonProperty("adult")]
|
||||
public bool Adult { get; set; }
|
||||
|
||||
[JsonProperty("category")]
|
||||
public string Category { get; set; }
|
||||
|
||||
[JsonProperty("account_type")]
|
||||
public string AccountType { get; set; }
|
||||
|
||||
[JsonProperty("commissions")]
|
||||
public bool Commissions { get; set; }
|
||||
|
||||
[JsonProperty("recordings")]
|
||||
public bool Recordings { get; set; }
|
||||
|
||||
[JsonProperty("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[JsonProperty("description_panels")]
|
||||
public List<DescriptionPanel> DescriptionPanels { get; set; }
|
||||
|
||||
[JsonProperty("private")]
|
||||
public bool Private { get; set; }
|
||||
|
||||
[JsonProperty("private_message")]
|
||||
public string PrivateMessage { get; set; }
|
||||
|
||||
[JsonProperty("gaming")]
|
||||
public bool Gaming { get; set; }
|
||||
|
||||
[JsonProperty("chat_settings")]
|
||||
public ChatSettings ChatSettings { get; set; }
|
||||
|
||||
[JsonProperty("last_live")]
|
||||
public DateTime LastLive { get; set; }
|
||||
|
||||
[JsonProperty("tags")]
|
||||
public List<string> Tags { get; set; }
|
||||
|
||||
[JsonProperty("multistream")]
|
||||
public List<Multistream> Multistream { get; set; }
|
||||
|
||||
[JsonProperty("languages")]
|
||||
public List<Language> Languages { get; set; }
|
||||
|
||||
[JsonProperty("following")]
|
||||
public bool Following { get; set; }
|
||||
}
|
||||
|
||||
public class Thumbnails
|
||||
{
|
||||
[JsonProperty("web")]
|
||||
public string Web { get; set; }
|
||||
|
||||
[JsonProperty("web_large")]
|
||||
public string WebLarge { get; set; }
|
||||
|
||||
[JsonProperty("mobile")]
|
||||
public string Mobile { get; set; }
|
||||
|
||||
[JsonProperty("tablet")]
|
||||
public string Tablet { get; set; }
|
||||
}
|
||||
|
||||
public class DescriptionPanel
|
||||
{
|
||||
[JsonProperty("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[JsonProperty("body")]
|
||||
public string Body { get; set; }
|
||||
|
||||
[JsonProperty("image")]
|
||||
public string Image { get; set; }
|
||||
|
||||
[JsonProperty("image_link")]
|
||||
public string ImageLink { get; set; }
|
||||
|
||||
[JsonProperty("button_text")]
|
||||
public string ButtonText { get; set; }
|
||||
|
||||
[JsonProperty("button_link")]
|
||||
public string ButtonLink { get; set; }
|
||||
|
||||
[JsonProperty("position")]
|
||||
public int Position { get; set; }
|
||||
}
|
||||
|
||||
public class ChatSettings
|
||||
{
|
||||
[JsonProperty("guest_chat")]
|
||||
public bool GuestChat { get; set; }
|
||||
|
||||
[JsonProperty("links")]
|
||||
public bool Links { get; set; }
|
||||
|
||||
[JsonProperty("level")]
|
||||
public int Level { get; set; }
|
||||
}
|
||||
|
||||
public class Multistream
|
||||
{
|
||||
[JsonProperty("user_id")]
|
||||
public int UserId { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("online")]
|
||||
public bool Online { get; set; }
|
||||
|
||||
[JsonProperty("adult")]
|
||||
public bool Adult { get; set; }
|
||||
}
|
||||
|
||||
public class Language
|
||||
{
|
||||
[JsonProperty("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
#nullable disable
|
||||
using Ellie.Db.Models;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public readonly struct StreamDataKey
|
||||
{
|
||||
public FollowedStream.FType Type { get; init; }
|
||||
public string Name { get; init; }
|
||||
|
||||
public StreamDataKey(FollowedStream.FType type, string name)
|
||||
{
|
||||
Type = type;
|
||||
Name = name;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
|
||||
public class TrovoGetUsersResponse
|
||||
{
|
||||
[JsonPropertyName("is_live")]
|
||||
public bool IsLive { get; set; }
|
||||
|
||||
[JsonPropertyName("category_id")]
|
||||
public string CategoryId { get; set; }
|
||||
|
||||
[JsonPropertyName("category_name")]
|
||||
public string CategoryName { get; set; }
|
||||
|
||||
[JsonPropertyName("live_title")]
|
||||
public string LiveTitle { get; set; }
|
||||
|
||||
[JsonPropertyName("audi_type")]
|
||||
public string AudiType { get; set; }
|
||||
|
||||
[JsonPropertyName("language_code")]
|
||||
public string LanguageCode { get; set; }
|
||||
|
||||
[JsonPropertyName("thumbnail")]
|
||||
public string Thumbnail { get; set; }
|
||||
|
||||
[JsonPropertyName("current_viewers")]
|
||||
public int CurrentViewers { get; set; }
|
||||
|
||||
[JsonPropertyName("followers")]
|
||||
public int Followers { get; set; }
|
||||
|
||||
[JsonPropertyName("streamer_info")]
|
||||
public string StreamerInfo { get; set; }
|
||||
|
||||
[JsonPropertyName("profile_pic")]
|
||||
public string ProfilePic { get; set; }
|
||||
|
||||
[JsonPropertyName("channel_url")]
|
||||
public string ChannelUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public string CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("subscriber_num")]
|
||||
public int SubscriberNum { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; }
|
||||
|
||||
[JsonPropertyName("social_links")]
|
||||
public List<TrovoSocialLink> SocialLinks { get; set; }
|
||||
|
||||
[JsonPropertyName("started_at")]
|
||||
public string StartedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("ended_at")]
|
||||
public string EndedAt { get; set; }
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
|
||||
public class TrovoRequestData
|
||||
{
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; }
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
#nullable disable
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
|
||||
public class TrovoSocialLink
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; }
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class TwitchResponseV5
|
||||
{
|
||||
public List<Stream> Streams { get; set; }
|
||||
|
||||
public class Channel
|
||||
{
|
||||
[JsonProperty("_id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonProperty("broadcaster_language")]
|
||||
public string BroadcasterLanguage { get; set; }
|
||||
|
||||
[JsonProperty("created_at")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonProperty("display_name")]
|
||||
public string DisplayName { get; set; }
|
||||
|
||||
[JsonProperty("followers")]
|
||||
public int Followers { get; set; }
|
||||
|
||||
[JsonProperty("game")]
|
||||
public string Game { get; set; }
|
||||
|
||||
[JsonProperty("language")]
|
||||
public string Language { get; set; }
|
||||
|
||||
[JsonProperty("logo")]
|
||||
public string Logo { get; set; }
|
||||
|
||||
[JsonProperty("mature")]
|
||||
public bool Mature { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("partner")]
|
||||
public bool Partner { get; set; }
|
||||
|
||||
[JsonProperty("profile_banner")]
|
||||
public string ProfileBanner { get; set; }
|
||||
|
||||
[JsonProperty("profile_banner_background_color")]
|
||||
public object ProfileBannerBackgroundColor { get; set; }
|
||||
|
||||
[JsonProperty("status")]
|
||||
public string Status { get; set; }
|
||||
|
||||
[JsonProperty("updated_at")]
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
[JsonProperty("url")]
|
||||
public string Url { get; set; }
|
||||
|
||||
[JsonProperty("video_banner")]
|
||||
public string VideoBanner { get; set; }
|
||||
|
||||
[JsonProperty("views")]
|
||||
public int Views { get; set; }
|
||||
}
|
||||
|
||||
public class Preview
|
||||
{
|
||||
[JsonProperty("large")]
|
||||
public string Large { get; set; }
|
||||
|
||||
[JsonProperty("medium")]
|
||||
public string Medium { get; set; }
|
||||
|
||||
[JsonProperty("small")]
|
||||
public string Small { get; set; }
|
||||
|
||||
[JsonProperty("template")]
|
||||
public string Template { get; set; }
|
||||
}
|
||||
|
||||
public class Stream
|
||||
{
|
||||
[JsonProperty("_id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonProperty("average_fps")]
|
||||
public double AverageFps { get; set; }
|
||||
|
||||
[JsonProperty("channel")]
|
||||
public Channel Channel { get; set; }
|
||||
|
||||
[JsonProperty("created_at")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonProperty("delay")]
|
||||
public double Delay { get; set; }
|
||||
|
||||
[JsonProperty("game")]
|
||||
public string Game { get; set; }
|
||||
|
||||
[JsonProperty("is_playlist")]
|
||||
public bool IsPlaylist { get; set; }
|
||||
|
||||
[JsonProperty("preview")]
|
||||
public Preview Preview { get; set; }
|
||||
|
||||
[JsonProperty("video_height")]
|
||||
public int VideoHeight { get; set; }
|
||||
|
||||
[JsonProperty("viewers")]
|
||||
public int Viewers { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class TwitchUsersResponseV5
|
||||
{
|
||||
[JsonProperty("users")]
|
||||
public List<User> Users { get; set; }
|
||||
|
||||
public class User
|
||||
{
|
||||
[JsonProperty("_id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
// [JsonProperty("bio")]
|
||||
// public string Bio { get; set; }
|
||||
//
|
||||
// [JsonProperty("created_at")]
|
||||
// public DateTime CreatedAt { get; set; }
|
||||
//
|
||||
// [JsonProperty("display_name")]
|
||||
// public string DisplayName { get; set; }
|
||||
//
|
||||
// [JsonProperty("logo")]
|
||||
// public string Logo { get; set; }
|
||||
//
|
||||
// [JsonProperty("name")]
|
||||
// public string Name { get; set; }
|
||||
//
|
||||
// [JsonProperty("type")]
|
||||
// public string Type { get; set; }
|
||||
//
|
||||
// [JsonProperty("updated_at")]
|
||||
// public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,215 @@
|
|||
using Ellie.Db.Models;
|
||||
using Ellie.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common.StreamNotifications;
|
||||
|
||||
public class NotifChecker
|
||||
{
|
||||
public event Func<List<StreamData>, Task> OnStreamsOffline = _ => Task.CompletedTask;
|
||||
public event Func<List<StreamData>, Task> OnStreamsOnline = _ => Task.CompletedTask;
|
||||
|
||||
private readonly IReadOnlyDictionary<FollowedStream.FType, Provider> _streamProviders;
|
||||
private readonly HashSet<(FollowedStream.FType, string)> _offlineBuffer;
|
||||
private readonly ConcurrentDictionary<StreamDataKey, StreamData?> _cache = new();
|
||||
|
||||
public NotifChecker(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IBotCredsProvider credsProvider)
|
||||
{
|
||||
_streamProviders = new Dictionary<FollowedStream.FType, Provider>()
|
||||
{
|
||||
{ FollowedStream.FType.Twitch, new TwitchHelixProvider(httpClientFactory, credsProvider) },
|
||||
{ FollowedStream.FType.Picarto, new PicartoProvider(httpClientFactory) },
|
||||
{ FollowedStream.FType.Trovo, new TrovoProvider(httpClientFactory, credsProvider) }
|
||||
};
|
||||
_offlineBuffer = new();
|
||||
}
|
||||
|
||||
// gets all streams which have been failing for more than the provided timespan
|
||||
public IEnumerable<StreamDataKey> GetFailingStreams(TimeSpan duration, bool remove = false)
|
||||
{
|
||||
var toReturn = _streamProviders
|
||||
.SelectMany(prov => prov.Value
|
||||
.FailingStreams
|
||||
.Where(fs => DateTime.UtcNow - fs.Value > duration)
|
||||
.Select(fs => new StreamDataKey(prov.Value.Platform, fs.Key)))
|
||||
.ToList();
|
||||
|
||||
if (remove)
|
||||
{
|
||||
foreach (var toBeRemoved in toReturn)
|
||||
_streamProviders[toBeRemoved.Type].ClearErrorsFor(toBeRemoved.Name);
|
||||
}
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
public Task RunAsync()
|
||||
=> Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var allStreamData = GetAllData();
|
||||
|
||||
var oldStreamDataDict = allStreamData
|
||||
// group by type
|
||||
.GroupBy(entry => entry.Key.Type)
|
||||
.ToDictionary(entry => entry.Key,
|
||||
entry => entry.AsEnumerable()
|
||||
.ToDictionary(x => x.Key.Name, x => x.Value));
|
||||
|
||||
var newStreamData = await oldStreamDataDict
|
||||
.Select(x =>
|
||||
{
|
||||
// get all stream data for the streams of this type
|
||||
if (_streamProviders.TryGetValue(x.Key,
|
||||
out var provider))
|
||||
{
|
||||
return provider.GetStreamDataAsync(x.Value
|
||||
.Select(entry => entry.Key)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
// this means there's no provider for this stream data, (and there was before?)
|
||||
return Task.FromResult<IReadOnlyCollection<StreamData>>(
|
||||
new List<StreamData>());
|
||||
})
|
||||
.WhenAll();
|
||||
|
||||
var newlyOnline = new List<StreamData>();
|
||||
var newlyOffline = new List<StreamData>();
|
||||
// go through all new stream data, compare them with the old ones
|
||||
foreach (var newData in newStreamData.SelectMany(x => x))
|
||||
{
|
||||
// update cached data
|
||||
var key = newData.CreateKey();
|
||||
|
||||
// compare old data with new data
|
||||
if (!oldStreamDataDict.TryGetValue(key.Type, out var typeDict)
|
||||
|| !typeDict.TryGetValue(key.Name, out var oldData)
|
||||
|| oldData is null)
|
||||
{
|
||||
AddLastData(key, newData, true);
|
||||
continue;
|
||||
}
|
||||
|
||||
// fill with last known game in case it's empty
|
||||
if (string.IsNullOrWhiteSpace(newData.Game))
|
||||
newData.Game = oldData.Game;
|
||||
|
||||
AddLastData(key, newData, true);
|
||||
|
||||
// if the stream is offline, we need to check if it was
|
||||
// marked as offline once previously
|
||||
// if it was, that means this is second time we're getting offline
|
||||
// status for that stream -> notify subscribers
|
||||
// Note: This is done because twitch api will sometimes return an offline status
|
||||
// shortly after the stream is already online, which causes duplicate notifications.
|
||||
// (stream is online -> stream is offline -> stream is online again (and stays online))
|
||||
// This offlineBuffer will make it so that the stream has to be marked as offline TWICE
|
||||
// before it sends an offline notification to the subscribers.
|
||||
var streamId = (key.Type, key.Name);
|
||||
if (!newData.IsLive && _offlineBuffer.Remove(streamId))
|
||||
newlyOffline.Add(newData);
|
||||
else if (newData.IsLive != oldData.IsLive)
|
||||
{
|
||||
if (newData.IsLive)
|
||||
{
|
||||
_offlineBuffer.Remove(streamId);
|
||||
newlyOnline.Add(newData);
|
||||
}
|
||||
else
|
||||
{
|
||||
_offlineBuffer.Add(streamId);
|
||||
// newlyOffline.Add(newData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
Task.Delay(30_000)
|
||||
};
|
||||
|
||||
if (newlyOnline.Count > 0)
|
||||
tasks.Add(OnStreamsOnline(newlyOnline));
|
||||
|
||||
if (newlyOffline.Count > 0)
|
||||
tasks.Add(OnStreamsOffline(newlyOffline));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error getting stream notifications: {ErrorMessage}", ex.Message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
public bool AddLastData(StreamDataKey key, StreamData? data, bool replace)
|
||||
{
|
||||
if (replace)
|
||||
{
|
||||
_cache[key] = data;
|
||||
return true;
|
||||
}
|
||||
|
||||
return _cache.TryAdd(key, data);
|
||||
}
|
||||
|
||||
public void DeleteLastData(StreamDataKey key)
|
||||
=> _cache.TryRemove(key, out _);
|
||||
|
||||
public Dictionary<StreamDataKey, StreamData?> GetAllData()
|
||||
=> _cache.ToDictionary(x => x.Key, x => x.Value);
|
||||
|
||||
public async Task<StreamData?> GetStreamDataByUrlAsync(string url)
|
||||
{
|
||||
// loop through all providers and see which regex matches
|
||||
foreach (var (_, provider) in _streamProviders)
|
||||
{
|
||||
var isValid = await provider.IsValidUrl(url);
|
||||
if (!isValid)
|
||||
continue;
|
||||
// if it's not a valid url, try another provider
|
||||
var data = await provider.GetStreamDataByUrlAsync(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
// if no provider found, return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return currently available stream data, get new one if none available, and start tracking the stream.
|
||||
/// </summary>
|
||||
/// <param name="url">Url of the stream</param>
|
||||
/// <returns>Stream data, if any</returns>
|
||||
public async Task<StreamData?> TrackStreamByUrlAsync(string url)
|
||||
{
|
||||
var data = await GetStreamDataByUrlAsync(url);
|
||||
EnsureTracked(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Make sure a stream is tracked using its stream data.
|
||||
/// </summary>
|
||||
/// <param name="data">Data to try to track if not already tracked</param>
|
||||
/// <returns>Whether it's newly added</returns>
|
||||
private bool EnsureTracked(StreamData? data)
|
||||
{
|
||||
// something failed, don't add anything to cache
|
||||
if (data is null)
|
||||
return false;
|
||||
|
||||
// if stream is found, add it to the cache for tracking only if it doesn't already exist
|
||||
// because stream will be checked and events will fire in a loop. We don't want to override old state
|
||||
return AddLastData(data.CreateKey(), data, false);
|
||||
}
|
||||
|
||||
public void UntrackStreamByKey(in StreamDataKey key)
|
||||
=> DeleteLastData(key);
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
using Ellie.Db.Models;
|
||||
using Newtonsoft.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
|
||||
public class PicartoProvider : Provider
|
||||
{
|
||||
private static Regex Regex { get; } = new(@"picarto.tv/(?<name>.+[^/])/?",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public override FollowedStream.FType Platform
|
||||
=> FollowedStream.FType.Picarto;
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public PicartoProvider(IHttpClientFactory httpClientFactory)
|
||||
=> _httpClientFactory = httpClientFactory;
|
||||
|
||||
public override Task<bool> IsValidUrl(string url)
|
||||
{
|
||||
var match = Regex.Match(url);
|
||||
if (!match.Success)
|
||||
return Task.FromResult(false);
|
||||
|
||||
// var username = match.Groups["name"].Value;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public override Task<StreamData?> GetStreamDataByUrlAsync(string url)
|
||||
{
|
||||
var match = Regex.Match(url);
|
||||
if (match.Success)
|
||||
{
|
||||
var name = match.Groups["name"].Value;
|
||||
return GetStreamDataAsync(name);
|
||||
}
|
||||
|
||||
return Task.FromResult<StreamData?>(null);
|
||||
}
|
||||
|
||||
public override async Task<StreamData?> GetStreamDataAsync(string login)
|
||||
{
|
||||
var data = await GetStreamDataAsync(new List<string>
|
||||
{
|
||||
login
|
||||
});
|
||||
|
||||
return data.FirstOrDefault();
|
||||
}
|
||||
|
||||
public override async Task<IReadOnlyCollection<StreamData>> GetStreamDataAsync(List<string> logins)
|
||||
{
|
||||
if (logins.Count == 0)
|
||||
return new List<StreamData>();
|
||||
|
||||
using var http = _httpClientFactory.CreateClient();
|
||||
var toReturn = new List<StreamData>();
|
||||
foreach (var login in logins)
|
||||
{
|
||||
try
|
||||
{
|
||||
http.DefaultRequestHeaders.Accept.Add(new("application/json"));
|
||||
// get id based on the username
|
||||
using var res = await http.GetAsync($"https://api.picarto.tv/v1/channel/name/{login}");
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
continue;
|
||||
|
||||
var userData =
|
||||
JsonConvert.DeserializeObject<PicartoChannelResponse>(await res.Content.ReadAsStringAsync())!;
|
||||
|
||||
toReturn.Add(ToStreamData(userData));
|
||||
_failingStreams.TryRemove(login, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Something went wrong retreiving {StreamPlatform} stream data for {Login}: {ErrorMessage}",
|
||||
Platform,
|
||||
login,
|
||||
ex.Message);
|
||||
_failingStreams.TryAdd(login, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
private StreamData ToStreamData(PicartoChannelResponse stream)
|
||||
=> new()
|
||||
{
|
||||
StreamType = FollowedStream.FType.Picarto,
|
||||
Name = stream.Name,
|
||||
UniqueName = stream.Name,
|
||||
Viewers = stream.Viewers,
|
||||
Title = stream.Title,
|
||||
IsLive = stream.Online,
|
||||
Preview = stream.Thumbnails.Web,
|
||||
Game = stream.Category,
|
||||
StreamUrl = $"https://picarto.tv/{stream.Name}",
|
||||
AvatarUrl = stream.Avatar
|
||||
};
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
using Ellie.Db.Models;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract class implemented by providers of all supported platforms
|
||||
/// </summary>
|
||||
public abstract class Provider
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of the platform.
|
||||
/// </summary>
|
||||
public abstract FollowedStream.FType Platform { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stream usernames which fail to execute due to an error, and when they started throwing errors.
|
||||
/// This can happen if stream name is invalid, or if the stream doesn't exist anymore.
|
||||
/// Override to provide a custom implementation
|
||||
/// </summary>
|
||||
public virtual IReadOnlyDictionary<string, DateTime> FailingStreams
|
||||
=> _failingStreams;
|
||||
|
||||
/// <summary>
|
||||
/// When was the first time the stream continually had errors while being retrieved
|
||||
/// </summary>
|
||||
protected readonly ConcurrentDictionary<string, DateTime> _failingStreams = new();
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the specified url is a valid stream url for this platform.
|
||||
/// </summary>
|
||||
/// <param name="url">Url to check</param>
|
||||
/// <returns>True if valid, otherwise false</returns>
|
||||
public abstract Task<bool> IsValidUrl(string url);
|
||||
|
||||
/// <summary>
|
||||
/// Gets stream data of the stream on the specified url on this <see cref="Platform" />
|
||||
/// </summary>
|
||||
/// <param name="url">Url of the stream</param>
|
||||
/// <returns><see cref="StreamData" /> of the specified stream. Null if none found</returns>
|
||||
public abstract Task<StreamData?> GetStreamDataByUrlAsync(string url);
|
||||
|
||||
/// <summary>
|
||||
/// Gets stream data of the specified id/username on this <see cref="Platform" />
|
||||
/// </summary>
|
||||
/// <param name="login">Name (or id where applicable) of the user on the platform</param>
|
||||
/// <returns><see cref="StreamData" /> of the user. Null if none found</returns>
|
||||
public abstract Task<StreamData?> GetStreamDataAsync(string login);
|
||||
|
||||
/// <summary>
|
||||
/// Gets stream data of all specified ids/usernames on this <see cref="Platform" />
|
||||
/// </summary>
|
||||
/// <param name="usernames">List of ids/usernames</param>
|
||||
/// <returns><see cref="StreamData" /> of all users, in the same order. Null for every id/user not found.</returns>
|
||||
public abstract Task<IReadOnlyCollection<StreamData>> GetStreamDataAsync(List<string> usernames);
|
||||
|
||||
/// <summary>
|
||||
/// Unmark the stream as errored. You should override this method
|
||||
/// if you've overridden the <see cref="FailingStreams"/> property.
|
||||
/// </summary>
|
||||
/// <param name="login"></param>
|
||||
public virtual void ClearErrorsFor(string login)
|
||||
=> _failingStreams.Clear();
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
using Ellie.Db.Models;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
|
||||
public class TrovoProvider : Provider
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public override FollowedStream.FType Platform
|
||||
=> FollowedStream.FType.Trovo;
|
||||
|
||||
private readonly Regex _urlRegex = new(@"trovo.live\/(?<channel>[\w\d\-_]+)/?", RegexOptions.Compiled);
|
||||
|
||||
private readonly IBotCredsProvider _creds;
|
||||
|
||||
|
||||
public TrovoProvider(IHttpClientFactory httpClientFactory, IBotCredsProvider creds)
|
||||
{
|
||||
(_httpClientFactory, _creds) = (httpClientFactory, creds);
|
||||
|
||||
|
||||
if (string.IsNullOrWhiteSpace(creds.GetCreds().TrovoClientId))
|
||||
{
|
||||
Log.Warning("""
|
||||
Trovo streams are using a default clientId.
|
||||
If you are experiencing ratelimits, you should create your own application at: https://developer.trovo.live/
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<bool> IsValidUrl(string url)
|
||||
=> Task.FromResult(_urlRegex.IsMatch(url));
|
||||
|
||||
public override Task<StreamData?> GetStreamDataByUrlAsync(string url)
|
||||
{
|
||||
var match = _urlRegex.Match(url);
|
||||
if (match.Length == 0)
|
||||
return Task.FromResult(default(StreamData?));
|
||||
|
||||
return GetStreamDataAsync(match.Groups["channel"].Value);
|
||||
}
|
||||
|
||||
public override async Task<StreamData?> GetStreamDataAsync(string login)
|
||||
{
|
||||
using var http = _httpClientFactory.CreateClient();
|
||||
|
||||
var trovoClientId = _creds.GetCreds().TrovoClientId;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trovoClientId))
|
||||
{
|
||||
trovoClientId = "8b3cc4719b7051803099661a3265e50b";
|
||||
}
|
||||
|
||||
http.DefaultRequestHeaders.Clear();
|
||||
http.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
http.DefaultRequestHeaders.Add("Client-ID", trovoClientId);
|
||||
|
||||
// trovo ratelimit is very generous (1200 per minute)
|
||||
// so there is no need for ratelimit checks atm
|
||||
try
|
||||
{
|
||||
using var res = await http.PostAsJsonAsync(
|
||||
$"https://open-api.trovo.live/openplatform/channels/id",
|
||||
new TrovoRequestData()
|
||||
{
|
||||
Username = login
|
||||
});
|
||||
|
||||
res.EnsureSuccessStatusCode();
|
||||
|
||||
var data = await res.Content.ReadFromJsonAsync<TrovoGetUsersResponse>();
|
||||
|
||||
if (data is null)
|
||||
{
|
||||
Log.Warning("An empty response received while retrieving stream data for trovo.live/{TrovoId}", login);
|
||||
_failingStreams.TryAdd(login, DateTime.UtcNow);
|
||||
return null;
|
||||
}
|
||||
|
||||
_failingStreams.TryRemove(data.Username, out _);
|
||||
return new()
|
||||
{
|
||||
IsLive = data.IsLive,
|
||||
Game = data.CategoryName,
|
||||
Name = data.Username,
|
||||
Title = data.LiveTitle,
|
||||
Viewers = data.CurrentViewers,
|
||||
AvatarUrl = data.ProfilePic,
|
||||
StreamType = Platform,
|
||||
StreamUrl = data.ChannelUrl,
|
||||
UniqueName = data.Username,
|
||||
Preview = data.Thumbnail
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error retrieving stream data for trovo.live/{TrovoId}", login);
|
||||
_failingStreams.TryAdd(login, DateTime.UtcNow);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<IReadOnlyCollection<StreamData>> GetStreamDataAsync(List<string> usernames)
|
||||
{
|
||||
var trovoClientId = _creds.GetCreds().TrovoClientId;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trovoClientId))
|
||||
{
|
||||
Log.Warning("Trovo streams will be ignored until TrovoClientId is added to creds.yml");
|
||||
return Array.Empty<StreamData>();
|
||||
}
|
||||
|
||||
var results = new List<StreamData>(usernames.Count);
|
||||
foreach (var chunk in usernames.Chunk(10)
|
||||
.Select(x => x.Select(GetStreamDataAsync)))
|
||||
{
|
||||
var chunkResults = await Task.WhenAll(chunk);
|
||||
results.AddRange(chunkResults.Where(x => x is not null)!);
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,197 @@
|
|||
using Ellie.Db.Models;
|
||||
using System.Text.RegularExpressions;
|
||||
using TwitchLib.Api;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common.StreamNotifications.Providers;
|
||||
|
||||
public sealed class TwitchHelixProvider : Provider
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
private static Regex Regex { get; } = new(@"twitch.tv/(?<name>[\w\d\-_]+)/?",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public override FollowedStream.FType Platform
|
||||
=> FollowedStream.FType.Twitch;
|
||||
|
||||
private readonly Lazy<TwitchAPI> _api;
|
||||
private readonly string _clientId;
|
||||
|
||||
public TwitchHelixProvider(IHttpClientFactory httpClientFactory, IBotCredsProvider credsProvider)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
|
||||
var creds = credsProvider.GetCreds();
|
||||
_clientId = creds.TwitchClientId;
|
||||
var clientSecret = creds.TwitchClientSecret;
|
||||
_api = new(() => new()
|
||||
{
|
||||
Helix =
|
||||
{
|
||||
Settings =
|
||||
{
|
||||
ClientId = _clientId,
|
||||
Secret = clientSecret
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string?> EnsureTokenValidAsync()
|
||||
=> await _api.Value.Auth.GetAccessTokenAsync();
|
||||
|
||||
public override Task<bool> IsValidUrl(string url)
|
||||
{
|
||||
var match = Regex.Match(url);
|
||||
if (!match.Success)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public override Task<StreamData?> GetStreamDataByUrlAsync(string url)
|
||||
{
|
||||
var match = Regex.Match(url);
|
||||
if (match.Success)
|
||||
{
|
||||
var name = match.Groups["name"].Value;
|
||||
return GetStreamDataAsync(name);
|
||||
}
|
||||
|
||||
return Task.FromResult<StreamData?>(null);
|
||||
}
|
||||
|
||||
public override async Task<StreamData?> GetStreamDataAsync(string login)
|
||||
{
|
||||
var data = await GetStreamDataAsync(new List<string>
|
||||
{
|
||||
login
|
||||
});
|
||||
|
||||
return data.FirstOrDefault();
|
||||
}
|
||||
|
||||
public override async Task<IReadOnlyCollection<StreamData>> GetStreamDataAsync(List<string> logins)
|
||||
{
|
||||
if (logins.Count == 0)
|
||||
{
|
||||
return Array.Empty<StreamData>();
|
||||
}
|
||||
|
||||
var token = await EnsureTokenValidAsync();
|
||||
|
||||
if (token is null)
|
||||
{
|
||||
Log.Warning("Twitch client id and client secret key are not added to creds.yml or incorrect");
|
||||
return Array.Empty<StreamData>();
|
||||
}
|
||||
|
||||
using var http = _httpClientFactory.CreateClient();
|
||||
http.DefaultRequestHeaders.Clear();
|
||||
http.DefaultRequestHeaders.Add("Client-Id", _clientId);
|
||||
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
|
||||
|
||||
var loginsSet = logins.Select(x => x.ToLowerInvariant())
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
var dataDict = new Dictionary<string, StreamData>();
|
||||
|
||||
foreach (var chunk in logins.Chunk(100))
|
||||
{
|
||||
try
|
||||
{
|
||||
var str = await http.GetStringAsync($"https://api.twitch.tv/helix/users"
|
||||
+ $"?{chunk.Select(x => $"login={x}").Join('&')}"
|
||||
+ $"&first=100");
|
||||
|
||||
var resObj = JsonSerializer.Deserialize<HelixUsersResponse>(str);
|
||||
|
||||
if (resObj?.Data is null || resObj.Data.Count == 0)
|
||||
continue;
|
||||
|
||||
foreach (var user in resObj.Data)
|
||||
{
|
||||
var lowerLogin = user.Login.ToLowerInvariant();
|
||||
if (loginsSet.Remove(lowerLogin))
|
||||
{
|
||||
dataDict[lowerLogin] = UserToStreamData(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Something went wrong retreiving {StreamPlatform} streams", Platform);
|
||||
return new List<StreamData>();
|
||||
}
|
||||
}
|
||||
|
||||
// any item left over loginsSet is an invalid username
|
||||
foreach (var login in loginsSet)
|
||||
{
|
||||
_failingStreams.TryAdd(login, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// only get streams for users which exist
|
||||
foreach (var chunk in dataDict.Keys.Chunk(100))
|
||||
{
|
||||
try
|
||||
{
|
||||
var str = await http.GetStringAsync($"https://api.twitch.tv/helix/streams"
|
||||
+ $"?{chunk.Select(x => $"user_login={x}").Join('&')}"
|
||||
+ "&first=100");
|
||||
|
||||
var res = JsonSerializer.Deserialize<HelixStreamsResponse>(str);
|
||||
|
||||
if (res?.Data is null || res.Data.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var helixStreamData in res.Data)
|
||||
{
|
||||
var login = helixStreamData.UserLogin.ToLowerInvariant();
|
||||
if (dataDict.TryGetValue(login, out var old))
|
||||
{
|
||||
dataDict[login] = FillStreamData(old, helixStreamData);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Something went wrong retreiving {StreamPlatform} streams", Platform);
|
||||
return new List<StreamData>();
|
||||
}
|
||||
}
|
||||
|
||||
return dataDict.Values;
|
||||
}
|
||||
|
||||
private StreamData UserToStreamData(HelixUsersResponse.User user)
|
||||
=> new()
|
||||
{
|
||||
UniqueName = user.Login,
|
||||
Name = user.DisplayName,
|
||||
AvatarUrl = user.ProfileImageUrl,
|
||||
IsLive = false,
|
||||
StreamUrl = $"https://twitch.tv/{user.Login}",
|
||||
StreamType = FollowedStream.FType.Twitch,
|
||||
Preview = user.OfflineImageUrl
|
||||
};
|
||||
|
||||
private StreamData FillStreamData(StreamData partial, HelixStreamsResponse.StreamData apiData)
|
||||
=> partial with
|
||||
{
|
||||
StreamType = FollowedStream.FType.Twitch,
|
||||
Viewers = apiData.ViewerCount,
|
||||
Title = apiData.Title,
|
||||
IsLive = apiData.Type == "live",
|
||||
Preview = apiData.ThumbnailUrl
|
||||
.Replace("{width}", "640")
|
||||
.Replace("{height}", "480"),
|
||||
Game = apiData.GameName,
|
||||
};
|
||||
}
|
9
src/Ellie.Bot.Modules.Searches/_common/TimeData.cs
Normal file
9
src/Ellie.Bot.Modules.Searches/_common/TimeData.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Modules.Searches;
|
||||
|
||||
public class TimeData
|
||||
{
|
||||
public string Address { get; set; }
|
||||
public DateTime Time { get; set; }
|
||||
public string TimeZoneName { get; set; }
|
||||
}
|
22
src/Ellie.Bot.Modules.Searches/_common/TimeModels.cs
Normal file
22
src/Ellie.Bot.Modules.Searches/_common/TimeModels.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class TimeZoneResult
|
||||
{
|
||||
[JsonProperty("abbreviation")]
|
||||
public string TimezoneName { get; set; }
|
||||
|
||||
[JsonProperty("timestamp")]
|
||||
public int Timestamp { get; set; }
|
||||
}
|
||||
|
||||
public class LocationIqResponse
|
||||
{
|
||||
public float Lat { get; set; }
|
||||
public float Lon { get; set; }
|
||||
|
||||
[JsonProperty("display_name")]
|
||||
public string DisplayName { get; set; }
|
||||
}
|
14
src/Ellie.Bot.Modules.Searches/_common/UrbanDef.cs
Normal file
14
src/Ellie.Bot.Modules.Searches/_common/UrbanDef.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class UrbanResponse
|
||||
{
|
||||
public UrbanDef[] List { get; set; }
|
||||
}
|
||||
|
||||
public class UrbanDef
|
||||
{
|
||||
public string Word { get; set; }
|
||||
public string Definition { get; set; }
|
||||
public string Permalink { get; set; }
|
||||
}
|
67
src/Ellie.Bot.Modules.Searches/_common/WeatherModels.cs
Normal file
67
src/Ellie.Bot.Modules.Searches/_common/WeatherModels.cs
Normal file
|
@ -0,0 +1,67 @@
|
|||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class Coord
|
||||
{
|
||||
public double Lon { get; set; }
|
||||
public double Lat { get; set; }
|
||||
}
|
||||
|
||||
public class Weather
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Main { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Icon { get; set; }
|
||||
}
|
||||
|
||||
public class Main
|
||||
{
|
||||
public double Temp { get; set; }
|
||||
public float Pressure { get; set; }
|
||||
public float Humidity { get; set; }
|
||||
|
||||
[JsonProperty("temp_min")]
|
||||
public double TempMin { get; set; }
|
||||
|
||||
[JsonProperty("temp_max")]
|
||||
public double TempMax { get; set; }
|
||||
}
|
||||
|
||||
public class Wind
|
||||
{
|
||||
public double Speed { get; set; }
|
||||
public double Deg { get; set; }
|
||||
}
|
||||
|
||||
public class Clouds
|
||||
{
|
||||
public int All { get; set; }
|
||||
}
|
||||
|
||||
public class Sys
|
||||
{
|
||||
public int Type { get; set; }
|
||||
public int Id { get; set; }
|
||||
public double Message { get; set; }
|
||||
public string Country { get; set; }
|
||||
public double Sunrise { get; set; }
|
||||
public double Sunset { get; set; }
|
||||
}
|
||||
|
||||
public class WeatherData
|
||||
{
|
||||
public Coord Coord { get; set; }
|
||||
public List<Weather> Weather { get; set; }
|
||||
public Main Main { get; set; }
|
||||
public int Visibility { get; set; }
|
||||
public Wind Wind { get; set; }
|
||||
public Clouds Clouds { get; set; }
|
||||
public int Dt { get; set; }
|
||||
public Sys Sys { get; set; }
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int Cod { get; set; }
|
||||
}
|
18
src/Ellie.Bot.Modules.Searches/_common/WikipediaApiModel.cs
Normal file
18
src/Ellie.Bot.Modules.Searches/_common/WikipediaApiModel.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class WikipediaApiModel
|
||||
{
|
||||
public WikipediaQuery Query { get; set; }
|
||||
|
||||
public class WikipediaQuery
|
||||
{
|
||||
public WikipediaPage[] Pages { get; set; }
|
||||
|
||||
public class WikipediaPage
|
||||
{
|
||||
public bool Missing { get; set; } = false;
|
||||
public string FullUrl { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
11
src/Ellie.Bot.Modules.Searches/_common/WoWJoke.cs
Normal file
11
src/Ellie.Bot.Modules.Searches/_common/WoWJoke.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
#nullable disable
|
||||
namespace Ellie.Modules.Searches.Common;
|
||||
|
||||
public class WoWJoke
|
||||
{
|
||||
public string Question { get; set; }
|
||||
public string Answer { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
=> $"`{Question}`\n\n**{Answer}**";
|
||||
}
|
138
src/Ellie/Ellie.csproj
Normal file
138
src/Ellie/Ellie.csproj
Normal file
|
@ -0,0 +1,138 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>true</ImplicitUsings>
|
||||
|
||||
<!-- Output/build -->
|
||||
<RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory>
|
||||
<OutputType>exe</OutputType>
|
||||
<ApplicationIcon>ellie_icon.ico</ApplicationIcon>
|
||||
|
||||
<!-- Analysis/Warnings -->
|
||||
<!-- <AnalysisMode>Recommended</AnalysisMode>-->
|
||||
<!-- <AnalysisModeGlobalization>None</AnalysisModeGlobalization>-->
|
||||
<NoWarn>CS1066;CS8981</NoWarn>
|
||||
|
||||
<!-- Profile-guided optimization -->
|
||||
<TieredPGO>true</TieredPGO>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<Publish>True</Publish>
|
||||
</PackageReference>
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.101.58" />
|
||||
<PackageReference Include="CodeHollow.FeedReader" Version="1.2.6" />
|
||||
<PackageReference Include="CommandLineParser" Version="2.9.1" />
|
||||
<PackageReference Include="CoreCLR-NCalc" Version="2.2.110" />
|
||||
<PackageReference Include="Google.Apis.Urlshortener.v1" Version="1.41.1.138" />
|
||||
<PackageReference Include="Google.Apis.YouTube.v3" Version="1.60.0.2945" />
|
||||
<PackageReference Include="Google.Apis.Customsearch.v1" Version="1.49.0.2084" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.22.1" />
|
||||
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.52.0" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.53.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Html2Markdown" Version="5.1.0.703" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.5.0" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
|
||||
|
||||
<PackageReference Include="MorseCode.ITask" Version="2.0.3" />
|
||||
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="3.0.0" />
|
||||
|
||||
<!-- DI -->
|
||||
<PackageReference Include="Ninject" Version="3.3.6" />
|
||||
<PackageReference Include="Ninject.Extensions.Conventions" Version="3.3.0" />
|
||||
<!-- <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />-->
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
|
||||
<!-- <PackageReference Include="Scrutor" Version="4.2.0" />-->
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="NonBlocking" Version="2.1.1" />
|
||||
<PackageReference Include="OneOf" Version="3.0.243" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="5.2.2" />
|
||||
<PackageReference Include="SixLabors.Fonts" Version="1.0.0-beta17" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta14" />
|
||||
<PackageReference Include="SixLabors.Shapes" Version="1.0.0-beta0009" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.6.104" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.0.2" />
|
||||
|
||||
<PackageReference Include="Humanizer" Version="2.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<Publish>True</Publish>
|
||||
</PackageReference>
|
||||
|
||||
<PackageReference Include="JetBrains.Annotations" Version="2022.3.1" />
|
||||
|
||||
|
||||
<!-- Db-related packages -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
<PackageReference Include="linq2db.EntityFrameworkCore" Version="7.3.0" />
|
||||
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.3" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0" />
|
||||
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="7.0.2" />
|
||||
|
||||
<!-- Used by stream notifications -->
|
||||
<PackageReference Include="TwitchLib.Api" Version="3.4.1" />
|
||||
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ayu\Ayu.Discord.Voice\Ayu.Discord.Voice.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Econ\Ellie.Econ.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Marmalade\Ellie.Marmalade.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Bot.Common\Ellie.Bot.Common.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Bot.Modules.Administration\Ellie.Bot.Modules.Administration.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Bot.Modules.Help\Ellie.Bot.Modules.Help.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Bot.Modules.Music\Ellie.Bot.Modules.Music.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Bot.Modules.Patronage\Ellie.Bot.Modules.Patronage.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Bot.Modules.Permisssions\Ellie.Bot.Modules.Permisssions.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Bot.Modules.Utility\Ellie.Bot.Modules.Utility.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Bot.Modules.Xp\Ellie.Bot.Modules.Xp.csproj" />
|
||||
<!-- <ProjectReference Include="..\Ellise.Common\Ellise.Common.csproj" />-->
|
||||
<ProjectReference Include="..\Ellie.Bot.Modules.Expresssions\Ellie.Bot.Modules.Expresssions.csproj" />
|
||||
<ProjectReference Include="..\Ellie.Bot.Modules.Gambling\Ellie.Bot.Modules.Gambling.csproj" />
|
||||
|
||||
<ProjectReference Include="..\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj" OutputItemType="Analyzer" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Protobuf Include="..\Ellie.Coordinator\Protos\coordinator.proto" GrpcServices="Client">
|
||||
<Link>Protos\coordinator.proto</Link>
|
||||
</Protobuf>
|
||||
<None Update="data\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="ellie_icon.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="creds.yml;creds_example.yml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Migrations\Mysql\" />
|
||||
<Folder Include="Migrations\Postgresql\" />
|
||||
<Folder Include="Migrations\Sqlite\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
48
src/Ellie/Migrations/MigrationQueries.cs
Normal file
48
src/Ellie/Migrations/MigrationQueries.cs
Normal file
|
@ -0,0 +1,48 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace Ellie.Migrations;
|
||||
|
||||
public static class MigrationQueries
|
||||
{
|
||||
public static void MigrateRero(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (migrationBuilder.IsMySql())
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT IGNORE into reactionroles(guildid, channelid, messageid, emote, roleid, `group`, levelreq, dateadded)
|
||||
select guildid, channelid, messageid, emotename, roleid, exclusive, 0, reactionrolemessage.dateadded
|
||||
from reactionrole
|
||||
left join reactionrolemessage on reactionrolemessage.id = reactionrole.reactionrolemessageid
|
||||
left join guildconfigs on reactionrolemessage.guildconfigid = guildconfigs.id;
|
||||
""");
|
||||
}
|
||||
else if (migrationBuilder.IsSqlite())
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
insert or ignore into reactionroles(guildid, channelid, messageid, emote, roleid, 'group', levelreq, dateadded)
|
||||
select guildid, channelid, messageid, emotename, roleid, exclusive, 0, reactionrolemessage.dateadded
|
||||
from reactionrole
|
||||
left join reactionrolemessage on reactionrolemessage.id = reactionrole.reactionrolemessageid
|
||||
left join guildconfigs on reactionrolemessage.guildconfigid = guildconfigs.id;
|
||||
""");
|
||||
}
|
||||
else if (migrationBuilder.IsNpgsql())
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
insert into reactionroles(guildid, channelid, messageid, emote, roleid, "group", levelreq, dateadded)
|
||||
select guildid, channelid, messageid, emotename, roleid, exclusive::int, 0, reactionrolemessage.dateadded
|
||||
from reactionrole
|
||||
left join reactionrolemessage on reactionrolemessage.id = reactionrole.reactionrolemessageid
|
||||
left join guildconfigs on reactionrolemessage.guildconfigid = guildconfigs.id
|
||||
ON CONFLICT DO NOTHING;
|
||||
""");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException("This database provider doesn't have an implementation for MigrateRero");
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue