Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

resolve #39, add youtube grid preview #63

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions BotNet.Services/BotCommands/Preview.cs
Original file line number Diff line number Diff line change
@@ -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 <code>/preview youtoube link </code> atau reply message dengan command <code>/preview</code>",
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<YoutubePreview>().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<YoutubePreview>().YoutubeStoryBoardAsync(youtubeLink, cancellationToken);

await botClient.SendPhotoAsync(
chatId: message.Chat.Id,
photo: previewYoutubeStoryboard.ToString(),
replyToMessageId: message.MessageId,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken);
}
}
}
}
}
10 changes: 10 additions & 0 deletions BotNet.Services/Preview/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<YoutubePreview>();
return services;
}
}
}
127 changes: 127 additions & 0 deletions BotNet.Services/Preview/YoutubePreview.cs
Original file line number Diff line number Diff line change
@@ -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();
}


/// <summary>
/// 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/<id>/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/<id>/storyboard3_L2/M2.jpg
/// </summary>
/// <param name="youtubeLink"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task<Uri> 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);
}
}

}
45 changes: 45 additions & 0 deletions BotNet.Tests/Services/Preview/RegexTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
3 changes: 3 additions & 0 deletions BotNet/Bot/UpdateHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions BotNet/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -71,6 +72,7 @@
services.AddGoogleMaps();
services.AddWeatherService();
services.AddBMKG();
services.AddPreviewServices();

// Hosted Services
services.Configure<BotOptions>(configuration.GetSection("BotOptions"));
Expand All @@ -78,7 +80,7 @@
services.AddHostedService<BotService>();

// Telegram Bot
services.AddTelegramBot(botToken: configuration["BotOptions:AccessToken"]);

Check warning on line 83 in BotNet/Program.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'botToken' in 'IServiceCollection ServiceCollectionExtensions.AddTelegramBot(IServiceCollection services, string botToken)'.
})
.UseOrleans((hostBuilderContext, siloBuilder) => {
siloBuilder
Expand Down
Loading