diff --git a/BotNet.Services/SocialLink/SocialLinkEmbedFixer.cs b/BotNet.Services/SocialLink/SocialLinkEmbedFixer.cs new file mode 100644 index 0000000..d75fd3e --- /dev/null +++ b/BotNet.Services/SocialLink/SocialLinkEmbedFixer.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace BotNet.Services.SocialLink { + public partial class SocialLinkEmbedFixer { + public static Uri Fix(Uri link) { + string url = link.ToString(); + string newUrl = link.Host switch { + "twitter.com" => url.Replace("//twitter.com/", "//vxtwitter.com/"), + "www.twitter.com" => url.Replace("//www.twitter.com/", "//www.vxtwitter.com/"), + "x.com" => url.Replace("//x.com/", "//vxtwitter.com/"), + "www.x.com" => url.Replace("//www.x.com/", "//www.vxtwitter.com/"), + "instagram.com" => url.Replace("//instagram.com/", "//ddinstagram.com/"), + "www.instagram.com" => url.Replace("//www.instagram.com/", "//www.ddinstagram.com/"), + _ => url + }; + return new Uri(newUrl); + } + + public static IEnumerable GetPossibleUrls(string message) { + MatchCollection twitterMatches = TwitterRegex().Matches(message); + foreach (Match match in twitterMatches) { + yield return new Uri(match.Value); + } + + MatchCollection instagramMatches = InstagramRegex().Matches(message); + foreach (Match match in instagramMatches) { + yield return new Uri(match.Value); + } + } + + [GeneratedRegex(@"(?:https?:\/\/)?(?:www.)?(?:twitter\.com|x\.com)\/(?:[a-zA-Z0-9_]+\/status\/)?[0-9]+")] + private static partial Regex TwitterRegex(); + + [GeneratedRegex(@"(?:https?:\/\/)?(?:www.)?instagram.com\/?([a-zA-Z0-9\._\-]+)?\/([p]+)?([reel]+)?([tv]+)?([stories]+)?\/([a-zA-Z0-9\-_\.]+)\/?([0-9]+)?")] + private static partial Regex InstagramRegex(); + } +} diff --git a/BotNet.Tests/Services/SocialLink/SocialLinkEmbedFixerTests.cs b/BotNet.Tests/Services/SocialLink/SocialLinkEmbedFixerTests.cs new file mode 100644 index 0000000..237de84 --- /dev/null +++ b/BotNet.Tests/Services/SocialLink/SocialLinkEmbedFixerTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using BotNet.Services.SocialLink; +using FluentAssertions; +using Xunit; + +namespace BotNet.Tests.Services.SocialLink { + public class SocialLinkEmbedFixerTests { + [Theory] + [InlineData("https://www.instagram.com/reel/C0XXKVnpRUI/", "https://www.ddinstagram.com/reel/C0XXKVnpRUI/")] + [InlineData("https://instagram.com/reel/C0XXKVnpRUI/", "https://ddinstagram.com/reel/C0XXKVnpRUI/")] + [InlineData("https://twitter.com/ShowwcaseHQ/status/1556259601829576707?t=S6GuFx37mAXOLI2wdusfXg&s=19", + "https://vxtwitter.com/ShowwcaseHQ/status/1556259601829576707?t=S6GuFx37mAXOLI2wdusfXg&s=19")] + [InlineData("https://www.twitter.com/ShowwcaseHQ/status/1556259601829576707?t=S6GuFx37mAXOLI2wdusfXg&s=19", + "https://www.vxtwitter.com/ShowwcaseHQ/status/1556259601829576707?t=S6GuFx37mAXOLI2wdusfXg&s=19")] + public void CanReplaceSocialLink(string url, string? replacedUrl) { + Uri resUrl = SocialLinkEmbedFixer.Fix(new Uri(url)); + resUrl.OriginalString.Should().Be(replacedUrl); + } + + [Theory] + [InlineData("LUCU ga si? https://www.instagram.com/reel/C0XXKVnpRUI/", + new[] { "https://www.instagram.com/reel/C0XXKVnpRUI/" })] + [InlineData("LUCU ga si? https://instagram.com/reel/C0XXKVnpRUI/", + new[] { "https://instagram.com/reel/C0XXKVnpRUI/" })] + [InlineData( + "WKWKWK alisnya Kevin https://twitter.com/ShowwcaseHQ/status/1556259601829576707?t=S6GuFx37mAXOLI2wdusfXg&s=19 😂", + new[] { "https://twitter.com/ShowwcaseHQ/status/1556259601829576707" })] + [InlineData( + "WKWKWK alisnya Kevin https://x.com/ShowwcaseHQ/status/1556259601829576707?t=S6GuFx37mAXOLI2wdusfXg&s=19 😂", + new[] { "https://x.com/ShowwcaseHQ/status/1556259601829576707" })] + [InlineData("https://www.x.com/ShowwcaseHQ/status/1556259601829576707", + new[] { "https://www.x.com/ShowwcaseHQ/status/1556259601829576707" })] + [InlineData("https://www.twitter.com/ShowwcaseHQ/status/1556259601829576707", + new[] { "https://www.twitter.com/ShowwcaseHQ/status/1556259601829576707" })] + [InlineData( + "lo udah liat ini belum? https://www.twitter.com/ShowwcaseHQ/status/1556259601829576707. Ohh iya, ini juga lucu: https://instagram.com/reel/C0XXKVnpRUI", + new[] { "https://www.twitter.com/ShowwcaseHQ/status/1556259601829576707", "https://instagram.com/reel/C0XXKVnpRUI" })] + [InlineData("Iyakah?", new string[] { })] + public void CanDetectSocialLink(string message, IEnumerable? urls) { + List? resUrls = + SocialLinkEmbedFixer.GetPossibleUrls(message)?.Select(u => u.OriginalString).ToList(); + if (urls == null) { + resUrls.Should().BeNull(); + } else { + resUrls.Should().BeEquivalentTo(urls); + } + } + } +} diff --git a/BotNet/Bot/UpdateHandler.cs b/BotNet/Bot/UpdateHandler.cs index c6b84be..97c560f 100644 --- a/BotNet/Bot/UpdateHandler.cs +++ b/BotNet/Bot/UpdateHandler.cs @@ -4,9 +4,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using AngleSharp.Dom; using BotNet.Services.BotCommands; using BotNet.Services.BubbleWrap; using BotNet.Services.OpenAI; +using BotNet.Services.SocialLink; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using RG.Ninja; @@ -34,7 +36,8 @@ private async Task GetMeAsync(ITelegramBotClient botClient, CancellationTo return _me; } - public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) { + public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, + CancellationToken cancellationToken) { try { switch (update.Type) { case UpdateType.Message: @@ -42,6 +45,21 @@ public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, // Retrieve bot identity _me ??= await GetMeAsync(botClient, cancellationToken); + + // Handle Social Link (better embed) using SocialLinkEmbedFixer + if (update.Message != null) { + string message = update.Message.Text ?? update.Message.Caption ?? ""; + IEnumerable possibleUrls = SocialLinkEmbedFixer.GetPossibleUrls(message); + + foreach (Uri u in possibleUrls.Select(SocialLinkEmbedFixer.Fix)) + { + await botClient.SendTextMessageAsync( + chatId: update.Message.Chat.Id, + text: $"Preview: {u.OriginalString}", + replyToMessageId: update.Message.MessageId, + cancellationToken: cancellationToken); + } + } // Handle reddit mirroring if (update.Message?.Entities?.FirstOrDefault(entity => entity is { Type: MessageEntityType.Url }) is { Offset: var offset, Length: var length } @@ -54,8 +72,9 @@ await botClient.SendTextMessageAsync( disableWebPagePreview: true, cancellationToken: cancellationToken ); - } else if (update.Message?.Entities?.FirstOrDefault(entity => entity is { Type: MessageEntityType.TextLink }) is { Url: { } textUrl } - && textUrl.StartsWith("https://www.reddit.com/", out string? remainingTextUrl)) { + } else if (update.Message?.Entities?.FirstOrDefault(entity => + entity is { Type: MessageEntityType.TextLink }) is { Url: { } textUrl } + && textUrl.StartsWith("https://www.reddit.com/", out string? remainingTextUrl)) { await botClient.SendTextMessageAsync( chatId: update.Message.Chat.Id, text: $"Mirror: https://libreddit.teknologiumum.com/{remainingTextUrl}", @@ -67,9 +86,9 @@ await botClient.SendTextMessageAsync( // Handle call sign if ((update.Message?.Text ?? update.Message?.Caption) is { } messageText && ( - messageText.StartsWith("AI,") - || messageText.StartsWith("Pakde,") - )) { + messageText.StartsWith("AI,") + || messageText.StartsWith("Pakde,") + )) { // Get call sign string callSign = messageText.Split(',')[0]; @@ -82,10 +101,12 @@ await botClient.SendTextMessageAsync( // Respond to call sign switch (callSign) { case "AI": - await OpenAI.StreamChatWithFriendlyBotAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await OpenAI.StreamChatWithFriendlyBotAsync(botClient, _serviceProvider, + update.Message, cancellationToken); break; case "Pakde": - Message? sentMessage = await OpenAI.ChatWithSarcasticBotAsync(botClient, _serviceProvider, update.Message, callSign, cancellationToken); + Message? sentMessage = await OpenAI.ChatWithSarcasticBotAsync(botClient, + _serviceProvider, update.Message, callSign, cancellationToken); if (sentMessage is not null) { // Track sent message _serviceProvider.GetRequiredService().TrackMessage( @@ -96,26 +117,28 @@ await botClient.SendTextMessageAsync( replyToMessageId: sentMessage.ReplyToMessage!.MessageId ); } + break; default: throw new NotImplementedException($"Call sign {callSign} not handled"); } + break; } // Handle reply if (update.Message is { - MessageId: int messageId, - From: { FirstName: string firstName, LastName: var lastName }, - Text: { Length: > 0 } text - } - && update.Message.Entities?.FirstOrDefault(entity => entity is { Type: MessageEntityType.BotCommand, Offset: 0 }) is null - && update.Message.ReplyToMessage is { - MessageId: int replyToMessageId, - From.Id: long replyToUserId - } - && replyToUserId == _me?.Id) { - + MessageId: int messageId, + From: { FirstName: string firstName, LastName: var lastName }, + Text: { Length: > 0 } text + } + && update.Message.Entities?.FirstOrDefault(entity => + entity is { Type: MessageEntityType.BotCommand, Offset: 0 }) is null + && update.Message.ReplyToMessage is { + MessageId: int replyToMessageId, + From.Id: long replyToUserId + } + && replyToUserId == _me?.Id) { ThreadTracker threadTracker = _serviceProvider.GetRequiredService(); // Track message @@ -128,10 +151,11 @@ await botClient.SendTextMessageAsync( ); // Get thread - ImmutableList<(string Sender, string? Text, string? ImageBase64)> thread = threadTracker.GetThread( - messageId: replyToMessageId, - maxLines: 20 - ).ToImmutableList(); + ImmutableList<(string Sender, string? Text, string? ImageBase64)> thread = threadTracker + .GetThread( + messageId: replyToMessageId, + maxLines: 20 + ).ToImmutableList(); // Don't respond if thread is empty if (thread.Count > 0) { @@ -141,10 +165,12 @@ await botClient.SendTextMessageAsync( // Respond to thread switch (callSign) { case "AI": - await OpenAI.StreamChatWithFriendlyBotAsync(botClient, _serviceProvider, update.Message, thread, cancellationToken); + await OpenAI.StreamChatWithFriendlyBotAsync(botClient, _serviceProvider, + update.Message, thread, cancellationToken); break; case "Pakde": - Message? sentMessage = await OpenAI.ChatWithSarcasticBotAsync(botClient, _serviceProvider, update.Message, thread, callSign, cancellationToken); + Message? sentMessage = await OpenAI.ChatWithSarcasticBotAsync(botClient, + _serviceProvider, update.Message, thread, callSign, cancellationToken); if (sentMessage is not null) { // Track sent message threadTracker.TrackMessage( @@ -155,16 +181,19 @@ await botClient.SendTextMessageAsync( replyToMessageId: sentMessage.ReplyToMessage!.MessageId ); } + break; default: throw new NotImplementedException($"Call sign {callSign} not handled"); } + break; } } // Handle commands - if (update.Message?.Entities?.FirstOrDefault(entity => entity is { Type: MessageEntityType.BotCommand, Offset: 0 }) is { } commandEntity) { + if (update.Message?.Entities?.FirstOrDefault(entity => + entity is { Type: MessageEntityType.BotCommand, Offset: 0 }) is { } commandEntity) { string command = update.Message.Text!.Substring(commandEntity.Offset, commandEntity.Length); // Check if command is in /command@botname format @@ -173,11 +202,13 @@ await botClient.SendTextMessageAsync( string targetUsername = command[(ampersandPos + 1)..]; // Command is not for me - if (!StringComparer.InvariantCultureIgnoreCase.Equals(targetUsername, (await GetMeAsync(botClient, cancellationToken)).Username)) break; + if (!StringComparer.InvariantCultureIgnoreCase.Equals(targetUsername, + (await GetMeAsync(botClient, cancellationToken)).Username)) break; // Normalize command command = command[..ampersandPos]; } + switch (command.ToLowerInvariant()) { case "/flip": await FlipFlop.HandleFlipAsync(botClient, update.Message, cancellationToken); @@ -195,10 +226,12 @@ await botClient.SendTextMessageAsync( await Fuck.HandleFuckAsync(botClient, update.Message, cancellationToken); break; case "/evaljs": - await Eval.EvalJSAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await Eval.EvalJSAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/evalcs": - await Eval.EvalCSAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await Eval.EvalCSAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/c": case "/clojure": @@ -217,31 +250,41 @@ await botClient.SendTextMessageAsync( case "/scala": case "/swift": case "/julia": - await Exec.ExecAsync(botClient, _serviceProvider, update.Message, command.ToLowerInvariant()[1..], command.ToLowerInvariant()[1..], cancellationToken); + await Exec.ExecAsync(botClient, _serviceProvider, update.Message, + command.ToLowerInvariant()[1..], command.ToLowerInvariant()[1..], + cancellationToken); break; case "/sqlite3": - await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "SQLite3", "sqlite3", cancellationToken); + await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "SQLite3", + "sqlite3", cancellationToken); break; case "/commonlisp": - await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "CommonLisp", "cl", cancellationToken); + await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "CommonLisp", + "cl", cancellationToken); break; case "/cpp": - await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "C++", "cpp", cancellationToken); + await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "C++", "cpp", + cancellationToken); break; case "/cs": - await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "csharp.net", "csharp", cancellationToken); + await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "csharp.net", + "csharp", cancellationToken); break; case "/fs": - await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "fsharp.net", "fsharp", cancellationToken); + await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "fsharp.net", + "fsharp", cancellationToken); break; case "/js": - await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "JavaScript", "js", cancellationToken); + await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "JavaScript", + "js", cancellationToken); break; case "/ts": - await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "TypeScript", "ts", cancellationToken); + await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "TypeScript", + "ts", cancellationToken); break; case "/vb": - await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "basic.net", "vbnet", cancellationToken); + await Exec.ExecAsync(botClient, _serviceProvider, update.Message, "basic.net", + "vbnet", cancellationToken); break; case "/pop": await botClient.SendTextMessageAsync( @@ -253,76 +296,96 @@ await botClient.SendTextMessageAsync( ); break; case "/explain": - await OpenAI.ExplainAsync(botClient, _serviceProvider, update.Message, "en", cancellationToken); + await OpenAI.ExplainAsync(botClient, _serviceProvider, update.Message, "en", + cancellationToken); break; case "/jelaskan": - await OpenAI.ExplainAsync(botClient, _serviceProvider, update.Message, "id", cancellationToken); + await OpenAI.ExplainAsync(botClient, _serviceProvider, update.Message, "id", + cancellationToken); break; case "/ask": - await OpenAI.AskHelpAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await OpenAI.AskHelpAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/enid": case "/iden": case "/eniden": case "/idenid": - await OpenAI.TranslateAsync(botClient, _serviceProvider, update.Message, command.ToLowerInvariant()[1..], cancellationToken); + await OpenAI.TranslateAsync(botClient, _serviceProvider, update.Message, + command.ToLowerInvariant()[1..], cancellationToken); break; case "/genjs": - await OpenAI.GenerateJavaScriptCodeAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await OpenAI.GenerateJavaScriptCodeAsync(botClient, _serviceProvider, + update.Message, cancellationToken); break; case "/humor": - await Joke.GetRandomJokeAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await Joke.GetRandomJokeAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/clean": - await Clean.SanitizeLinkAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await Clean.SanitizeLinkAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/waifu": await Waifu.GetRandomWaifuAsync(botClient, update.Message, cancellationToken); break; case "/cat": - await Cat.GetRandomCatAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await Cat.GetRandomCatAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/idea": - await Idea.GetRandomIdeaAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await Idea.GetRandomIdeaAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/art": - await Art.GetRandomArtAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await Art.GetRandomArtAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/tldr": - await OpenAI.GenerateTldrAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await OpenAI.GenerateTldrAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/webp": await Webp.ConvertToImageAsync(botClient, update.Message, cancellationToken); break; case "/map": - await SearchPlace.SearchPlaceAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await SearchPlace.SearchPlaceAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/weather": - await Weather.GetWeatherAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await Weather.GetWeatherAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/bmkg": - await BMKG.GetLatestEarthQuakeAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await BMKG.GetLatestEarthQuakeAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/preview": - await Preview.GetPreviewAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await Preview.GetPreviewAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; case "/ramad": - await Meme.HandleRamadAsync(botClient, _serviceProvider, update.Message, cancellationToken); + await Meme.HandleRamadAsync(botClient, _serviceProvider, update.Message, + cancellationToken); break; } } + break; case UpdateType.InlineQuery: if (update.InlineQuery?.Query.Trim().ToLowerInvariant() is { Length: > 0 } query) { - IEnumerable inlineQueryResults = await _inlineQueryHandler.GetResultsAsync(query, cancellationToken); + IEnumerable inlineQueryResults = + await _inlineQueryHandler.GetResultsAsync(query, cancellationToken); await botClient.AnswerInlineQueryAsync( inlineQueryId: update.InlineQuery.Id, results: inlineQueryResults, cancellationToken: cancellationToken); } + break; case UpdateType.CallbackQuery: - BubbleWrapKeyboardGenerator bubbleWrapKeyboardGenerator = _serviceProvider.GetRequiredService(); + BubbleWrapKeyboardGenerator bubbleWrapKeyboardGenerator = + _serviceProvider.GetRequiredService(); InlineKeyboardMarkup poppedKeyboardMarkup = bubbleWrapKeyboardGenerator.HandleCallback( chatId: update.CallbackQuery!.Message!.Chat.Id, messageId: update.CallbackQuery.Message.MessageId, @@ -345,9 +408,11 @@ await botClient.EditMessageReplyMarkupAsync( } } - public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, CancellationToken cancellationToken) { + public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, + CancellationToken cancellationToken) { string errorMessage = exception switch { - ApiRequestException apiRequestException => $"Telegram API Error:\n{apiRequestException.ErrorCode}\n{apiRequestException.Message}", + ApiRequestException apiRequestException => + $"Telegram API Error:\n{apiRequestException.ErrorCode}\n{apiRequestException.Message}", _ => exception.ToString() }; _logger.LogError(exception, "{message}", errorMessage);