From 29f2863c9a5164e78caf44c5c6ac98f848645448 Mon Sep 17 00:00:00 2001 From: barelyprofessional <150058423+barelyprofessional@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:53:06 -0600 Subject: [PATCH] Refactored Xeet embedding --- KfChatDotNetBot/ChatBot.cs | 1 - KfChatDotNetBot/Commands/XeetEmbedCommand.cs | 102 +++++++++ KfChatDotNetBot/Models/FxTwitterModels.cs | 96 ++++++++ KfChatDotNetBot/Services/XeetEmbed.cs | 226 ------------------- KfChatDotNetBot/Settings/BuiltIn.cs | 2 + 5 files changed, 200 insertions(+), 227 deletions(-) create mode 100644 KfChatDotNetBot/Commands/XeetEmbedCommand.cs create mode 100644 KfChatDotNetBot/Models/FxTwitterModels.cs delete mode 100644 KfChatDotNetBot/Services/XeetEmbed.cs diff --git a/KfChatDotNetBot/ChatBot.cs b/KfChatDotNetBot/ChatBot.cs index 200cd49..80b173f 100644 --- a/KfChatDotNetBot/ChatBot.cs +++ b/KfChatDotNetBot/ChatBot.cs @@ -361,7 +361,6 @@ public class ChatBot _logger.Error("ProcessMessage threw an exception"); _logger.Error(e); } - XeetEmbed.HandleXeet(this, message).Wait(_cancellationToken); } // Update or add the element to keep it in sync diff --git a/KfChatDotNetBot/Commands/XeetEmbedCommand.cs b/KfChatDotNetBot/Commands/XeetEmbedCommand.cs new file mode 100644 index 0000000..4ea2718 --- /dev/null +++ b/KfChatDotNetBot/Commands/XeetEmbedCommand.cs @@ -0,0 +1,102 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +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 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 async Task RunCommand(ChatBot botInstance, MessageModel 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 xeetId = arguments["xeetId"].Value; + 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")); + + var tweetData = await client.GetFromJsonAsync(api, cancellationToken: ctx); + + if (tweetData == null) + { + throw new InvalidOperationException("tweetData was null"); + } + + if (tweetData.Tweet.HasAnyMedia()) + { + // todo: gamba sesh still handles media tweets for now + return; + } + + var tweet = tweetData.Tweet; + var mainText = tweet.Text; + // Sneedchat works off of bytes hence you can't count characters reliably :( + const int xeetLimit = 900; + if (mainText.Utf8LengthBytes() > xeetLimit) + { + // Copied behavior from SendChatMessageAsync + mainText = mainText.TruncateBytes(xeetLimit).TrimEnd() + "โ€ฆ"; + } + var created = DateTimeOffset.FromUnixTimeSeconds(tweet.CreatedTimestamp); + var response = $"[b][plain]{tweet.Author.Name}[/plain][/b] [plain](@{tweet.Author.ScreenName})[/plain] - {created.Humanize(DateTimeOffset.UtcNow)}[br]" + + $"[plain]{mainText}[/plain][br]" + + $"๐Ÿ’ฌ {tweet.Replies}, ๐Ÿ” {tweet.Retweets}, โค๏ธ {tweet.Likes}, ๐Ÿ‘๏ธ {tweet.Views}[br]" + + $"[url={tweet.Url}]X.com[/url] | [url=https://xcancel.com/{tweet.Author.ScreenName}/status/{xeetId}]Xcancel[/url]"; + await botInstance.SendChatMessageAsync(response, true); + } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Models/FxTwitterModels.cs b/KfChatDotNetBot/Models/FxTwitterModels.cs new file mode 100644 index 0000000..34d5cdf --- /dev/null +++ b/KfChatDotNetBot/Models/FxTwitterModels.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Serialization; + +namespace KfChatDotNetBot.Models; + +internal class FxTwitterResponse +{ + [JsonPropertyName("code")] public int Code { get; set; } + [JsonPropertyName("message")] public string Message { get; set; } = string.Empty; + [JsonPropertyName("tweet")] public FxTweet Tweet { get; set; } = new(); +} + +internal class FxTweet +{ + [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; + [JsonPropertyName("raw_text")] public FxRawText RawText { get; set; } = new(); + [JsonPropertyName("author")] public FxAuthor Author { get; set; } = new(); + [JsonPropertyName("replies")] public int Replies { get; set; } + [JsonPropertyName("retweets")] public int Retweets { get; set; } + [JsonPropertyName("likes")] public int Likes { get; set; } + + [JsonPropertyName("created_timestamp")] + public long CreatedTimestamp { get; set; } + + [JsonPropertyName("views")] public int Views { get; set; } + [JsonPropertyName("is_note_tweet")] public bool IsNoteTweet { get; set; } + [JsonPropertyName("community_note")] public object? CommunityNote { get; set; } + [JsonPropertyName("lang")] public string Lang { get; set; } = string.Empty; + [JsonPropertyName("replying_to")] public string? ReplyingTo { get; set; } + + [JsonPropertyName("replying_to_status")] + public string? ReplyingToStatus { get; set; } + + [JsonPropertyName("media")] public FxMedia? Media { get; set; } + [JsonPropertyName("source")] public string Source { get; set; } = string.Empty; + [JsonPropertyName("quote")] public FxTweet? Quote { get; set; } = null; + + internal bool HasAnyMedia() + { + if (Media == null) + return false; + + if (Media.Photos != null && Media.Photos.Count > 0) + return true; + + if (Media.Videos != null && Media.Videos.Count > 0) + return true; + + return false; + } +} + +internal class FxRawText +{ + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; +} + +internal class FxAuthor +{ + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("screen_name")] public string ScreenName { get; set; } = string.Empty; +} + +internal class FxMedia +{ + [JsonPropertyName("photos")] public List? Photos { get; set; } + [JsonPropertyName("videos")] public List? Videos { get; set; } +} + +internal class FxPhoto +{ + [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; + [JsonPropertyName("width")] public int Width { get; set; } + [JsonPropertyName("height")] public int Height { get; set; } +} + +internal class FxVideo +{ + [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; + [JsonPropertyName("thumbnail_url")] public string ThumbnailUrl { get; set; } = string.Empty; + [JsonPropertyName("duration")] public double Duration { get; set; } + [JsonPropertyName("width")] public int Width { get; set; } + [JsonPropertyName("height")] public int Height { get; set; } + [JsonPropertyName("format")] public string Format { get; set; } = string.Empty; + [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + [JsonPropertyName("variants")] public List Variants { get; set; } = []; +} + +internal class FxVariant +{ + [JsonPropertyName("content_type")] public string ContentType { get; set; } = string.Empty; + [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; + [JsonPropertyName("bitrate")] public int? Bitrate { get; set; } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Services/XeetEmbed.cs b/KfChatDotNetBot/Services/XeetEmbed.cs deleted file mode 100644 index 165abda..0000000 --- a/KfChatDotNetBot/Services/XeetEmbed.cs +++ /dev/null @@ -1,226 +0,0 @@ - -using System.Globalization; -using System.Net; -using System.Net.Http.Headers; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using KfChatDotNetBot.Models; -using KfChatDotNetBot.Settings; -using KfChatDotNetWsClient.Models.Events; -using NLog; - -namespace KfChatDotNetBot.Services; - -public class XeetEmbed -{ - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - - internal class FxTwitterResponse - { - [JsonPropertyName("code")] public int Code { get; set; } - [JsonPropertyName("message")] public string Message { get; set; } = string.Empty; - [JsonPropertyName("tweet")] public FxTweet Tweet { get; set; } = new FxTweet(); - } - - internal class FxTweet - { - [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; - [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; - [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; - [JsonPropertyName("raw_text")] public FxRawText RawText { get; set; } = new FxRawText(); - [JsonPropertyName("author")] public FxAuthor Author { get; set; } = new FxAuthor(); - [JsonPropertyName("replies")] public int Replies { get; set; } - [JsonPropertyName("retweets")] public int Retweets { get; set; } - [JsonPropertyName("likes")] public int Likes { get; set; } - [JsonPropertyName("created_timestamp")] public long CreatedTimestamp { get; set; } - [JsonPropertyName("views")] public int Views { get; set; } - [JsonPropertyName("is_note_tweet")] public bool IsNoteTweet { get; set; } - [JsonPropertyName("community_note")] public object? CommunityNote { get; set; } - [JsonPropertyName("lang")] public string Lang { get; set; } = string.Empty; - [JsonPropertyName("replying_to")] public string? ReplyingTo { get; set; } - [JsonPropertyName("replying_to_status")] public string? ReplyingToStatus { get; set; } - [JsonPropertyName("media")] public FxMedia? Media { get; set; } - [JsonPropertyName("source")] public string Source { get; set; } = string.Empty; - [JsonPropertyName("quote")] public FxTweet? Quote { get; set; } = null; - - internal bool HasAnyMedia() - { - if (Media == null) - return false; - - if (Media.Photos != null && Media.Photos.Count > 0) - return true; - - if (Media.Videos != null && Media.Videos.Count > 0) - return true; - - return false; - } - } - - internal class FxRawText - { - [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; - } - - internal class FxAuthor - { - [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; - [JsonPropertyName("screen_name")] public string ScreenName { get; set; } = string.Empty; - } - - internal class FxMedia - { - [JsonPropertyName("photos")] public List? Photos { get; set; } - [JsonPropertyName("videos")] public List? Videos { get; set; } - } - - internal class FxPhoto - { - [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; - [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; - [JsonPropertyName("width")] public int Width { get; set; } - [JsonPropertyName("height")] public int Height { get; set; } - } - - internal class FxVideo - { - [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; - [JsonPropertyName("thumbnail_url")] public string ThumbnailUrl { get; set; } = string.Empty; - [JsonPropertyName("duration")] public double Duration { get; set; } - [JsonPropertyName("width")] public int Width { get; set; } - [JsonPropertyName("height")] public int Height { get; set; } - [JsonPropertyName("format")] public string Format { get; set; } = string.Empty; - [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; - [JsonPropertyName("variants")] public List Variants { get; set; } = new List(); - } - - internal class FxVariant - { - [JsonPropertyName("content_type")] public string ContentType { get; set; } = string.Empty; - [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; - [JsonPropertyName("bitrate")] public int? Bitrate { get; set; } - } - - internal static string MakeDate(long timestamp) - { - var dateTime = DateTimeOffset.FromUnixTimeMilliseconds(timestamp * 1000).DateTime; - - var fullTimestamp = dateTime.ToString("h:mm tt ยท MMM d, yyyy", CultureInfo.InvariantCulture); - - var relativeTimestamp = ""; - if ((DateTime.UtcNow - dateTime).TotalHours < 24) - { - var timeAgo = DateTime.UtcNow - dateTime; - if (timeAgo.TotalHours >= 1) - { - int hours = (int)timeAgo.TotalHours; - relativeTimestamp = $"{hours} hour{(hours > 1 ? "s" : "")} ago"; - } - else if (timeAgo.TotalMinutes >= 1) - { - int minutes = (int)timeAgo.TotalMinutes; - relativeTimestamp = $"{minutes} minute{(minutes > 1 ? "s" : "")} ago"; - } - else - { - int seconds = (int)timeAgo.TotalSeconds; - relativeTimestamp = $"{seconds} second{(seconds > 1 ? "s" : "")} ago"; - } - } - - return fullTimestamp + (relativeTimestamp != "" ? $" ({relativeTimestamp})" : ""); - } - - internal static string MakeMessageFromTweet(FxTweet tweet, string xeetId) - { - var mainText = tweet.Text; - if (mainText.Length > 900) - { - mainText = mainText.Substring(0, 900) + "..."; - } - var message = $"[b]{tweet.Author.Name}[/b] (@{tweet.Author.ScreenName}) - {MakeDate(tweet.CreatedTimestamp)}\n"; - message += $"{mainText}\n"; - message += $"๐Ÿ’ฌ {tweet.Replies}, ๐Ÿ” {tweet.Retweets}, โค๏ธ {tweet.Likes}, ๐Ÿ‘๏ธ {tweet.Views}\n"; - message += $"[url={tweet.Url}]X.com[/url] | [url=https://xcancel.com/{tweet.Author.ScreenName}/status/{xeetId}]Xcancel[/url]"; - - return message; - } - - internal static async Task HandleXeet(ChatBot botInstance, MessageModel message) - { - var kiwiFarmsUsername = await SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsUsername); - if (message.Author.Username == kiwiFarmsUsername.Value) - { - return; - } - - try - { - string messageText = message.Message.Trim(); - - var xeetIdMatch = Regex.Match(messageText, @"status/(\d+)"); - if (!xeetIdMatch.Success) - return; - - var xeetId = xeetIdMatch.Groups[1].Value; - 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}"); - } - using var client = new HttpClient(handler) - { - // idk but the requests fail with http 401 while curl works just fine so some of these settings fix it, too lazy to check which one it is. - DefaultRequestVersion = HttpVersion.Version11, - DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower - }; - client.DefaultRequestHeaders.UserAgent.ParseAdd("curl/8.5.0"); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*")); - client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.8"); - - using var request = new HttpRequestMessage(HttpMethod.Get, api) - { - Version = HttpVersion.Version11, - VersionPolicy = HttpVersionPolicy.RequestVersionOrLower - }; - - var response = await client.SendAsync(request); - response.EnsureSuccessStatusCode(); - var responseBody = await response.Content.ReadAsStringAsync(); - var tweetData = JsonSerializer.Deserialize(responseBody); - - if (tweetData == null || tweetData.Code != 200) - { - Logger.Error("Failed to fetch Xeet info or invalid response code."); - await botInstance.SendChatMessageAsync("Failed to fetch Xeet info.", true); - return; - } - - if (tweetData.Tweet.HasAnyMedia()) - { - // todo: gamba sesh still handles media tweets for now - return; - } - var formattedXeet = MakeMessageFromTweet(tweetData.Tweet, xeetId); - Logger.Info($"Final xeet: \n{formattedXeet}"); - await botInstance.SendChatMessageAsync(formattedXeet, true); - } - catch (Exception ex) - { - await botInstance.SendChatMessageAsync("Failed to fetch Xeet info.", true); - Logger.Error(ex, "Error fetching Xeet info"); - return; - } - } -} \ No newline at end of file diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index b0b6308..6b2276c 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -500,6 +500,8 @@ public static class BuiltIn public static string KasinoSlotsEnabled = "Kasino.Slots.Enabled"; [BuiltInSetting("Whether plinko is enabled", SettingValueType.Boolean, "true", BooleanRegex)] public static string KasinoPlinkoEnabled = "Kasino.Plinko.Enabled"; + [BuiltInSetting("Whether Xeet posting is enabled", SettingValueType.Boolean, "true", BooleanRegex)] + public static string XeetEnabled = "Xeet.Enabled"; } }