using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; using System.Text.RegularExpressions; using Humanizer; using KfChatDotNetBot.Extensions; using KfChatDotNetBot.Models; using KfChatDotNetBot.Models.DbModels; using KfChatDotNetBot.Services; using KfChatDotNetBot.Settings; using KfChatDotNetWsClient.Models.Events; using NLog; namespace KfChatDotNetBot.Commands; [NoPrefixRequired] public class XeetEmbedCommand : ICommand { private static string LoadingGif = "[img]https://i.ddos.lgbt/u/3sKyHs.webp[/img]"; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); public List Patterns { get; set; } = [ new Regex(@"https?:\/\/x\.com\/(?:\#!\/)?(\w+)\/(?:status|statuses|thread)\/(?\d+)", RegexOptions.IgnoreCase), new Regex(@"https?:\/\/twitter\.com\/(?:\#!\/)?(\w+)\/(?:status|statuses|thread)\/(?\d+)", RegexOptions.IgnoreCase), new Regex(@"https?:\/\/mobile\.twitter\.com\/(?:\#!\/)?(\w+)\/(?:status|statuses|thread)\/(?\d+)", RegexOptions.IgnoreCase) ]; public string? HelpText { get; } = "Embed Xeets"; public UserRight RequiredRight { get; } = UserRight.Loser; public TimeSpan Timeout { get; } = TimeSpan.FromSeconds(30); public RateLimitOptionsModel? RateLimitOptions { get; } = new() { MaxInvocations = 3, Window = TimeSpan.FromSeconds(30), // Really don't want to get rate-limited by FxTwitter hence global rate-limits Flags = RateLimitFlags.Global }; public bool WhisperCanInvoke => false; public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx) { var kiwiFarmsUsername = await SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsUsername); if (message.Author.Username == kiwiFarmsUsername.Value) { return; } var xeetEnabled = await SettingsProvider.GetValueAsync(BuiltIn.Keys.XeetEnabled); if (!xeetEnabled.ToBoolean()) return; var loadingMessage = await botInstance.SendChatMessageAsync($"{LoadingGif} Fetching tweet...", true); var success = await botInstance.WaitForChatMessageAsync(loadingMessage, TimeSpan.FromSeconds(10), ctx); if (!success) throw new InvalidOperationException(); try { var xeetId = arguments["xeetId"].Value; var tweetData = await FetchTweetDataAsync(xeetId, ctx); if (tweetData == null) { throw new InvalidOperationException("tweetData was null"); } var tweet = tweetData.Tweet; var mediaUrls = new List(); if (tweet.HasAnyMedia()) { mediaUrls = await ProcessMediaAsync(tweet, ctx); } var messages = await BuildTweetMessagesAsync(tweet, xeetId, mediaUrls, ctx); if (loadingMessage.ChatMessageUuid != null) { await botInstance.KfClient.DeleteMessageAsync(loadingMessage.ChatMessageUuid); } if (messages.Count == 0) { return; } if (messages.Count > 4) { // bail, we don't want to spam the chat with giant threads of messages if something goes wrong with the splitting logic Logger.Warn($"Aborting sending Xeet embed - message count {messages.Count} exceeds threshold"); return; } foreach (var msg in messages) { await botInstance.SendChatMessageAsync(msg, true); } // send archive link message var url = $"https://nitter.net/{tweet.Author.ScreenName}/status/{xeetId}"; await botInstance.SendChatMessageAsync( $"[url=https://archive.is/submit/?url={url}]Archive Xeet on archive.is[/url]", true); } catch { // Delete loading message on error if (loadingMessage.ChatMessageUuid != null) { await botInstance.KfClient.DeleteMessageAsync(loadingMessage.ChatMessageUuid); } throw; } } private async Task FetchTweetDataAsync(string xeetId, CancellationToken ctx) { var api = $"https://api.fxtwitter.com/status/{xeetId}"; var proxy = await SettingsProvider.GetValueAsync(BuiltIn.Keys.Proxy); var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All, AllowAutoRedirect = true }; if (proxy.Value != null) { handler.UseProxy = true; handler.Proxy = new WebProxy(proxy.Value); Logger.Debug($"Configured to use proxy {proxy.Value}"); } // Yes, very ghetto but we do need a "real" UA for FxTwitter from my experience var ua = await SettingsProvider.GetValueAsync(BuiltIn.Keys.CaptureYtDlpUserAgent); using var client = new HttpClient(handler); client.DefaultRequestHeaders.UserAgent.ParseAdd(ua.Value); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); return await client.GetFromJsonAsync(api, cancellationToken: ctx); } private async Task> ProcessMediaAsync(FxTweet tweet, CancellationToken ctx) { var uploadedUrls = new List(); if (!await Zipline.IsZiplineEnabled()) { Logger.Warn("Zipline is not enabled, skipping media upload"); return uploadedUrls; } try { if (tweet.Media?.Photos != null) { foreach (var photo in tweet.Media.Photos) { try { var url = await DownloadAndUploadImageAsync(photo.Url, ctx); if (!string.IsNullOrEmpty(url)) { uploadedUrls.Add(url); } } catch (Exception ex) { Logger.Error(ex, $"Failed to process photo: {photo.Url}"); } } } if (tweet.Media?.Videos != null) { foreach (var video in tweet.Media.Videos) { try { var url = await DownloadAndConvertVideoAsync(video, ctx); if (!string.IsNullOrEmpty(url)) { uploadedUrls.Add(url); } } catch (Exception ex) { Logger.Error(ex, $"Failed to process video: {video.Url}"); } } } } catch (Exception ex) { Logger.Error(ex, "Error processing media"); } return uploadedUrls; } private async Task DownloadAndUploadImageAsync(string imageUrl, CancellationToken ctx) { using var httpClient = new HttpClient(); var imageBytes = await httpClient.GetByteArrayAsync(imageUrl, ctx); using var imageStream = new MemoryStream(imageBytes); var url = await Zipline.Upload(imageStream, new MediaTypeHeaderValue("image/jpeg"), "9h", ctx); Logger.Info($"Uploaded image to Zipline: {url}"); return url; } private async Task DownloadAndConvertVideoAsync(FxVideo video, CancellationToken ctx) { var maxDurationSetting = await SettingsProvider.GetValueAsync(BuiltIn.Keys.XeetMaxVideoDurationSeconds); var maxDuration = maxDurationSetting.ToType(); if (video.Duration > maxDuration) { Logger.Info($"Skipping video conversion: duration {video.Duration}s exceeds max {maxDuration}s"); return null; } // Get the best quality variant var bestVariant = video.Variants .Where(v => v.Bitrate.HasValue) .OrderByDescending(v => v.Bitrate) .FirstOrDefault() ?? video.Variants.FirstOrDefault(); if (bestVariant == null) { Logger.Warn("No video variant found"); return null; } var videoUrl = bestVariant.Url; Logger.Info($"Downloading video from {videoUrl} (duration: {video.Duration}s)"); using var httpClient = new HttpClient(); var videoBytes = await httpClient.GetByteArrayAsync(videoUrl, ctx); var tempVideoPath = Path.Combine(Path.GetTempPath(), $"tweet_video_{Guid.NewGuid()}.mp4"); var tempWebpPath = Path.Combine(Path.GetTempPath(), $"tweet_video_{Guid.NewGuid()}.webp"); try { await File.WriteAllBytesAsync(tempVideoPath, videoBytes, ctx); var ffmpegPath = await SettingsProvider.GetValueAsync(BuiltIn.Keys.FFmpegBinaryPath); var ffmpegArgs = $"-i \"{tempVideoPath}\" -vf \"fps=10,scale='min(640,iw)':'min(480,ih)':force_original_aspect_ratio=decrease\" -c:v libwebp -lossless 0 -quality 75 -loop 0 -an \"{tempWebpPath}\""; var processInfo = new ProcessStartInfo { FileName = ffmpegPath.Value ?? "ffmpeg", Arguments = ffmpegArgs, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var process = Process.Start(processInfo); if (process == null) { Logger.Error("Failed to start FFmpeg process"); return null; } await process.WaitForExitAsync(ctx); if (process.ExitCode != 0) { var error = await process.StandardError.ReadToEndAsync(ctx); Logger.Error($"FFmpeg conversion failed: {error}"); return null; } await using var webpStream = File.OpenRead(tempWebpPath); var url = await Zipline.Upload(webpStream, new MediaTypeHeaderValue("image/webp"), "9h", ctx); Logger.Info($"Uploaded video as WebP to Zipline: {url}"); return url; } finally { try { if (File.Exists(tempVideoPath)) File.Delete(tempVideoPath); if (File.Exists(tempWebpPath)) File.Delete(tempWebpPath); } catch (Exception ex) { Logger.Warn(ex, "Failed to cleanup temp files"); } } } private async Task> BuildTweetMessagesAsync(FxTweet tweet, string xeetId, List mediaUrls, CancellationToken ctx) { var bodyBuilder = new StringBuilder(); var footerBuilder = new StringBuilder(); // Build header - main tweet author and timestamp (always goes first) var created = DateTimeOffset.FromUnixTimeSeconds(tweet.CreatedTimestamp); var header = $"[b]{tweet.Author.Name}[/b] (@{tweet.Author.ScreenName}) - {created.Humanize(DateTimeOffset.UtcNow)}[br]"; // Handle reply chain (if this tweet is a reply) if (!string.IsNullOrEmpty(tweet.ReplyingToStatus)) { try { var replyData = await FetchTweetDataAsync(tweet.ReplyingToStatus, ctx); if (replyData?.Tweet != null) { var replyTweet = replyData.Tweet; var replyCreated = DateTimeOffset.FromUnixTimeSeconds(replyTweet.CreatedTimestamp); bodyBuilder.Append($"[i]â†Šī¸ Replying to:[/i][br]"); bodyBuilder.Append($"[b]{replyTweet.Author.Name}[/b] (@{replyTweet.Author.ScreenName}) - {replyCreated.Humanize(DateTimeOffset.UtcNow)}[br]"); var replyText = replyTweet.Text; const int replyTextLimit = 250; if (replyText.Utf8LengthBytes() > replyTextLimit) { replyText = replyText.TruncateBytes(replyTextLimit).TrimEnd() + "â€Ļ"; } bodyBuilder.Append($"{replyText}[br][br]"); } } catch (Exception ex) { Logger.Error(ex, $"Failed to fetch reply tweet: {tweet.ReplyingToStatus}"); } } // Main tweet text var mainText = tweet.Text; bodyBuilder.Append($"{mainText}[br]"); if (mediaUrls.Count > 0) { foreach (var mediaUrl in mediaUrls) { bodyBuilder.Append($"[img]{mediaUrl}[/img][br]"); } } // Handle quote tweet (if this tweet quotes another) if (tweet.Quote != null) { bodyBuilder.Append("[br]"); var quoteTweet = tweet.Quote; var quoteCreated = DateTimeOffset.FromUnixTimeSeconds(quoteTweet.CreatedTimestamp); bodyBuilder.Append($"[i]đŸ’Ŧ Quoting:[/i][br]"); bodyBuilder.Append($"[b]{quoteTweet.Author.Name}[/b] (@{quoteTweet.Author.ScreenName}) - {quoteCreated.Humanize(DateTimeOffset.UtcNow)}[br]"); var quoteText = quoteTweet.Text; const int quoteTextLimit = 250; if (quoteText.Utf8LengthBytes() > quoteTextLimit) { quoteText = quoteText.TruncateBytes(quoteTextLimit).TrimEnd() + "â€Ļ"; } bodyBuilder.Append($"{quoteText}[br]"); } // Build footer (stats + links) - this will always be on the last message footerBuilder.Append($"đŸ’Ŧ {tweet.Replies:N0} 🔁 {tweet.Retweets:N0} â¤ī¸ {tweet.Likes:N0} đŸ‘ī¸ {tweet.Views:N0}[br]"); footerBuilder.Append($"[url={tweet.Url}]X.com[/url] | [url=https://xcancel.com/{tweet.Author.ScreenName}/status/{xeetId}]Xcancel[/url]"); // Split message if needed, with header always first and footer always last var messages = (header + bodyBuilder + footerBuilder).FancySplitMessage(); return messages; } }