diff --git a/src/NadekoBot/Modules/Searches/Commands/StreamNotificationCommands.cs b/src/NadekoBot/Modules/Searches/Commands/StreamNotificationCommands.cs new file mode 100644 index 00000000..e038a9d8 --- /dev/null +++ b/src/NadekoBot/Modules/Searches/Commands/StreamNotificationCommands.cs @@ -0,0 +1,410 @@ +using Discord.Commands; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using NadekoBot.Services; +using System.Threading; +using System.Collections.Generic; +using NadekoBot.Services.Database.Models; +using System.Net.Http; +using Discord.WebSocket; +using NadekoBot.Attributes; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using NLog; +using NadekoBot.Services.Database; +using NadekoBot.Extensions; + +namespace NadekoBot.Modules.Searches +{ + public partial class Searches + { + public class StreamStatus + { + public bool IsLive { get; set; } + public string ApiLink { get; set; } + public string Views { get; set; } + } + + public class HitboxResponse { + public bool Success { get; set; } = true; + [JsonProperty("media_is_live")] + public string MediaIsLive { get; set; } + public bool IsLive => MediaIsLive == "1"; + [JsonProperty("media_views")] + public string Views { get; set; } + } + + public class TwitchResponse + { + public string Error { get; set; } = null; + public bool IsLive => Stream != null; + public StreamInfo Stream { get; set; } + + public class StreamInfo + { + public int Viewers { get; set; } + } + } + + public class BeamResponse + { + public string Error { get; set; } = null; + + [JsonProperty("online")] + public bool IsLive { get; set; } + public int ViewersCurrent { get; set; } + } + + public class StreamNotFoundException : Exception + { + public StreamNotFoundException(string message) : base("Stream '" + message + "' not found.") + { + } + } + + [Group] + public class StreamNotificationCommands + { + private Timer checkTimer { get; } + private ConcurrentDictionary oldCachedStatuses = new ConcurrentDictionary(); + private ConcurrentDictionary cachedStatuses = new ConcurrentDictionary(); + private Logger _log { get; } + + private bool FirstPass { get; set; } = true; + + public StreamNotificationCommands() + { + + _log = NLog.LogManager.GetCurrentClassLogger(); + checkTimer = new Timer(async (state) => + { + oldCachedStatuses = new ConcurrentDictionary(cachedStatuses); + cachedStatuses.Clear(); + IEnumerable streams; + using (var uow = DbHandler.UnitOfWork()) + { + streams = uow.GuildConfigs.GetAllFollowedStreams(); + } + + await Task.WhenAll(streams.Select(async fs => + { + try + { + var newStatus = await GetStreamStatus(fs).ConfigureAwait(false); + if (FirstPass) + { + return; + } + + StreamStatus oldStatus; + if (oldCachedStatuses.TryGetValue(newStatus.ApiLink, out oldStatus) && + oldStatus.IsLive != newStatus.IsLive) + { + var server = NadekoBot.Client.GetGuild(fs.GuildId); + var channel = server?.GetTextChannel(fs.ChannelId); + if (channel == null) + return; + try { await channel.EmbedAsync(fs.GetEmbed(newStatus).Build()).ConfigureAwait(false); } catch { } + } + } + catch (Exception ex) + { + + } + })); + + FirstPass = false; + }, null, TimeSpan.Zero, TimeSpan.FromSeconds(60)); + } + + private async Task GetStreamStatus(FollowedStream stream, bool checkCache = true) + { + string response; + StreamStatus result; + switch (stream.Type) + { + case FollowedStream.FollowedStreamType.Hitbox: + var hitboxUrl = $"https://api.hitbox.tv/media/status/{stream.Username.ToLowerInvariant()}"; + if (checkCache && cachedStatuses.TryGetValue(hitboxUrl, out result)) + return result; + using (var http = new HttpClient()) + { + response = await http.GetStringAsync(hitboxUrl).ConfigureAwait(false); + } + var hbData = JsonConvert.DeserializeObject(response); + if (!hbData.Success) + throw new StreamNotFoundException($"{stream.Username} [{stream.Type}]"); + result = new StreamStatus() + { + IsLive = hbData.IsLive, + ApiLink = hitboxUrl, + Views = hbData.Views + }; + cachedStatuses.AddOrUpdate(hitboxUrl, result, (key, old) => result); + return result; + case FollowedStream.FollowedStreamType.Twitch: + var twitchUrl = $"https://api.twitch.tv/kraken/streams/{Uri.EscapeUriString(stream.Username.ToLowerInvariant())}?client_id=67w6z9i09xv2uoojdm9l0wsyph4hxo6"; + if (checkCache && cachedStatuses.TryGetValue(twitchUrl, out result)) + return result; + using (var http = new HttpClient()) + { + response = await http.GetStringAsync(twitchUrl).ConfigureAwait(false); + } + var twData = JsonConvert.DeserializeObject(response); + if (twData.Error != null) + { + throw new StreamNotFoundException($"{stream.Username} [{stream.Type}]"); + } + result = new StreamStatus() + { + IsLive = twData.IsLive, + ApiLink = twitchUrl, + Views = twData.Stream?.Viewers.ToString() ?? "0" + }; + cachedStatuses.AddOrUpdate(twitchUrl, result, (key, old) => result); + return result; + case FollowedStream.FollowedStreamType.Beam: + var beamUrl = $"https://beam.pro/api/v1/channels/{stream.Username.ToLowerInvariant()}"; + if (checkCache && cachedStatuses.TryGetValue(beamUrl, out result)) + return result; + using (var http = new HttpClient()) + { + response = await http.GetStringAsync(beamUrl).ConfigureAwait(false); + } + + var bmData = JsonConvert.DeserializeObject(response); + if (bmData.Error != null) + throw new StreamNotFoundException($"{stream.Username} [{stream.Type}]"); + result = new StreamStatus() + { + IsLive = bmData.IsLive, + ApiLink = beamUrl, + Views = bmData.ViewersCurrent.ToString() + }; + cachedStatuses.AddOrUpdate(beamUrl, result, (key, old) => result); + return result; + default: + break; + } + return null; + } + + //[NadekoCommand, Usage, Description, Aliases] + //[RequireContext(ContextType.Guild)] + //public async Task Test(IUserMessage imsg) + //{ + // var channel = (ITextChannel)imsg.Channel; + + // await channel.EmbedAsync(new Discord.API.Embed() + // { + // Title = "Imqtpie", + // Url = "https://twitch.tv/masterkwoth", + // Fields = new[] { + // new Discord.API.EmbedField() + // { + // Name = "Status", + // Value = "Online", + // Inline = true, + // }, + // new Discord.API.EmbedField() + // { + // Name = "Viewers", + // Value = "123123", + // Inline = true + // }, + // new Discord.API.EmbedField() + // { + // Name = "Platform", + // Value = "Twitch", + // Inline = true + // }, + // }, + // Color = NadekoBot.OkColor + // }); + //} + + [NadekoCommand, Usage, Description, Aliases] + [RequireContext(ContextType.Guild)] + [RequirePermission(GuildPermission.ManageMessages)] + public async Task Hitbox(IUserMessage msg, [Remainder] string username) => + await TrackStream((ITextChannel)msg.Channel, username, FollowedStream.FollowedStreamType.Hitbox) + .ConfigureAwait(false); + + [NadekoCommand, Usage, Description, Aliases] + [RequireContext(ContextType.Guild)] + [RequirePermission(GuildPermission.ManageMessages)] + public async Task Twitch(IUserMessage msg, [Remainder] string username) => + await TrackStream((ITextChannel)msg.Channel, username, FollowedStream.FollowedStreamType.Twitch) + .ConfigureAwait(false); + + [NadekoCommand, Usage, Description, Aliases] + [RequireContext(ContextType.Guild)] + [RequirePermission(GuildPermission.ManageMessages)] + public async Task Beam(IUserMessage msg, [Remainder] string username) => + await TrackStream((ITextChannel)msg.Channel, username, FollowedStream.FollowedStreamType.Beam) + .ConfigureAwait(false); + + [NadekoCommand, Usage, Description, Aliases] + [RequireContext(ContextType.Guild)] + public async Task ListStreams(IUserMessage imsg) + { + var channel = (ITextChannel)imsg.Channel; + + IEnumerable streams; + using (var uow = DbHandler.UnitOfWork()) + { + streams = uow.GuildConfigs + .For(channel.Guild.Id, + set => set.Include(gc => gc.FollowedStreams)) + .FollowedStreams; + } + + if (!streams.Any()) + { + await channel.SendMessageAsync("You are not following any streams on this server.").ConfigureAwait(false); + return; + } + + var text = string.Join("\n", streams.Select(snc => + { + return $"`{snc.Username}`'s stream on **{channel.Guild.GetTextChannel(snc.ChannelId)?.Name}** channel. 【`{snc.Type.ToString()}`】"; + })); + + await channel.SendMessageAsync($"You are following **{streams.Count()}** streams on this server.\n\n" + text).ConfigureAwait(false); + } + + [NadekoCommand, Usage, Description, Aliases] + [RequireContext(ContextType.Guild)] + [RequirePermission(GuildPermission.ManageMessages)] + public async Task RemoveStream(IUserMessage msg, FollowedStream.FollowedStreamType type, [Remainder] string username) + { + var channel = (ITextChannel)msg.Channel; + + username = username.ToLowerInvariant().Trim(); + + var fs = new FollowedStream() + { + ChannelId = channel.Id, + Username = username, + Type = type + }; + + bool removed; + using (var uow = DbHandler.UnitOfWork()) + { + var config = uow.GuildConfigs.For(channel.Guild.Id, set => set.Include(gc => gc.FollowedStreams)); + removed = config.FollowedStreams.Remove(fs); + if (removed) + await uow.CompleteAsync().ConfigureAwait(false); + } + if (!removed) + { + await channel.SendMessageAsync(":anger: No such stream.").ConfigureAwait(false); + return; + } + await channel.SendMessageAsync($":ok: Removed `{username}`'s stream ({type}) from notifications.").ConfigureAwait(false); + } + + [NadekoCommand, Usage, Description, Aliases] + [RequireContext(ContextType.Guild)] + public async Task CheckStream(IUserMessage imsg, FollowedStream.FollowedStreamType platform, [Remainder] string username) + { + var channel = (ITextChannel)imsg.Channel; + + var stream = username?.Trim(); + if (string.IsNullOrWhiteSpace(stream)) + return; + try + { + var streamStatus = (await GetStreamStatus(new FollowedStream + { + Username = stream, + Type = platform, + })); + if (streamStatus.IsLive) + { + await channel.SendMessageAsync($"`Streamer {username} is online with {streamStatus.Views} viewers.`"); + } + else + { + await channel.SendMessageAsync($"`Streamer {username} is offline.`"); + } + } + catch + { + await channel.SendMessageAsync("No channel found."); + } + } + + private async Task TrackStream(ITextChannel channel, string username, FollowedStream.FollowedStreamType type) + { + username = username.Trim(); + var fs = new FollowedStream + { + GuildId = channel.Guild.Id, + ChannelId = channel.Id, + Username = username, + Type = type, + }; + + StreamStatus status; + try + { + status = await GetStreamStatus(fs).ConfigureAwait(false); + } + catch + { + await channel.SendMessageAsync(":anger: Stream probably doesn't exist.").ConfigureAwait(false); + return; + } + + using (var uow = DbHandler.UnitOfWork()) + { + uow.GuildConfigs.For(channel.Guild.Id, set => set.Include(gc => gc.FollowedStreams)) + .FollowedStreams + .Add(fs); + await uow.CompleteAsync().ConfigureAwait(false); + } + + var msg = $":ok: I will notify this channel when status changes."; + await channel.EmbedAsync(fs.GetEmbed(status).Build(), msg).ConfigureAwait(false); + } + } + } + + public static class FollowedStreamExtensions + { + public static EmbedBuilder GetEmbed(this FollowedStream fs, Searches.StreamStatus status) + { + var embed = new EmbedBuilder().WithTitle(fs.Username) + .WithUrl(fs.GetLink()) + .AddField(efb => efb.WithName("Status") + .WithValue(status.IsLive ? "Online" : "Offline") + .WithIsInline(true)) + .AddField(efb => efb.WithName("Viewers") + .WithValue(status.IsLive ? status.Views : "-") + .WithIsInline(true)) + .AddField(efb => efb.WithName("Platform") + .WithValue(fs.Type.ToString()) + .WithIsInline(true)) + .WithColor(status.IsLive ? NadekoBot.OkColor : NadekoBot.ErrorColor); + + return embed; + } + + public static string GetLink(this FollowedStream fs) { + if (fs.Type == FollowedStream.FollowedStreamType.Hitbox) + return $"http://www.hitbox.tv/{fs.Username}/"; + else if (fs.Type == FollowedStream.FollowedStreamType.Twitch) + return $"http://www.twitch.tv/{fs.Username}/"; + else if (fs.Type == FollowedStream.FollowedStreamType.Beam) + return $"https://beam.pro/{fs.Username}/"; + else + return "??"; + } + } +} \ No newline at end of file