Killed history

This commit is contained in:
Kwoth
2021-09-06 21:29:22 +02:00
commit 7aca29ae8a
950 changed files with 366651 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
using AngleSharp;
using AngleSharp.Html.Dom;
using NadekoBot.Core.Services;
using NadekoBot.Modules.Searches.Common;
using Newtonsoft.Json;
using System;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Serilog;
namespace NadekoBot.Modules.Searches.Services
{
public class AnimeSearchService : INService
{
private readonly IDataCache _cache;
private readonly IHttpClientFactory _httpFactory;
public AnimeSearchService(IDataCache cache, IHttpClientFactory httpFactory)
{
_cache = cache;
_httpFactory = httpFactory;
}
public async Task<AnimeResult> GetAnimeData(string query)
{
if (string.IsNullOrWhiteSpace(query))
throw new ArgumentNullException(nameof(query));
try
{
var link = "https://aniapi.nadeko.bot/anime/" + Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
link = link.ToLowerInvariant();
var (ok, data) = await _cache.TryGetAnimeDataAsync(link).ConfigureAwait(false);
if (!ok)
{
using (var http = _httpFactory.CreateClient())
{
data = await http.GetStringAsync(link).ConfigureAwait(false);
}
await _cache.SetAnimeDataAsync(link, data).ConfigureAwait(false);
}
return JsonConvert.DeserializeObject<AnimeResult>(data);
}
catch
{
return null;
}
}
public async Task<NovelResult> GetNovelData(string query)
{
if (string.IsNullOrWhiteSpace(query))
throw new ArgumentNullException(nameof(query));
query = query.Replace(" ", "-", StringComparison.InvariantCulture);
try
{
var link = "http://www.novelupdates.com/series/" + Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
link = link.ToLowerInvariant();
var (ok, data) = await _cache.TryGetNovelDataAsync(link).ConfigureAwait(false);
if (!ok)
{
var config = Configuration.Default.WithDefaultLoader();
using (var document = await BrowsingContext.New(config).OpenAsync(link).ConfigureAwait(false))
{
var imageElem = document.QuerySelector("div.seriesimg > img");
if (imageElem == null)
return null;
var imageUrl = ((IHtmlImageElement)imageElem).Source;
var descElem = document.QuerySelector("div#editdescription > p");
var desc = descElem.InnerHtml;
var genres = document.QuerySelector("div#seriesgenre").Children
.Select(x => x as IHtmlAnchorElement)
.Where(x => x != null)
.Select(x => $"[{x.InnerHtml}]({x.Href})")
.ToArray();
var authors = document
.QuerySelector("div#showauthors")
.Children
.Select(x => x as IHtmlAnchorElement)
.Where(x => x != null)
.Select(x => $"[{x.InnerHtml}]({x.Href})")
.ToArray();
var score = ((IHtmlSpanElement)document
.QuerySelector("h5.seriesother > span.uvotes"))
.InnerHtml;
var status = document
.QuerySelector("div#editstatus")
.InnerHtml;
var title = document
.QuerySelector("div.w-blog-content > div.seriestitlenu")
.InnerHtml;
var obj = new NovelResult()
{
Description = desc,
Authors = authors,
Genres = genres,
ImageUrl = imageUrl,
Link = link,
Score = score,
Status = status,
Title = title,
};
await _cache.SetNovelDataAsync(link,
JsonConvert.SerializeObject(obj)).ConfigureAwait(false);
return obj;
}
}
return JsonConvert.DeserializeObject<NovelResult>(data);
}
catch (Exception ex)
{
Log.Error(ex, "Error getting novel data");
return null;
}
}
public async Task<MangaResult> GetMangaData(string query)
{
if (string.IsNullOrWhiteSpace(query))
throw new ArgumentNullException(nameof(query));
try
{
var link = "https://aniapi.nadeko.bot/manga/" + Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
link = link.ToLowerInvariant();
var (ok, data) = await _cache.TryGetAnimeDataAsync(link).ConfigureAwait(false);
if (!ok)
{
using (var http = _httpFactory.CreateClient())
{
data = await http.GetStringAsync(link).ConfigureAwait(false);
}
await _cache.SetAnimeDataAsync(link, data).ConfigureAwait(false);
}
return JsonConvert.DeserializeObject<MangaResult>(data);
}
catch
{
return null;
}
}
}
}

View File

@@ -0,0 +1,105 @@
using NadekoBot.Core.Modules.Searches.Common;
using NadekoBot.Core.Services;
using NadekoBot.Extensions;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
namespace NadekoBot.Core.Modules.Searches.Services
{
public class CryptoService : INService
{
private readonly IDataCache _cache;
private readonly IHttpClientFactory _httpFactory;
private readonly IBotCredentials _creds;
public CryptoService(IDataCache cache, IHttpClientFactory httpFactory, IBotCredentials creds)
{
_cache = cache;
_httpFactory = httpFactory;
_creds = creds;
}
public async Task<(CryptoResponseData Data, CryptoResponseData Nearest)> GetCryptoData(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return (null, null);
}
name = name.ToUpperInvariant();
var cryptos = await CryptoData().ConfigureAwait(false);
var crypto = cryptos
?.FirstOrDefault(x => x.Id.ToUpperInvariant() == name || x.Name.ToUpperInvariant() == name
|| x.Symbol.ToUpperInvariant() == name);
(CryptoResponseData Elem, int Distance)? nearest = null;
if (crypto == null)
{
nearest = cryptos.Select(x => (x, Distance: x.Name.ToUpperInvariant().LevenshteinDistance(name)))
.OrderBy(x => x.Distance)
.Where(x => x.Distance <= 2)
.FirstOrDefault();
crypto = nearest?.Elem;
}
if (nearest != null)
{
return (null, crypto);
}
return (crypto, null);
}
private readonly SemaphoreSlim getCryptoLock = new SemaphoreSlim(1, 1);
public async Task<List<CryptoResponseData>> CryptoData()
{
await getCryptoLock.WaitAsync();
try
{
var fullStrData = await _cache.GetOrAddCachedDataAsync("nadeko:crypto_data", async _ =>
{
try
{
using (var _http = _httpFactory.CreateClient())
{
var strData = await _http.GetStringAsync(new Uri($"https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?" +
$"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}" +
$"&start=1" +
$"&limit=500" +
$"&convert=USD"));
JsonConvert.DeserializeObject<CryptoResponse>(strData); // just to see if its' valid
return strData;
}
}
catch (Exception ex)
{
Log.Error(ex, "Error getting crypto data: {Message}", ex.Message);
return default;
}
}, "", TimeSpan.FromHours(1));
return JsonConvert.DeserializeObject<CryptoResponse>(fullStrData).Data;
}
catch (Exception ex)
{
Log.Error(ex, "Error retreiving crypto data: {Message}", ex.Message);
return default;
}
finally
{
getCryptoLock.Release();
}
}
}
}

View File

@@ -0,0 +1,246 @@
using CodeHollow.FeedReader.Feeds;
using Discord;
using Discord.WebSocket;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Core.Services;
using NadekoBot.Core.Services.Database.Models;
using NadekoBot.Extensions;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Searches.Services
{
public class FeedsService : INService
{
private readonly DbService _db;
private readonly ConcurrentDictionary<string, HashSet<FeedSub>> _subs;
private readonly DiscordSocketClient _client;
private readonly ConcurrentDictionary<string, DateTime> _lastPosts =
new ConcurrentDictionary<string, DateTime>();
public FeedsService(NadekoBot bot, DbService db, DiscordSocketClient client)
{
_db = db;
using (var uow = db.GetDbContext())
{
var guildConfigIds = bot.AllGuildConfigs.Select(x => x.Id).ToList();
_subs = uow._context.GuildConfigs
.AsQueryable()
.Where(x => guildConfigIds.Contains(x.Id))
.Include(x => x.FeedSubs)
.ThenInclude(x => x.GuildConfig)
.ToList()
.SelectMany(x => x.FeedSubs)
.GroupBy(x => x.Url.ToLower())
.ToDictionary(x => x.Key, x => x.ToHashSet())
.ToConcurrent();
}
_client = client;
var _ = Task.Run(TrackFeeds);
}
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.Key;
try
{
var feed = await CodeHollow.FeedReader.FeedReader.ReadAsync(rssUrl).ConfigureAwait(false);
var items = feed
.Items
.Select(item => (Item: item, LastUpdate: item.PublishingDate?.ToUniversalTime()
?? (item.SpecificItem as AtomFeedItem)?.UpdatedDate
?.ToUniversalTime()))
.Where(data => !(data.LastUpdate is 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 DateTime 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 = new EmbedBuilder()
.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 == null)
{
previewElement = afi.Element.Elements()
.FirstOrDefault(x => x.Name.LocalName == "thumbnail");
}
if (previewElement != null)
{
var urlAttribute = previewElement.Attribute("url");
if (urlAttribute != 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 != null)
.Select(x => _client.GetGuild(x.GuildConfig.GuildId)
?.GetTextChannel(x.ChannelId))
.Where(x => x != null)
.Select(x => x.EmbedAsync(embed));
allSendTasks.Add(Task.WhenAll(feedSendTasks));
}
}
catch
{
}
}
await Task.WhenAll(Task.WhenAll(allSendTasks), Task.Delay(10000)).ConfigureAwait(false);
}
}
public List<FeedSub> GetFeeds(ulong guildId)
{
using (var uow = _db.GetDbContext())
{
return uow.GuildConfigs.ForId(guildId,
set => set.Include(x => x.FeedSubs)
.ThenInclude(x => x.GuildConfig))
.FeedSubs
.OrderBy(x => x.Id)
.ToList();
}
}
public bool AddFeed(ulong guildId, ulong channelId, string rssFeed)
{
rssFeed.ThrowIfNull(nameof(rssFeed));
var fs = new FeedSub()
{
ChannelId = channelId,
Url = rssFeed.Trim(),
};
using (var uow = _db.GetDbContext())
{
var gc = uow.GuildConfigs.ForId(guildId,
set => set.Include(x => x.FeedSubs)
.ThenInclude(x => x.GuildConfig));
if (gc.FeedSubs.Any(x => x.Url.ToLower() == fs.Url.ToLower()))
{
return false;
}
else if (gc.FeedSubs.Count >= 10)
{
return false;
}
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 HashSet<FeedSub>() {feed}, (k, old) =>
{
old.Add(feed);
return old;
});
}
}
return true;
}
public bool RemoveFeed(ulong guildId, int index)
{
if (index < 0)
return false;
using (var uow = _db.GetDbContext())
{
var items = uow.GuildConfigs.ForId(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 HashSet<FeedSub>(), (key, old) =>
{
old.Remove(toRemove);
return old;
});
uow._context.Remove(toRemove);
uow.SaveChanges();
}
return true;
}
}
}

View File

@@ -0,0 +1,945 @@
using Discord;
using Discord.WebSocket;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common;
using NadekoBot.Core.Modules.Searches.Common;
using NadekoBot.Core.Services;
using NadekoBot.Core.Services.Database.Models;
using NadekoBot.Core.Services.Impl;
using NadekoBot.Extensions;
using NadekoBot.Modules.Searches.Common;
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;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using Microsoft.EntityFrameworkCore.Internal;
using Serilog;
using HorizontalAlignment = SixLabors.Fonts.HorizontalAlignment;
using Image = SixLabors.ImageSharp.Image;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace NadekoBot.Modules.Searches.Services
{
public class SearchesService : INService, IUnloadableService
{
private readonly IHttpClientFactory _httpFactory;
private readonly DiscordSocketClient _client;
private readonly IGoogleApiService _google;
private readonly DbService _db;
private readonly IImageCache _imgs;
private readonly IDataCache _cache;
private readonly FontProvider _fonts;
private readonly IBotCredentials _creds;
private readonly NadekoRandom _rng;
public ConcurrentDictionary<ulong, bool> TranslatedChannels { get; } = new ConcurrentDictionary<ulong, bool>();
// (userId, channelId)
public ConcurrentDictionary<(ulong UserId, ulong ChannelId), string> UserLanguages { get; } = new ConcurrentDictionary<(ulong, ulong), string>();
public List<WoWJoke> WowJokes { get; } = new List<WoWJoke>();
public List<MagicItem> MagicItems { get; } = new List<MagicItem>();
private readonly ConcurrentDictionary<ulong, SearchImageCacher> _imageCacher = new ConcurrentDictionary<ulong, SearchImageCacher>();
public ConcurrentDictionary<ulong, Timer> AutoHentaiTimers { get; } = new ConcurrentDictionary<ulong, Timer>();
public ConcurrentDictionary<ulong, Timer> AutoBoobTimers { get; } = new ConcurrentDictionary<ulong, Timer>();
public ConcurrentDictionary<ulong, Timer> AutoButtTimers { get; } = new ConcurrentDictionary<ulong, Timer>();
private readonly ConcurrentDictionary<ulong, HashSet<string>> _blacklistedTags = new ConcurrentDictionary<ulong, HashSet<string>>();
private readonly List<string> _yomamaJokes;
public SearchesService(DiscordSocketClient client, IGoogleApiService google,
DbService db, NadekoBot bot, IDataCache cache, IHttpClientFactory factory,
FontProvider fonts, IBotCredentials creds)
{
_httpFactory = factory;
_client = client;
_google = google;
_db = db;
_imgs = cache.LocalImages;
_cache = cache;
_fonts = fonts;
_creds = creds;
_rng = new NadekoRandom();
_blacklistedTags = new ConcurrentDictionary<ulong, HashSet<string>>(
bot.AllGuildConfigs.ToDictionary(
x => x.GuildId,
x => new HashSet<string>(x.NsfwBlacklistedTags.Select(y => y.Tag))));
//translate commands
_client.MessageReceived += (msg) =>
{
var _ = Task.Run(async () =>
{
try
{
if (!(msg is SocketUserMessage umsg))
return;
if (!TranslatedChannels.TryGetValue(umsg.Channel.Id, out var autoDelete))
return;
var key = (umsg.Author.Id, umsg.Channel.Id);
if (!UserLanguages.TryGetValue(key, out string langs))
return;
var text = await Translate(langs, umsg.Resolve(TagHandling.Ignore))
.ConfigureAwait(false);
if (autoDelete)
try { await umsg.DeleteAsync().ConfigureAwait(false); } catch { }
await umsg.Channel.SendConfirmAsync($"{umsg.Author.Mention} `:` "
+ text.Replace("<@ ", "<@", StringComparison.InvariantCulture)
.Replace("<@! ", "<@!", StringComparison.InvariantCulture)).ConfigureAwait(false);
}
catch { }
});
return Task.CompletedTask;
};
//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 List<string>();
Log.Warning("data/yomama.txt is missing. .yomama command won't work");
}
}
public async Task<Stream> GetRipPictureAsync(string text, Uri imgUrl)
{
byte[] data = await _cache.GetOrAddCachedDataAsync($"nadeko_rip_{text}_{imgUrl}",
GetRipPictureFactory,
(text, imgUrl),
TimeSpan.FromDays(1)).ConfigureAwait(false);
return data.ToStream();
}
private void DrawAvatar(Image bg, Image avatarImage)
=> bg.Mutate(x => x.Grayscale().DrawImage(avatarImage, new Point(83, 139), new GraphicsOptions()));
public async Task<byte[]> GetRipPictureFactory((string text, Uri avatarUrl) arg)
{
var (text, avatarUrl) = arg;
using (var bg = Image.Load<Rgba32>(_imgs.Rip.ToArray()))
{
var (succ, data) = (false, (byte[])null); //await _cache.TryGetImageDataAsync(avatarUrl);
if (!succ)
{
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));
data = avatarImg.ToStream().ToArray();
DrawAvatar(bg, avatarImg);
}
await _cache.SetImageDataAsync(avatarUrl, data);
}
}
else
{
using (var avatarImg = Image.Load<Rgba32>(data))
{
DrawAvatar(bg, avatarImg);
}
}
bg.Mutate(x => x.DrawText(
new TextGraphicsOptions()
{
TextOptions = new TextOptions
{
HorizontalAlignment = HorizontalAlignment.Center,
WrapTextWidth = 190,
}.WithFallbackFonts(_fonts.FallBackFonts)
},
text,
_fonts.RipFont,
SixLabors.ImageSharp.Color.Black,
new PointF(25, 225)));
//flowa
using (var flowers = Image.Load(_imgs.RipOverlay.ToArray()))
{
bg.Mutate(x => x.DrawImage(flowers, new Point(0, 0), new GraphicsOptions()));
}
return bg.ToStream().ToArray();
}
}
public Task<WeatherData> GetWeatherDataAsync(string query)
{
query = query.Trim().ToLowerInvariant();
return _cache.GetOrAddCachedDataAsync($"nadeko_weather_{query}",
GetWeatherDataFactory,
query,
expiry: TimeSpan.FromHours(3));
}
private async Task<WeatherData> GetWeatherDataFactory(string query)
{
using (var http = _httpFactory.CreateClient())
{
try
{
var data = await http.GetStringAsync($"http://api.openweathermap.org/data/2.5/weather?" +
$"q={query}&" +
$"appid=42cd627dd60debf25a5739e50a217d74&" +
$"units=metric").ConfigureAwait(false);
if (data == null)
return null;
return JsonConvert.DeserializeObject<WeatherData>(data);
}
catch (Exception ex)
{
Log.Warning(ex.Message);
return null;
}
}
}
public Task<((string Address, DateTime Time, string TimeZoneName), TimeErrors?)> GetTimeDataAsync(string arg)
{
return 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);
}
if (string.IsNullOrWhiteSpace(_creds.LocationIqApiKey)
|| string.IsNullOrWhiteSpace(_creds.TimezoneDbApiKey))
{
return (default, TimeErrors.ApiKeyMissing);
}
try
{
using (var _http = _httpFactory.CreateClient())
{
var res = await _cache.GetOrAddCachedDataAsync($"geo_{query}", _ =>
{
var url = "https://eu1.locationiq.com/v1/search.php?" +
(string.IsNullOrWhiteSpace(_creds.LocationIqApiKey) ? "key=" : $"key={_creds.LocationIqApiKey}&") +
$"q={Uri.EscapeDataString(query)}&" +
$"format=json";
var res = _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={_creds.TimezoneDbApiKey}&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, System.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 enum ImageTag
{
Food,
Dogs,
Cats,
Birds
}
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 async Task<string> Translate(string langs, string text = null)
{
if (string.IsNullOrWhiteSpace(text))
throw new ArgumentException("Text is empty or null", nameof(text));
var langarr = langs.ToLowerInvariant().Split('>');
if (langarr.Length != 2)
throw new ArgumentException("Langs does not have 2 parts separated by a >", nameof(langs));
var from = langarr[0];
var to = langarr[1];
text = text?.Trim();
return (await _google.Translate(text, from, to).ConfigureAwait(false)).SanitizeMentions(true);
}
public Task<ImageCacherObject> DapiSearch(string tag, DapiSearchType type, ulong? guild, bool isExplicit = false)
{
tag = tag ?? "";
if (string.IsNullOrWhiteSpace(tag)
&& (tag.Contains("loli") || tag.Contains("shota")))
{
return null;
}
var tags = tag
.Split('+')
.Select(x => x.ToLowerInvariant().Replace(' ', '_'))
.ToArray();
if (guild.HasValue)
{
var blacklistedTags = GetBlacklistedTags(guild.Value);
var cacher = _imageCacher.GetOrAdd(guild.Value, (key) => new SearchImageCacher(_httpFactory));
return cacher.GetImage(tags, isExplicit, type, blacklistedTags);
}
else
{
var cacher = _imageCacher.GetOrAdd(guild ?? 0, (key) => new SearchImageCacher(_httpFactory));
return cacher.GetImage(tags, isExplicit, type);
}
}
public HashSet<string> GetBlacklistedTags(ulong guildId)
{
if (_blacklistedTags.TryGetValue(guildId, out var tags))
return tags;
return new HashSet<string>();
}
public bool ToggleBlacklistedTag(ulong guildId, string tag)
{
var tagObj = new NsfwBlacklitedTag
{
Tag = tag
};
bool added;
using (var uow = _db.GetDbContext())
{
var gc = uow.GuildConfigs.ForId(guildId, set => set.Include(y => y.NsfwBlacklistedTags));
if (gc.NsfwBlacklistedTags.Add(tagObj))
added = true;
else
{
gc.NsfwBlacklistedTags.Remove(tagObj);
var toRemove = gc.NsfwBlacklistedTags.FirstOrDefault(x => x.Equals(tagObj));
if (toRemove != null)
uow._context.Remove(toRemove);
added = false;
}
var newTags = new HashSet<string>(gc.NsfwBlacklistedTags.Select(x => x.Tag));
_blacklistedTags.AddOrUpdate(guildId, newTags, delegate { return newTags; });
uow.SaveChanges();
}
return added;
}
public void ClearCache()
{
foreach (var c in _imageCacher)
{
c.Value?.Clear();
}
}
private readonly object yomamaLock = new object();
private int yomamaJokeIndex = 0;
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/")).ConfigureAwait(false);
// 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("http://api.icndb.com/jokes/random/")).ConfigureAwait(false);
return JObject.Parse(response)["value"]["joke"].ToString() + " 😆";
}
}
public Task Unload()
{
AutoBoobTimers.ForEach(x => x.Value.Change(Timeout.Infinite, Timeout.Infinite));
AutoBoobTimers.Clear();
AutoButtTimers.ForEach(x => x.Value.Change(Timeout.Infinite, Timeout.Infinite));
AutoButtTimers.Clear();
AutoHentaiTimers.ForEach(x => x.Value.Change(Timeout.Infinite, Timeout.Infinite));
AutoHentaiTimers.Clear();
_imageCacher.Clear();
return Task.CompletedTask;
}
public async Task<MtgData> GetMtgCardAsync(string search)
{
search = search.Trim().ToLowerInvariant();
var data = await _cache.GetOrAddCachedDataAsync($"nadeko_mtg_{search}",
GetMtgCardFactory,
search,
TimeSpan.FromDays(1)).ConfigureAwait(false);
if (data == 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.EscapeUriString(card.Name)}").ConfigureAwait(false);
}
catch { storeUrl = "<url can't be found>"; }
return new MtgData
{
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.EscapeUriString(search)}")
.ConfigureAwait(false);
var responseObject = JsonConvert.DeserializeObject<MtgResponse>(response);
if (responseObject == null)
return new MtgData[0];
var cards = responseObject.Cards.Take(5).ToArray();
if (cards.Length == 0)
return new MtgData[0];
var tasks = new List<Task<MtgData>>(cards.Length);
for (int i = 0; i < cards.Length; i++)
{
var card = cards[i];
tasks.Add(GetMtgDataAsync(card));
}
return await Task.WhenAll(tasks).ConfigureAwait(false);
}
}
public Task<HearthstoneCardData> GetHearthstoneCardDataAsync(string name)
{
name = name.ToLowerInvariant();
return _cache.GetOrAddCachedDataAsync($"nadeko_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.MashapeKey);
try
{
var response = await http.GetStringAsync($"https://omgvamp-hearthstone-v1.p.rapidapi.com/" +
$"cards/search/{Uri.EscapeUriString(name)}").ConfigureAwait(false);
var objs = JsonConvert.DeserializeObject<HearthstoneCardData[]>(response);
if (objs == null || objs.Length == 0)
return null;
var data = objs.FirstOrDefault(x => x.Collectible)
?? objs.FirstOrDefault(x => !string.IsNullOrEmpty(x.PlayerClass))
?? objs.FirstOrDefault();
if (data == null)
return null;
if (!string.IsNullOrWhiteSpace(data.Img))
{
data.Img = await _google.ShortenUrl(data.Img).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(data.Text))
{
var converter = new Html2Markdown.Converter();
data.Text = converter.Convert(data.Text);
}
return data;
}
catch (Exception ex)
{
Log.Error(ex.Message);
return null;
}
}
}
public Task<OmdbMovie> GetMovieDataAsync(string name)
{
name = name.Trim().ToLowerInvariant();
return _cache.GetOrAddCachedDataAsync($"nadeko_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(' ', '+'))).ConfigureAwait(false);
var movie = JsonConvert.DeserializeObject<OmdbMovie>(res);
if (movie?.Title == null)
return null;
movie.Poster = await _google.ShortenUrl(movie.Poster).ConfigureAwait(false);
return movie;
}
}
public async Task<int> GetSteamAppIdByName(string query)
{
var redis = _cache.Redis;
var db = redis.GetDatabase();
const string STEAM_GAME_IDS_KEY = "steam_names_to_appid";
var exists = await db.KeyExistsAsync(STEAM_GAME_IDS_KEY).ConfigureAwait(false);
// if we didn't get steam name to id map already, get it
//if (!exists)
//{
// using (var http = _httpFactory.CreateClient())
// {
// // https://api.steampowered.com/ISteamApps/GetAppList/v2/
// var gamesStr = await http.GetStringAsync("https://api.steampowered.com/ISteamApps/GetAppList/v2/").ConfigureAwait(false);
// var apps = JsonConvert.DeserializeAnonymousType(gamesStr, new { applist = new { apps = new List<SteamGameId>() } }).applist.apps;
// //await db.HashSetAsync("steam_game_ids", apps.Select(app => new HashEntry(app.Name.Trim().ToLowerInvariant(), app.AppId)).ToArray()).ConfigureAwait(false);
// await db.StringSetAsync("steam_game_ids", gamesStr, TimeSpan.FromHours(24));
// //await db.KeyExpireAsync("steam_game_ids", TimeSpan.FromHours(24), CommandFlags.FireAndForget).ConfigureAwait(false);
// }
//}
var gamesMap = await _cache.GetOrAddCachedDataAsync(STEAM_GAME_IDS_KEY, 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/").ConfigureAwait(false);
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);
//await db.HashSetAsync("steam_game_ids", apps.Select(app => new HashEntry(app.Name.Trim().ToLowerInvariant(), app.AppId)).ToArray()).ConfigureAwait(false);
//await db.StringSetAsync("steam_game_ids", gamesStr, TimeSpan.FromHours(24));
//await db.KeyExpireAsync("steam_game_ids", TimeSpan.FromHours(24), CommandFlags.FireAndForget).ConfigureAwait(false);
}
}, default(string), TimeSpan.FromHours(24));
if (gamesMap == 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];
//// try finding the game id
//var val = db.HashGet(STEAM_GAME_IDS_KEY, query);
//if (val == default)
// return -1; // not found
//var appid = (int)val;
//return appid;
// now that we have appid, get the game info with that appid
//var gameData = await _cache.GetOrAddCachedDataAsync($"steam_game:{appid}", SteamGameDataFactory, appid, TimeSpan.FromHours(12))
// .ConfigureAwait(false);
//return gameData;
}
//private async Task<SteamGameData> SteamGameDataFactory(int appid)
//{
// using (var http = _httpFactory.CreateClient())
// {
// // https://store.steampowered.com/api/appdetails?appids=
// var responseStr = await http.GetStringAsync($"https://store.steampowered.com/api/appdetails?appids={appid}").ConfigureAwait(false);
// var data = JsonConvert.DeserializeObject<Dictionary<int, SteamGameData.Container>>(responseStr);
// if (!data.ContainsKey(appid) || !data[appid].Success)
// return null; // for some reason we can't get the game with valid appid. SHould never happen
// return data[appid].Data;
// }
//}
public class GoogleSearchResultData
{
public IReadOnlyList<GoogleSearchResult> Results { get; }
public string FullQueryLink { get; }
public string TotalResults { get; }
public GoogleSearchResultData(IReadOnlyList<GoogleSearchResult> results, string fullQueryLink,
string totalResults)
{
Results = results;
FullQueryLink = fullQueryLink;
TotalResults = totalResults;
}
}
private static readonly HtmlParser _googleParser = new HtmlParser(new HtmlParserOptions()
{
IsScripting = false,
IsEmbedded = false,
IsSupportingProcessingInstructions = false,
IsKeepingSourceReferences = false,
IsNotSupportingFrames = true,
});
public async Task<GoogleSearchResultData> GoogleSearchAsync(string query)
{
query = WebUtility.UrlEncode(query)?.Replace(' ', '+');
var fullQueryLink = $"https://www.google.ca/search?q={ query }&safe=on&lr=lang_eng&hl=en&ie=utf-8&oe=utf-8";
using var msg = new HttpRequestMessage(HttpMethod.Get, fullQueryLink);
msg.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36");
msg.Headers.Add("Cookie", "CONSENT=YES+shp.gws-20210601-0-RC2.en+FX+423;");
using var http = _httpFactory.CreateClient();
http.DefaultRequestHeaders.Clear();
using var response = await http.SendAsync(msg);
var content = await response.Content.ReadAsStreamAsync();
using var document = await _googleParser.ParseDocumentAsync(content);
var elems = document.QuerySelectorAll("div.g > div > div");
var resultsElem = document.QuerySelectorAll("#resultStats").FirstOrDefault();
var totalResults = resultsElem?.TextContent;
//var time = resultsElem.Children.FirstOrDefault()?.TextContent
//^ this doesn't work for some reason, <nobr> is completely missing in parsed collection
if (!elems.Any())
return default;
var results = elems.Select(elem =>
{
var children = elem.Children.ToList();
if (children.Count < 2)
return null;
var href = (children[0].QuerySelector("a") as IHtmlAnchorElement)?.Href;
var name = children[0].QuerySelector("h3")?.TextContent;
if (href == null || name == null)
return null;
var txt = children[1].TextContent;
if (string.IsNullOrWhiteSpace(txt))
return null;
return new GoogleSearchResult(name, href, txt);
})
.Where(x => x != null)
.ToList();
return new GoogleSearchResultData(
results.AsReadOnly(),
fullQueryLink,
totalResults);
}
public async Task<GoogleSearchResultData> DuckDuckGoSearchAsync(string query)
{
query = WebUtility.UrlEncode(query)?.Replace(' ', '+');
var fullQueryLink = $"https://html.duckduckgo.com/html";
using var http = _httpFactory.CreateClient();
http.DefaultRequestHeaders.Clear();
http.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36");
using var formData = new MultipartFormDataContent();
formData.Add(new StringContent(query), "q");
using var response = await http.PostAsync(fullQueryLink, formData);
var content = await response.Content.ReadAsStringAsync();
using var document = await _googleParser.ParseDocumentAsync(content);
var searchResults = document.QuerySelector(".results");
var elems = searchResults.QuerySelectorAll(".result");
if (!elems.Any())
return default;
var results = elems.Select(elem =>
{
var anchor = elem.QuerySelector(".result__a") as IHtmlAnchorElement;
if (anchor is null)
return null;
var href = anchor.Href;
var name = anchor.TextContent;
if (string.IsNullOrWhiteSpace(href) || string.IsNullOrWhiteSpace(name))
return null;
var txt = elem.QuerySelector(".result__snippet")?.TextContent;
if (string.IsNullOrWhiteSpace(txt))
return null;
return new GoogleSearchResult(name, href, txt);
})
.Where(x => x != null)
.ToList();
return new GoogleSearchResultData(
results.AsReadOnly(),
fullQueryLink,
"0");
}
#region Nhentai
private string GetNhentaiExtensionInternal(string s)
=> s switch
{
"j" => "jpg",
"p" => "png",
"g" => "gif",
_ => "jpg"
};
private Gallery ModelToGallery(NhentaiApiModel.Gallery model)
{
var thumbnail = $"https://t.nhentai.net/galleries/{model.MediaId}/thumb."
+ GetNhentaiExtensionInternal(model.Images.Thumbnail.T);
var url = $"https://nhentai.net/g/{model.Id}";
return new Gallery(
model.Id.ToString(),
url,
model.Title.English,
model.Title.Pretty,
thumbnail,
model.NumPages,
model.NumFavorites,
model.UploadDate.ToUnixTimestamp().UtcDateTime,
model.Tags.Map(x => new Tag()
{
Name = x.Name,
Url = "https://nhentai.com/" + x.Url
}));
}
public async Task<NhentaiApiModel.Gallery> GetNhentaiByIdInternalAsync(uint id)
{
using var http = _httpFactory.CreateClient();
try
{
var res = await http.GetStringAsync("https://nhentai.net/api/gallery/" + id);
return JsonConvert.DeserializeObject<NhentaiApiModel.Gallery>(res);
}
catch (HttpRequestException)
{
Log.Warning("Nhentai with id {NhentaiId} not found", id);
return null;
}
}
private async Task<NhentaiApiModel.Gallery[]> SearchNhentaiInternalAsync(string search)
{
using var http = _httpFactory.CreateClient();
try
{
var res = await http.GetStringAsync("https://nhentai.net/api/galleries/search?query=" + search);
return JsonConvert.DeserializeObject<NhentaiApiModel.SearchResult>(res).Result;
}
catch (HttpRequestException)
{
Log.Warning("Nhentai with search {NhentaiSearch} not found", search);
return null;
}
}
public async Task<Gallery> GetNhentaiByIdAsync(uint id)
{
var model = await GetNhentaiByIdInternalAsync(id);
return ModelToGallery(model);
}
private static readonly string[] _bannedTags =
{
"loli",
"lolicon",
"shota",
"shotacon",
"cub"
};
public async Task<Gallery> GetNhentaiBySearchAsync(string search)
{
var models = await SearchNhentaiInternalAsync(search);
models = models.Where(x => !x.Tags.Any(t => _bannedTags.Contains(t.Name))).ToArray();
if (models.Length == 0)
return null;
return ModelToGallery(models[_rng.Next(0, models.Length)]);
}
#endregion
}
}

View File

@@ -0,0 +1,578 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common;
using NadekoBot.Core.Modules.Searches.Common;
using NadekoBot.Core.Modules.Searches.Common.StreamNotifications;
using NadekoBot.Core.Services;
using NadekoBot.Core.Services.Database.Models;
using NadekoBot.Extensions;
using Newtonsoft.Json;
using StackExchange.Redis;
using Discord;
using Discord.WebSocket;
using NadekoBot.Common.Collections;
using NadekoBot.Common.Replacements;
using Serilog;
namespace NadekoBot.Modules.Searches.Services
{
public class StreamNotificationService : INService
{
private readonly DbService _db;
private readonly IBotStrings _strings;
private readonly Random _rng = new NadekoRandom();
private readonly DiscordSocketClient _client;
private readonly NotifChecker _streamTracker;
private readonly object _shardLock = new object();
private readonly Dictionary<StreamDataKey, HashSet<ulong>> _trackCounter =
new Dictionary<StreamDataKey, HashSet<ulong>>();
private readonly Dictionary<StreamDataKey, Dictionary<ulong, HashSet<FollowedStream>>> _shardTrackedStreams;
private readonly ConcurrentHashSet<ulong> _offlineNotificationServers;
private readonly ConnectionMultiplexer _multi;
private readonly IBotCredentials _creds;
private readonly Timer _notifCleanupTimer;
public StreamNotificationService(DbService db, DiscordSocketClient client,
IBotStrings strings, IDataCache cache, IBotCredentials creds, IHttpClientFactory httpFactory,
NadekoBot bot)
{
_db = db;
_client = client;
_strings = strings;
_multi = cache.Redis;
_creds = creds;
_streamTracker = new NotifChecker(httpFactory, cache.Redis, creds.RedisKey(), client.ShardId == 0);
using (var uow = db.GetDbContext())
{
var ids = client.GetGuildIds();
var guildConfigs = uow._context.Set<GuildConfig>()
.AsQueryable()
.Include(x => x.FollowedStreams)
.Where(x => ids.Contains(x.GuildId))
.ToList();
_offlineNotificationServers = new ConcurrentHashSet<ulong>(guildConfigs
.Where(gc => gc.NotifyStreamOffline)
.Select(x => x.GuildId)
.ToList());
var followedStreams = guildConfigs
.SelectMany(x => x.FollowedStreams)
.ToList();
_shardTrackedStreams = followedStreams
.GroupBy(x => new {Type = 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._context.Set<FollowedStream>()
.AsQueryable()
.ToList();
foreach (var fs in allFollowedStreams)
{
_streamTracker.CacheAddData(fs.CreateKey(), null, replace: false);
}
_trackCounter = allFollowedStreams
.GroupBy(x => new {Type = x.Type, Name = x.Username.ToLower()})
.ToDictionary(
x => new StreamDataKey(x.Key.Type, x.Key.Name),
x => x.Select(fs => fs.GuildId).ToHashSet());
}
}
var sub = _multi.GetSubscriber();
sub.Subscribe($"{_creds.RedisKey()}_streams_offline", HandleStreamsOffline);
sub.Subscribe($"{_creds.RedisKey()}_streams_online", 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();
_notifCleanupTimer = new Timer(_ =>
{
try
{
var errorLimit = TimeSpan.FromHours(12);
var failingStreams = _streamTracker.GetFailingStreams(errorLimit, true)
.ToList();
if (!failingStreams.Any())
return;
var deleteGroups = failingStreams.GroupBy(x => x.Type)
.ToDictionary(x => x.Key, x => x.Select(x => x.Name).ToList());
using (var uow = _db.GetDbContext())
{
foreach (var kvp in deleteGroups)
{
Log.Information($"Deleting {kvp.Value.Count} {kvp.Key} streams because " +
$"they've been erroring for more than {errorLimit}: {string.Join(", ", kvp.Value)}");
var toDelete = uow._context.Set<FollowedStream>()
.AsQueryable()
.Where(x => x.Type == kvp.Key && kvp.Value.Contains(x.Username))
.ToList();
uow._context.RemoveRange(toDelete);
uow.SaveChanges();
foreach(var loginToDelete in kvp.Value)
_streamTracker.UntrackStreamByKey(new StreamDataKey(kvp.Key, loginToDelete));
}
}
}
catch (Exception ex)
{
Log.Error("Error cleaning up FollowedStreams");
Log.Error(ex.ToString());
}
}, null, TimeSpan.FromMinutes(30), TimeSpan.FromMinutes(30));
sub.Subscribe($"{_creds.RedisKey()}_follow_stream", HandleFollowStream);
sub.Subscribe($"{_creds.RedisKey()}_unfollow_stream", HandleUnfollowStream);
}
bot.JoinedGuild += ClientOnJoinedGuild;
client.LeftGuild += ClientOnLeftGuild;
}
/// <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 void HandleFollowStream(RedisChannel ch, RedisValue val)
=> Task.Run(() =>
{
var info = JsonConvert.DeserializeAnonymousType(
val.ToString(),
new {Key = default(StreamDataKey), GuildId = 0ul});
_streamTracker.CacheAddData(info.Key, null, replace: false);
lock (_shardLock)
{
var key = info.Key;
if (_trackCounter.ContainsKey(key))
{
_trackCounter[key].Add(info.GuildId);
}
else
{
_trackCounter[key] = new HashSet<ulong>()
{
info.GuildId
};
}
}
});
/// <summary>
/// Handles unfollow_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 void HandleUnfollowStream(RedisChannel ch, RedisValue val)
=> Task.Run(() =>
{
var info = JsonConvert.DeserializeAnonymousType(val.ToString(),
new {Key = default(StreamDataKey), GuildId = 0ul});
lock (_shardLock)
{
var key = info.Key;
if (!_trackCounter.TryGetValue(key, out var set))
{
// it should've been removed already?
_streamTracker.UntrackStreamByKey(in key);
return;
}
set.Remove(info.GuildId);
if (set.Count != 0)
return;
_trackCounter.Remove(key);
// if no other guilds are following this stream
// untrack the stream
_streamTracker.UntrackStreamByKey(in key);
}
});
private void HandleStreamsOffline(RedisChannel arg1, RedisValue val) => Task.Run(async () =>
{
var offlineStreams = JsonConvert.DeserializeObject<List<StreamData>>(val.ToString());
foreach (var stream in offlineStreams)
{
var key = stream.CreateKey();
if (_shardTrackedStreams.TryGetValue(key, out var fss))
{
var sendTasks = 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)));
await Task.WhenAll(sendTasks);
}
}
});
private void HandleStreamsOnline(RedisChannel arg1, RedisValue val) => Task.Run(async () =>
{
var onlineStreams = JsonConvert.DeserializeObject<List<StreamData>>(val.ToString());
foreach (var stream in onlineStreams)
{
var key = stream.CreateKey();
if (_shardTrackedStreams.TryGetValue(key, out var fss))
{
var sendTasks = fss
.SelectMany(x => x.Value)
.Select(fs =>
{
var textChannel = _client.GetGuild(fs.GuildId)?.GetTextChannel(fs.ChannelId);
if (textChannel is null)
return Task.CompletedTask;
var rep = new ReplacementBuilder()
.WithOverride("%user%", () => fs.Username)
.WithOverride("%platform%", () => fs.Type.ToString())
.Build();
var message = string.IsNullOrWhiteSpace(fs.Message)
? ""
: rep.Replace(fs.Message);
return textChannel.EmbedAsync(GetEmbed(fs.GuildId, stream), message);
});
await Task.WhenAll(sendTasks);
}
}
});
private Task OnStreamsOffline(List<StreamData> data)
{
var sub = _multi.GetSubscriber();
return sub.PublishAsync($"{_creds.RedisKey()}_streams_offline", JsonConvert.SerializeObject(data));
}
private Task OnStreamsOnline(List<StreamData> data)
{
var sub = _multi.GetSubscriber();
return sub.PublishAsync($"{_creds.RedisKey()}_streams_online", JsonConvert.SerializeObject(data));
}
private Task ClientOnJoinedGuild(GuildConfig guildConfig)
{
using (var uow = _db.GetDbContext())
{
var gc = uow._context.GuildConfigs
.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.GuildConfigs.ForId(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 int ClearAllStreams(ulong guildId)
{
// todo future clear streams
int count;
using (var uow = _db.GetDbContext())
{
var gc = uow.GuildConfigs.ForId(guildId, set => set.Include(x => x.FollowedStreams));
count = gc.FollowedStreams.Count;
gc.FollowedStreams.Clear();
uow.SaveChanges();
}
return count;
}
public async Task<FollowedStream> UnfollowStreamAsync(ulong guildId, int index)
{
FollowedStream fs;
using (var uow = _db.GetDbContext())
{
var fss = uow._context.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._context.Remove(fs);
await uow.SaveChangesAsync();
// remove from local cache
lock (_shardLock)
{
var key = fs.CreateKey();
var streams = GetLocalGuildStreams(key, guildId);
streams.Remove(fs);
}
}
PublishUnfollowStream(fs);
return fs;
}
private void PublishUnfollowStream(FollowedStream fs)
{
var sub = _multi.GetSubscriber();
sub.Publish($"{_creds.RedisKey()}_unfollow_stream",
JsonConvert.SerializeObject(new {Key = fs.CreateKey(), GuildId = fs.GuildId}));
}
private void PublishFollowStream(FollowedStream fs)
{
var sub = _multi.GetSubscriber();
sub.Publish($"{_creds.RedisKey()}_follow_stream",
JsonConvert.SerializeObject(new {Key = fs.CreateKey(), GuildId = fs.GuildId}),
CommandFlags.FireAndForget);
}
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;
using (var uow = _db.GetDbContext())
{
var gc = uow.GuildConfigs.ForId(guildId, set => set.Include(x => x.FollowedStreams));
// add it to the database
fs = new FollowedStream()
{
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 EmbedBuilder GetEmbed(ulong guildId, StreamData status)
{
var embed = new EmbedBuilder()
.WithTitle(status.Name)
.WithUrl(status.StreamUrl)
.WithDescription(status.StreamUrl)
.AddField(efb => efb.WithName(GetText(guildId, "status"))
.WithValue(status.IsLive ? "🟢 Online" : "🔴 Offline")
.WithIsInline(true))
.AddField(efb => efb.WithName(GetText(guildId, "viewers"))
.WithValue(status.IsLive ? status.Viewers.ToString() : "-")
.WithIsInline(true))
.WithColor(status.IsLive ? NadekoBot.OkColor : NadekoBot.ErrorColor);
if (!string.IsNullOrWhiteSpace(status.Title))
embed.WithAuthor(status.Title);
if (!string.IsNullOrWhiteSpace(status.Game))
embed.AddField(GetText(guildId, "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, string key, params object[] replacements)
=> _strings.GetText(key, guildId, replacements);
public bool ToggleStreamOffline(ulong guildId)
{
bool newValue;
using (var uow = _db.GetDbContext())
{
var gc = uow.GuildConfigs.ForId(guildId, set => set);
newValue = gc.NotifyStreamOffline = !gc.NotifyStreamOffline;
uow.SaveChanges();
if (newValue)
{
_offlineNotificationServers.Add(guildId);
}
else
{
_offlineNotificationServers.TryRemove(guildId);
}
}
return newValue;
}
public Task<StreamData> GetStreamDataAsync(string url)
{
return _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;
}
else
{
return map[guildId] = new HashSet<FollowedStream>();
}
}
else
{
_shardTrackedStreams[key] = new Dictionary<ulong, HashSet<FollowedStream>>()
{
{guildId, new HashSet<FollowedStream>()}
};
return _shardTrackedStreams[key][guildId];
}
}
public bool SetStreamMessage(ulong guildId, int index, string message, out FollowedStream fs)
{
using (var uow = _db.GetDbContext())
{
var fss = uow._context.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._context.Set<FollowedStream>()
.ToList();
if (all.Count == 0)
return 0;
all.ForEach(x => x.Message = message);
uow.SaveChanges();
return all.Count;
}
}
}

View File

@@ -0,0 +1,137 @@
using NadekoBot.Core.Services;
namespace NadekoBot.Core.Modules.Searches.Services
{
// public class YtTrackService : INService
// {
// private readonly IGoogleApiService _google;
// private readonly IHttpClientFactory httpClientFactory;
// private readonly DiscordSocketClient _client;
// private readonly DbService _db;
// private readonly ConcurrentDictionary<string, ConcurrentDictionary<ulong, List<YtFollowedChannel>>> followedChannels;
// private readonly ConcurrentDictionary<string, DateTime> _latestPublishes = new ConcurrentDictionary<string, DateTime>();
//
// public YtTrackService(IGoogleApiService google, IHttpClientFactory httpClientFactory, DiscordSocketClient client,
// DbService db)
// {
// this._google = google;
// this.httpClientFactory = httpClientFactory;
// this._client = client;
// this._db = db;
//
// if (_client.ShardId == 0)
// {
// _ = CheckLoop();
// }
// }
//
// public async Task CheckLoop()
// {
// while (true)
// {
// await Task.Delay(10000);
// using (var http = httpClientFactory.CreateClient())
// {
// await Task.WhenAll(followedChannels.Select(kvp => CheckChannel(kvp.Key, kvp.Value.SelectMany(x => x.Value).ToList())));
// }
// }
// }
//
// /// <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.GuildConfigs.ForId(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 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;
// }
// }
}