From bd5824d9bbe3f7f5f652d7d134503791b2289200 Mon Sep 17 00:00:00 2001 From: Ahmad Tohir Date: Mon, 14 Aug 2023 09:06:32 +0700 Subject: [PATCH] resolve #39, add youtube grid preview --- BotNet.Services/BotCommands/Preview.cs | 77 +++++++++++ .../Preview/ServiceCollectionExtensions.cs | 10 ++ BotNet.Services/Preview/YoutubePreview.cs | 127 ++++++++++++++++++ BotNet.Tests/Services/Preview/RegexTests.cs | 45 +++++++ BotNet/Bot/UpdateHandler.cs | 3 + BotNet/Program.cs | 2 + 6 files changed, 264 insertions(+) create mode 100644 BotNet.Services/BotCommands/Preview.cs create mode 100644 BotNet.Services/Preview/ServiceCollectionExtensions.cs create mode 100644 BotNet.Services/Preview/YoutubePreview.cs create mode 100644 BotNet.Tests/Services/Preview/RegexTests.cs diff --git a/BotNet.Services/BotCommands/Preview.cs b/BotNet.Services/BotCommands/Preview.cs new file mode 100644 index 0000000..e3707ec --- /dev/null +++ b/BotNet.Services/BotCommands/Preview.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BotNet.Services.Preview; +using Microsoft.Extensions.DependencyInjection; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace BotNet.Services.BotCommands { + public static class Preview { + public static async Task GetPreviewAsync(ITelegramBotClient botClient, IServiceProvider serviceProvider, Message message, CancellationToken cancellationToken) { + if (message.Entities?.FirstOrDefault() is { Type: MessageEntityType.BotCommand, Offset: 0, Length: int commandLength } + && message.Text![commandLength..].Trim() is string commandArgument) { + + if (commandArgument.Length <= 0 && message.ReplyToMessage?.Text is null) { + await botClient.SendTextMessageAsync( + chatId: message.Chat.Id, + text: "Gunakan /preview youtoube link atau reply message dengan command /preview", + parseMode: ParseMode.Html, + replyToMessageId: message.MessageId, + cancellationToken: cancellationToken); + + return; + } + + Uri? youtubeLink; + Uri? previewYoutubeStoryboard; + + if (message.ReplyToMessage?.Text is string repliedToMessage) { + youtubeLink = YoutubePreview.ValidateYoutubeLink(repliedToMessage); + if (youtubeLink is null) { + await botClient.SendTextMessageAsync( + chatId: message.Chat.Id, + text: "Youtube link tidak valid", + parseMode: ParseMode.Html, + replyToMessageId: message.MessageId, + cancellationToken: cancellationToken); + + return; + } + + previewYoutubeStoryboard = await serviceProvider.GetRequiredService().YoutubeStoryBoardAsync(youtubeLink, cancellationToken); + + await botClient.SendPhotoAsync( + chatId: message.Chat.Id, + photo: previewYoutubeStoryboard.ToString(), + replyToMessageId: message.MessageId, + parseMode: ParseMode.Html, + cancellationToken: cancellationToken); + } else if (commandArgument.Length >= 0) { + youtubeLink = YoutubePreview.ValidateYoutubeLink(commandArgument); + if (youtubeLink is null) { + await botClient.SendTextMessageAsync( + chatId: message.Chat.Id, + text: "Youtube link tidak valid", + parseMode: ParseMode.Html, + replyToMessageId: message.MessageId, + cancellationToken: cancellationToken); + + return; + } + + previewYoutubeStoryboard = await serviceProvider.GetRequiredService().YoutubeStoryBoardAsync(youtubeLink, cancellationToken); + + await botClient.SendPhotoAsync( + chatId: message.Chat.Id, + photo: previewYoutubeStoryboard.ToString(), + replyToMessageId: message.MessageId, + parseMode: ParseMode.Html, + cancellationToken: cancellationToken); + } + } + } + } +} diff --git a/BotNet.Services/Preview/ServiceCollectionExtensions.cs b/BotNet.Services/Preview/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..76a293d --- /dev/null +++ b/BotNet.Services/Preview/ServiceCollectionExtensions.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace BotNet.Services.Preview { + public static class ServiceCollectionExtensions { + public static IServiceCollection AddPreviewServices(this IServiceCollection services) { + services.AddTransient(); + return services; + } + } +} diff --git a/BotNet.Services/Preview/YoutubePreview.cs b/BotNet.Services/Preview/YoutubePreview.cs new file mode 100644 index 0000000..6fe9cf8 --- /dev/null +++ b/BotNet.Services/Preview/YoutubePreview.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Web; + +namespace BotNet.Services.Preview { + public class YoutubePreview : IDisposable { + private readonly HttpClientHandler _httpClientHandler; + private readonly HttpClient _httpClient; + private readonly string _userAgent; + private bool _disposedValue; + + public YoutubePreview() { + _httpClientHandler = new() { + AllowAutoRedirect = false + }; + _userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36"; + _httpClient = new(_httpClientHandler); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent); + } + + public static Uri? ValidateYoutubeLink(string message) { + return Regex.Matches(message, @"(https?://)?(www.)?youtube.com/watch\?v=[a-zA-Z0-9_-]+") + .Select(match => new Uri(match.Value)) + .FirstOrDefault(); + } + + + /// + /// Grid Preview, or in terms of youtube is called storyboard. + /// Generally youtube will fetch image when the progress bar is hovered. But fortunately, + /// youtube response gave us clue. + /// We can get the image from JSON inside their javascript. We only need this link + /// eg: https://i.ytimg.com/sb//storyboard3_L$L/$N.jpg + /// + /// We only need to change the id, $L and $N. + /// $L and $N is the grid length, 2 is recommended, so it will be + /// eg: https://i.ytimg.com/sb//storyboard3_L2/M2.jpg + /// + /// + /// + /// + /// + public async Task YoutubeStoryBoardAsync(Uri youtubeLink, CancellationToken cancellationToken) { + using HttpResponseMessage response = await _httpClient.GetAsync(youtubeLink.ToString(), cancellationToken); + response.EnsureSuccessStatusCode(); + string responseBody = await response.Content.ReadAsStringAsync(); + + string jsPattern = @"[^<]*ytInitialPlayerResponse\s*=\s*({.*?});[^<]*<\/script>"; + Match match = Regex.Match(responseBody, jsPattern, RegexOptions.Singleline); + + if (!match.Success) { + throw new InvalidOperationException("Failed to get preview image"); + } + + string jsonData = match.Groups[1].Value.Trim(); + if (string.IsNullOrWhiteSpace(jsonData)) { + throw new InvalidOperationException("Failed to get JSON data"); + } + + JsonNode? jsonObject = JsonNode.Parse(jsonData); + if (string.IsNullOrWhiteSpace(jsonData)) { + throw new InvalidOperationException("Failed parse JSON"); + } + + JsonNode? storyBoards = jsonObject?["storyboards"]?["playerStoryboardSpecRenderer"]?["spec"]; + if (storyBoards == null) { + throw new InvalidOperationException("Failed to get storyboards link"); + } + + string storyBoardsLink = storyBoards.ToString(); + + Uri uri = new(storyBoardsLink); + + // The "spec" key from storyboard is having a query string with "|" (pipe) delimiter. + // Somehow the Uri class cannot read "|" (pipe) delimiter and make the query string chopped. + string queryString = storyBoardsLink.TrimStart('?'); + queryString = queryString.Replace('|', '&'); + // Parse the query string manually + NameValueCollection queryParams = HttpUtility.ParseQueryString(queryString); + + // We need to take last dynamically generated "sigh" key from the querystring + string sighQueryKey = storyBoardsLink.Split(@"rs$").Last(); + + // We need to take last dynamically generated "sqp" key from the querystring + string sqpQueryKey = storyBoardsLink.Split('|').First(); + + Uri sqp = new(sqpQueryKey); + + // Currently only L2 and M2 combination. + // The other combination L1 - LN and M1 - MN is need different "sigh" query string + string path = uri.AbsolutePath.Replace("$L", "2").Replace("$N", "M2"); + + // Rebuilt the Uri + Uri storyboardYoutube = new(uri.Scheme + "://" + uri.Host + path + sqp.Query + "&sigh=rs%24" + sighQueryKey); + + return storyboardYoutube; + + } + + protected virtual void Dispose(bool disposing) { + if (!_disposedValue) { + if (disposing) { + // dispose managed state (managed objects) + _httpClient.Dispose(); + _httpClientHandler.Dispose(); + } + + _disposedValue = true; + } + } + + public void Dispose() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } + +} diff --git a/BotNet.Tests/Services/Preview/RegexTests.cs b/BotNet.Tests/Services/Preview/RegexTests.cs new file mode 100644 index 0000000..527cf81 --- /dev/null +++ b/BotNet.Tests/Services/Preview/RegexTests.cs @@ -0,0 +1,45 @@ +using System; +using BotNet.Services.Preview; +using FluentAssertions; +using Xunit; + +namespace BotNet.Tests.Services.Preview { + public class RegexTests { + + [Theory] + [InlineData("https://www.youtube.com/watch?v=wUGbUERmhJM&t=2711s", "https://www.youtube.com/watch?v=wUGbUERmhJM")] + [InlineData("https://www.youtube.com/watch?v=L8JJernNrS8", "https://www.youtube.com/watch?v=L8JJernNrS8")] + [InlineData("https://www.youtube.com/watch?v=JdqL89ZZwFw", "https://www.youtube.com/watch?v=JdqL89ZZwFw")] + public void YoutubeLink(string url, string validLink) { + Uri? uri = YoutubePreview.ValidateYoutubeLink(url); + uri.Should().Be(validLink); + } + + [Theory] + [InlineData("https://www.youtube.com/v=wUGbUERmhJM&t=2711s", null)] + [InlineData("https://www.youtube.com/?t=2711s", null)] + [InlineData("http://www.example.com", null)] + public void InvalidYoutubeLink(string url, string? validLink) { + Uri? uri = YoutubePreview.ValidateYoutubeLink(url); + uri.Should().BeNull(validLink); + } + + [Theory] + [InlineData("Tonton ini https://www.youtube.com/watch?v=wUGbUERmhJM&t=2711s", "https://www.youtube.com/watch?v=wUGbUERmhJM")] + [InlineData("https://www.youtube.com/watch?v=wUGbUERmhJM&t=2711s cocok", "https://www.youtube.com/watch?v=wUGbUERmhJM")] + [InlineData("http://www.example.com https://www.youtube.com/watch?v=wUGbUERmhJM&t=2711s", "https://www.youtube.com/watch?v=wUGbUERmhJM")] + public void LinkWithMessage(string url, string validLink) { + Uri? uri = YoutubePreview.ValidateYoutubeLink(url); + uri.Should().Be(validLink); + } + + [Theory] + [InlineData("Tonton ini https://www.youtube.com/v=wUGbUERmhJM&t=2711s", null)] + [InlineData("https://www.youtube.com/?t=2711s cocok", null)] + [InlineData("http://www.example.com salah link", null)] + public void InvalidLinkWithMessage(string url, string? validLink) { + Uri? uri = YoutubePreview.ValidateYoutubeLink(url); + uri.Should().BeNull(validLink); + } + } +} diff --git a/BotNet/Bot/UpdateHandler.cs b/BotNet/Bot/UpdateHandler.cs index 34cbe73..624f1f9 100644 --- a/BotNet/Bot/UpdateHandler.cs +++ b/BotNet/Bot/UpdateHandler.cs @@ -290,6 +290,9 @@ await botClient.SendTextMessageAsync( case "/bmkg": await BMKG.GetLatestEarthQuakeAsync(botClient, _serviceProvider, update.Message, cancellationToken); break; + case "/preview": + await Preview.GetPreviewAsync(botClient, _serviceProvider, update.Message, cancellationToken); + break; } } break; diff --git a/BotNet/Program.cs b/BotNet/Program.cs index 2947cdb..3286410 100644 --- a/BotNet/Program.cs +++ b/BotNet/Program.cs @@ -13,6 +13,7 @@ using BotNet.Services.OpenGraph; using BotNet.Services.Piston; using BotNet.Services.Pesto; +using BotNet.Services.Preview; using BotNet.Services.ProgrammerHumor; using BotNet.Services.Stability; using BotNet.Services.Tenor; @@ -71,6 +72,7 @@ services.AddGoogleMaps(); services.AddWeatherService(); services.AddBMKG(); + services.AddPreviewServices(); // Hosted Services services.Configure(configuration.GetSection("BotOptions"));