using System.Net; using System.Text.Json; using Homoglyphic; using KfChatDotNetBot.Extensions; using KfChatDotNetBot.Models; using KfChatDotNetBot.Models.DbModels; using KfChatDotNetBot.Services; using KfChatDotNetBot.Settings; using KfChatDotNetWsClient; using KfChatDotNetWsClient.Models; using KfChatDotNetWsClient.Models.Events; using KfChatDotNetWsClient.Models.Json; using Microsoft.EntityFrameworkCore; using NLog; using Websocket.Client; namespace KfChatDotNetBot; public class ChatBot { internal readonly ChatClient KfClient; private readonly Logger _logger = LogManager.GetCurrentClassLogger(); // Oh no it's an ever expanding list that may never get cleaned up! // BUY MORE RAM private readonly List _seenMessages = []; // Suppresses the command handler on initial start, so it doesn't pick up things already handled on restart internal bool InitialStartCooldown = true; private readonly CancellationToken _cancellationToken = new(); private readonly BotCommands _botCommands; public readonly List SentMessages = []; internal bool GambaSeshPresent; internal readonly BotServices BotServices; private Task _kfChatPing; private KfTokenService _kfTokenService; private int _joinFailures = 0; private Task _kfDeadBotDetection; private DateTime _lastReconnectAttempt = DateTime.UtcNow; private List _scheduledDeletions = []; private Task _scheduledAutoDeleteTask; private List _currentUsersInChat = []; private HomoglyphSearch? _homoglyphSearch; public ChatBot() { _logger.Info("Bot starting!"); _logger.Debug("Starting services"); BotServices = new BotServices(this, _cancellationToken); BotServices.InitializeServices(); _kfDeadBotDetection = KfDeadBotDetectionTask(); var settings = SettingsProvider.GetMultipleValuesAsync([ BuiltIn.Keys.KiwiFarmsWsEndpoint, BuiltIn.Keys.KiwiFarmsDomain, BuiltIn.Keys.Proxy, BuiltIn.Keys.KiwiFarmsWsReconnectTimeout]).Result; _kfTokenService = new KfTokenService(settings[BuiltIn.Keys.KiwiFarmsDomain].Value!, settings[BuiltIn.Keys.Proxy].Value, _cancellationToken); if (_kfTokenService.GetCookies().Count == 0) { try { RefreshXfToken().Wait(_cancellationToken); } catch (Exception e) { _logger.Error("Caught an exception while trying to refresh the XF token"); _logger.Error(e); } } KfClient = new ChatClient(new ChatClientConfigModel { WsUri = new Uri(settings[BuiltIn.Keys.KiwiFarmsWsEndpoint].Value ?? throw new InvalidOperationException($"{BuiltIn.Keys.KiwiFarmsWsEndpoint} cannot be null")), Cookies = _kfTokenService.GetCookies(), CookieDomain = settings[BuiltIn.Keys.KiwiFarmsDomain].Value ?? throw new InvalidOperationException($"{BuiltIn.Keys.KiwiFarmsDomain} cannot be null"), Proxy = settings[BuiltIn.Keys.Proxy].Value, ReconnectTimeout = settings[BuiltIn.Keys.KiwiFarmsWsReconnectTimeout].ToType() }); _logger.Debug("Creating bot command instance"); _botCommands = new BotCommands(this, _cancellationToken); KfClient.OnMessages += OnKfChatMessage; KfClient.OnUsersParted += OnUsersParted; KfClient.OnUsersJoined += OnUsersJoined; KfClient.OnWsDisconnection += OnKfWsDisconnected; KfClient.OnWsReconnect += OnKfWsReconnected; KfClient.OnFailedToJoinRoom += OnFailedToJoinRoom; KfClient.OnMotd += OnMotd; KfClient.OnWhisper += OnWhisper; KfClient.StartWsClient().Wait(_cancellationToken); _logger.Debug("Creating ping task"); _kfChatPing = KfPingTask(); _logger.Debug("Creating scheduled auto deletion task"); _scheduledAutoDeleteTask = ScheduledDeletionTask(); _logger.Debug("Trying to load homoglyphs"); if (File.Exists("homoglyphs.csv")) { var sets = HomoglyphLoader.LoadSets("homoglyphs.csv"); _homoglyphSearch = new HomoglyphSearch(sets); } _logger.Debug("Blocking the main thread"); var exitEvent = new ManualResetEvent(false); exitEvent.WaitOne(); } private void OnMotd(object sender, MessageModel message) { SettingsProvider.SetValueAsync(BuiltIn.Keys.KiwiFarmsMotdUuid, message.MessageUuid).Wait(_cancellationToken); } private void OnFailedToJoinRoom(object sender, string message) { var failureLimit = SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsJoinFailLimit).Result.ToType(); _joinFailures++; _logger.Error($"Couldn't join the room, attempt {_joinFailures}. KF returned: {message}"); _logger.Error("This is likely due to the session cookie expiring. Retrieving a new one."); if (_joinFailures >= failureLimit) { _logger.Error("Seems we're in a rejoin loop. Wiping out cookies entirely in hopes it'll make this piece of shit work"); _kfTokenService.WipeCookies(); } try { RefreshXfToken().Wait(_cancellationToken); } catch (Exception e) { _logger.Error("Caught an exception while trying to refresh the XF token"); _logger.Error(e); } _logger.Info("Retrieved fresh token. Reconnecting."); KfClient.ReconnectAsync().Wait(_cancellationToken); _logger.Info("Client should be reconnecting now"); } private async Task KfPingTask() { var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsPingInterval)).ToType(); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(interval)); while (await timer.WaitForNextTickAsync(_cancellationToken)) { _logger.Debug("Pinging KF"); if (KfClient.IsConnected()) { KfClient.SendMessage("/ping"); } else { _logger.Info("Not pinging the connection as we're currently disconnected"); } var inactivityTime = DateTime.UtcNow - KfClient.LastPacketReceived; _logger.Debug($"Last KF event was {inactivityTime:g} ago"); var inactivityTimeout = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsInactivityTimeout)).ToType(); if (inactivityTime.TotalSeconds > inactivityTimeout) { // Yeah, super dodgy KfClient.LastPacketReceived = DateTime.UtcNow; _logger.Error("Forcing disconnect and restart as bot is completely dead"); await KfClient.ReconnectAsync(); } } } private async Task KfDeadBotDetectionTask() { var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotDeadBotDetectionInterval)).ToType(); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(interval)); while (await timer.WaitForNextTickAsync(_cancellationToken)) { var inactivityTime = DateTime.UtcNow - KfClient.LastPacketReceived; var deadTime = DateTime.UtcNow - _lastReconnectAttempt; // No connection and no successful reconnection attempt in the last 5 minutes // Either the site is completely dead or the bot got screwed by a nasty error and can't reconnect if (inactivityTime > TimeSpan.FromMinutes(10) && deadTime > TimeSpan.FromMinutes(15)) { var shouldExit = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotExitOnDeath)).ToBoolean(); _logger.Error("The bot as is dead beyond belief right now"); _logger.Error($"IsConnected() -> {KfClient.IsConnected()}"); _logger.Error($"inactivityTime -> {inactivityTime:g}"); _logger.Error($"deadTime -> {deadTime:g}"); if (shouldExit) Environment.Exit(1); _logger.Error("Since we didn't exit, let's try forcing a reconnect"); await KfClient.ReconnectAsync(); } } } private async Task ScheduledDeletionTask() { var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotScheduledDeletionInterval)).ToType(); using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(interval)); var failures = new Dictionary(); while (await timer.WaitForNextTickAsync(_cancellationToken)) { if (!KfClient.IsConnected()) { _logger.Debug("Not cleaning scheduled deletions up as we're disconnected"); continue; } var now = DateTimeOffset.UtcNow; var removals = new List(); foreach (var deletion in _scheduledDeletions) { if (deletion.DeleteAt > now) continue; if (deletion.Message.ChatMessageUuid == null) { _logger.Error($"Can't clean up {deletion.Message.Reference} as it doesn't have a chat message ID"); if (failures.TryGetValue(deletion.Message.Reference, out var failure)) { if (failure > 20) { removals.Add(deletion); _logger.Error($"Giving up on {deletion.Message.Reference} and removing it from the deletion queue"); continue; } failures[deletion.Message.Reference] += 1; } else { failures[deletion.Message.Reference] = 1; } continue; } await KfClient.DeleteMessageAsync(deletion.Message.ChatMessageUuid); removals.Add(deletion); } foreach (var removal in removals) { _scheduledDeletions.Remove(removal); } } } private async Task RefreshXfToken() { try { if (await _kfTokenService.IsLoggedIn()) { _logger.Info("We were already logged in and should have a fresh cookie for chat now"); _logger.Info("Updating cookies"); await _kfTokenService.SaveCookies(); KfClient.UpdateCookies(_kfTokenService.GetCookies()); // Only seems to happen if the bot thinks it's already logged in return; } } catch (Exception e) { _logger.Error("Caught an error when trying to retrieve a fresh cookie"); _logger.Error(e); return; } var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.KiwiFarmsUsername, BuiltIn.Keys.KiwiFarmsPassword]); try { await _kfTokenService.PerformLogin(settings[BuiltIn.Keys.KiwiFarmsUsername].Value!, settings[BuiltIn.Keys.KiwiFarmsPassword].Value!); } catch (Exception e) { _logger.Error("Caught an error when trying to login"); _logger.Error(e); return; } _logger.Info("Successfully logged in"); _logger.Info("Updating cookies"); await _kfTokenService.SaveCookies(); KfClient.UpdateCookies(_kfTokenService.GetCookies()); } private void OnWhisper(object sender, WhisperModel whisper) { var settings = SettingsProvider.GetMultipleValuesAsync([ BuiltIn.Keys.KiwiFarmsUsername, BuiltIn.Keys.BotDisconnectReplayLimit ]).Result; if (whisper.Author.Username == settings[BuiltIn.Keys.KiwiFarmsUsername].Value) { _logger.Debug("Ignoring my own whisper"); return; } var sentMsgMaybe = SentMessages.FirstOrDefault(msg => msg.Type == SentMessageType.Whisper && msg.WhisperMessage == whisper.MessageRawHtmlDecoded && msg.Status == SentMessageTrackerStatus.WaitingForResponse); sentMsgMaybe?.Status = SentMessageTrackerStatus.ResponseReceived; _logger.Debug("Passing message to command interface"); var botCommandsMsg = new BotCommandMessageModel { Author = whisper.Author, Recipient = whisper.Recipient, Message = whisper.Message, MessageDate = whisper.MessageDate, MessageEditDate = null, MessageRaw = whisper.MessageRaw, MessageRawHtmlDecoded = whisper.MessageRawHtmlDecoded, MessageUuid = null, RoomId = null, IsWhisper = true }; try { _botCommands.ProcessMessage(botCommandsMsg); } catch (Exception e) { _logger.Error("ProcessMessage threw an exception"); _logger.Error(e); } } private void OnKfChatMessage(object sender, List messages, MessagesJsonModel jsonPayload) { // Reset value to 0 as we've now successfully joined if (_joinFailures > 0) _joinFailures = 0; var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.GambaSeshDetectEnabled, BuiltIn.Keys.GambaSeshUserId, BuiltIn.Keys.KiwiFarmsUsername, BuiltIn.Keys.BotDisconnectReplayLimit, BuiltIn.Keys.BotRespondToDiscordImpersonation]) .Result; // Send messages if there are any to replay (Assuming we DC'd, and it's now the message flood) foreach (var replayMsg in SentMessages.Where(msg => msg.Status == SentMessageTrackerStatus.ChatDisconnected) .TakeLast(settings[BuiltIn.Keys.BotDisconnectReplayLimit].ToType())) { // Bypass the helpful method we have for sending messages so we don't create new sent message items for them // The validation of whether to send based on GambaSesh's presence etc. has already been performed for msgs here KfClient.SendMessage(replayMsg.Message); replayMsg.Status = SentMessageTrackerStatus.WaitingForResponse; replayMsg.SentAt = DateTimeOffset.UtcNow; } foreach(var lostMsg in SentMessages.Where(msg => msg.Status == SentMessageTrackerStatus.ChatDisconnected)) { lostMsg.Status = SentMessageTrackerStatus.Lost; } _logger.Debug($"Received {messages.Count} message(s)"); foreach (var message in messages) { if (message.MessageEditDate == null) { _logger.Info($"KF ({message.MessageDate.ToLocalTime():HH:mm:ss}) <{message.Author.Username}> {message.Message}"); } // Update last edit timestamp if (message.Author.Username == settings[BuiltIn.Keys.KiwiFarmsUsername].Value && message.MessageEditDate != null) { var sentMessage = SentMessages.FirstOrDefault(x => x.ChatMessageUuid == message.MessageUuid); if (sentMessage != null) { sentMessage.LastEdited = message.MessageEditDate.Value; } } if (message.Author.Username == settings[BuiltIn.Keys.KiwiFarmsUsername].Value && message.MessageEditDate == null) { // MessageRaw is not actually REAL and RAW. The messages are still HTML encoded var decodedMessage = WebUtility.HtmlDecode(message.MessageRaw); var sentMessage = SentMessages.FirstOrDefault(sent => sent.Message == decodedMessage && sent is { Status: SentMessageTrackerStatus.WaitingForResponse, Type: SentMessageType.ChatMessage }); if (sentMessage == null) { _logger.Error("Received message from Sneedchat that I sent but have no idea about. Message Data Follows:"); _logger.Error(JsonSerializer.Serialize(message)); _logger.Error("Last item inserted into the sent messages collection waiting for response:"); var latest = SentMessages.LastOrDefault(msg => msg is { Status: SentMessageTrackerStatus.WaitingForResponse, Type: SentMessageType.ChatMessage }); _logger.Error(JsonSerializer.Serialize(latest)); if (latest != null) { // Generally when you msg Sneedchat, the next message you get in response is your message echoed // back to you. So this fallback should be generally correct and will account for the occasional // mismatch due to messages not being 1:1 with what we thought we sent _logger.Info("Just going to lazily associate it with the latest message"); latest.ChatMessageUuid = message.MessageUuid; latest.Delay = DateTimeOffset.UtcNow - latest.SentAt; latest.Status = SentMessageTrackerStatus.ResponseReceived; } } else { sentMessage.ChatMessageUuid = message.MessageUuid; sentMessage.Delay = DateTimeOffset.UtcNow - sentMessage.SentAt; sentMessage.Status = SentMessageTrackerStatus.ResponseReceived; } } if (message.Author.Id == settings[BuiltIn.Keys.GambaSeshUserId].ToType() && BotServices.TemporarilyBypassGambaSeshForDiscord && message.MessageRaw.Contains("discord16")) { _logger.Info("GambaSesh fixed itself, turning off bypass"); BotServices.TemporarilyBypassGambaSeshForDiscord = false; } if (settings[BuiltIn.Keys.GambaSeshDetectEnabled].ToBoolean() && !InitialStartCooldown && message.Author.Id == settings[BuiltIn.Keys.GambaSeshUserId].ToType() && !GambaSeshPresent) { _logger.Info("Received a GambaSesh message after cooldown and while thinking he's not here. Setting the presence flag to avoid spamming chat"); GambaSeshPresent = true; } // Basically the bot will ignore the message if it has been seen before and its edit time is the same // So this avoids reprocessing messages on reconnect while being able to handle edits, even if the edit came // during a disconnect / reconnect event if (!_seenMessages.Any(msg => msg.MessageUuid == message.MessageUuid && msg.LastEdited == message.MessageEditDate) && !InitialStartCooldown) { _logger.Debug("Passing message to command interface"); var botCommandsMsg = new BotCommandMessageModel { Author = message.Author, MessageRaw = message.MessageRaw, Message = message.Message, MessageDate = message.MessageDate, MessageEditDate = message.MessageEditDate, MessageRawHtmlDecoded = message.MessageRawHtmlDecoded, MessageUuid = message.MessageUuid, Recipient = null, RoomId = message.RoomId, IsWhisper = false }; try { _botCommands.ProcessMessage(botCommandsMsg); } catch (Exception e) { _logger.Error("ProcessMessage threw an exception"); _logger.Error(e); } } // Update or add the element to keep it in sync var existingMsg = _seenMessages.FirstOrDefault(msg => msg.MessageUuid == message.MessageUuid); if (existingMsg != null) { existingMsg.LastEdited = message.MessageEditDate; } else { _seenMessages.Add(new SeenMessageMetadataModel {MessageUuid = message.MessageUuid, LastEdited = message.MessageEditDate}); } UpdateUserLastActivityAsync(message.Author.Id, WhoWasActivityType.Message).Wait(_cancellationToken); // Strip weird control characters and just allow basic punctuation + whitespace var kindaSanitized = new string(message.MessageRawHtmlDecoded .Where(c => c == ' ' || char.IsPunctuation(c) || char.IsLetter(c) || char.IsDigit(c)).ToArray()); var homoglyphFound = false; if (_homoglyphSearch != null) { var searchStrings = SettingsProvider.GetValueAsync(BuiltIn.Keys.BotDiscordImpersonationSearchStrings).Result .JsonDeserialize>(); var lowerStrings = searchStrings?.Select(x => x.ToLower()).ToList(); var search = _homoglyphSearch.Search(kindaSanitized.ToLower(), lowerStrings); if (search.Count == 0) { search = _homoglyphSearch.Search(kindaSanitized, searchStrings); } homoglyphFound = search.Count > 0; } if ((message.MessageEditDate == null || message.MessageDate > DateTimeOffset.UtcNow.AddSeconds(-15)) && message.Author.Id != settings[BuiltIn.Keys.GambaSeshUserId].ToType() && message.Author.Username != settings[BuiltIn.Keys.KiwiFarmsUsername].Value && settings[BuiltIn.Keys.BotRespondToDiscordImpersonation].ToBoolean() && kindaSanitized.Contains("[img]") && homoglyphFound) { var deleteOrNah = SettingsProvider.GetValueAsync(BuiltIn.Keys.BotDiscordImpersonationDeleteAttempt) .Result.ToBoolean(); if (deleteOrNah) { _ = KfClient.DeleteMessageAsync(message.MessageUuid); } else { SendChatMessage($"☝️ {message.Author.Username} is a nigger faggot", true); } } } if (InitialStartCooldown) InitialStartCooldown = false; } // Reference for Sneedchat hardcoded length limit // https://github.com/jaw-sh/ruforo/blob/master/src/web/chat/connection.rs#L226 /// /// Async method for sending a chat message /// /// The message you wish to send /// Whether to bypass detecting if GambaSesh is present and send unconditionally /// What behavior to use when encountering a message that exceeds the length limit /// Length limit to enforce in bytes /// Length of time until the message is auto deleted, null to disable. Starts counting from when the message is echoed by Sneedchat /// An object you can use to check the status of the message and get its ID for editing/deleting later public async Task SendChatMessageAsync(string message, bool bypassSeshDetect = false, LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.TruncateNicely, int lengthLimit = 2048, TimeSpan? autoDeleteAfter = null) { var settings = await SettingsProvider .GetMultipleValuesAsync([ BuiltIn.Keys.KiwiFarmsSuppressChatMessages, BuiltIn.Keys.GambaSeshDetectEnabled ]); var reference = Guid.NewGuid().ToString(); var messageTracker = new SentMessageTrackerModel { Reference = reference, Message = message.TrimEnd(), // Sneedchat trims trailing spaces Status = SentMessageTrackerStatus.Unknown, Type = SentMessageType.ChatMessage, WhisperMessage = null }; if (settings[BuiltIn.Keys.KiwiFarmsSuppressChatMessages].ToBoolean()) { _logger.Info("Not sending message as SuppressChatMessages is enabled"); _logger.Info($"Message was: {message}"); messageTracker.Status = SentMessageTrackerStatus.NotSending; SentMessages.Add(messageTracker); return messageTracker; } if (GambaSeshPresent && settings[BuiltIn.Keys.GambaSeshDetectEnabled].ToBoolean() && !bypassSeshDetect) { _logger.Info($"Not sending message '{message}' as GambaSesh is present"); messageTracker.Status = SentMessageTrackerStatus.NotSending; SentMessages.Add(messageTracker); return messageTracker; } if (!KfClient.IsConnected()) { _logger.Info($"Not sending message '{message}' as Sneedchat is not connected"); messageTracker.Status = SentMessageTrackerStatus.ChatDisconnected; SentMessages.Add(messageTracker); return messageTracker; } if (messageTracker.Message.Utf8LengthBytes() > lengthLimit && lengthLimitBehavior != LengthLimitBehavior.DoNothing) { if (lengthLimitBehavior == LengthLimitBehavior.RefuseToSend) { _logger.Info("Refusing to send message as it exceeds the length limit and LengthLimitBehavior is RefuseToSend"); messageTracker.Status = SentMessageTrackerStatus.NotSending; SentMessages.Add(messageTracker); return messageTracker; } if (lengthLimitBehavior == LengthLimitBehavior.TruncateNicely) { // '…' is 3 bytes so we have to make room for it messageTracker.Message = messageTracker.Message.TruncateBytes(lengthLimit - 3).TrimEnd() + "…"; } if (lengthLimitBehavior == LengthLimitBehavior.TruncateExactly) { // TrimEnd in case you end up truncating on a space (happened during testing) as Sneedchat will trim it messageTracker.Message = messageTracker.Message.TruncateBytes(lengthLimit).TrimEnd(); } } messageTracker.Status = SentMessageTrackerStatus.WaitingForResponse; messageTracker.SentAt = DateTimeOffset.UtcNow; _logger.Debug($"Message is {messageTracker.Message.Utf8LengthBytes()} bytes"); SentMessages.Add(messageTracker); await KfClient.SendMessageInstantAsync(messageTracker.Message); if (autoDeleteAfter != null) { ScheduleMessageAutoDelete(messageTracker, autoDeleteAfter.Value); } return messageTracker; } // Reference for Sneedchat hardcoded length limit // https://github.com/jaw-sh/ruforo/blob/master/src/web/chat/connection.rs#L226 /// /// Async method for sending a whisper /// /// Kiwi Farms user ID of the recipient for this whisper /// The message you wish to whisper /// What behavior to use when encountering a message that exceeds the length limit /// Length limit to enforce in bytes /// An object you can use to check the status of the message public async Task SendWhisperAsync(int recipient, string message, LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.TruncateNicely, int lengthLimit = 2048) { var settings = await SettingsProvider .GetMultipleValuesAsync([ BuiltIn.Keys.KiwiFarmsSuppressChatMessages ]); var originalMessage = message; message = $"/w {recipient} {message}"; var reference = Guid.NewGuid().ToString(); var messageTracker = new SentMessageTrackerModel { Reference = reference, Message = message.TrimEnd(), // Sneedchat trims trailing spaces Status = SentMessageTrackerStatus.Unknown, Type = SentMessageType.Whisper, WhisperMessage = originalMessage.TrimEnd() }; if (settings[BuiltIn.Keys.KiwiFarmsSuppressChatMessages].ToBoolean()) { _logger.Info("Not sending message as SuppressChatMessages is enabled"); _logger.Info($"Message was: {message}"); messageTracker.Status = SentMessageTrackerStatus.NotSending; SentMessages.Add(messageTracker); return messageTracker; } if (!KfClient.IsConnected()) { _logger.Info($"Not sending message '{message}' as Sneedchat is not connected"); messageTracker.Status = SentMessageTrackerStatus.ChatDisconnected; SentMessages.Add(messageTracker); return messageTracker; } if (messageTracker.Message.Utf8LengthBytes() > lengthLimit && lengthLimitBehavior != LengthLimitBehavior.DoNothing) { if (lengthLimitBehavior == LengthLimitBehavior.RefuseToSend) { _logger.Info("Refusing to send message as it exceeds the length limit and LengthLimitBehavior is RefuseToSend"); messageTracker.Status = SentMessageTrackerStatus.NotSending; SentMessages.Add(messageTracker); return messageTracker; } if (lengthLimitBehavior == LengthLimitBehavior.TruncateNicely) { // '…' is 3 bytes so we have to make room for it messageTracker.Message = messageTracker.Message.TruncateBytes(lengthLimit - 3).TrimEnd() + "…"; } if (lengthLimitBehavior == LengthLimitBehavior.TruncateExactly) { // TrimEnd in case you end up truncating on a space (happened during testing) as Sneedchat will trim it messageTracker.Message = messageTracker.Message.TruncateBytes(lengthLimit).TrimEnd(); } } messageTracker.Status = SentMessageTrackerStatus.WaitingForResponse; messageTracker.SentAt = DateTimeOffset.UtcNow; _logger.Debug($"Message is {messageTracker.Message.Utf8LengthBytes()} bytes"); SentMessages.Add(messageTracker); await KfClient.SendMessageInstantAsync(messageTracker.Message); return messageTracker; } /// /// Exposes the private task used to delete messages based on a TimeSpan in case you want to use it on-demand /// e.g. for cleaning up a gambling message only after the game has finished /// /// The message you want to delete /// When you want it deleted public void ScheduleMessageAutoDelete(SentMessageTrackerModel message, TimeSpan deleteAfter) { _scheduledDeletions.Add(new ScheduledAutoDeleteModel { Message = message, DeleteAt = DateTimeOffset.UtcNow.Add(deleteAfter) }); } /// /// Non-async method which wraps the async method for sending a chat message /// /// The message you wish to send /// Whether to bypass detecting if GambaSesh is present and send unconditionally /// What behavior to use when encountering a message that exceeds the length limit /// Length limit to enforce in bytes /// Length of time until the message is auto deleted, null to disable. Starts counting from when the message is echoed by Sneedchat /// An object you can use to check the status of the message and get its ID for editing/deleting later public SentMessageTrackerModel SendChatMessage(string message, bool bypassSeshDetect = false, LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.TruncateNicely, int lengthLimit = 2048, TimeSpan? autoDeleteAfter = null) { return SendChatMessageAsync(message, bypassSeshDetect, lengthLimitBehavior, lengthLimit, autoDeleteAfter).Result; } public async Task> SendChatMessagesAsync(List messages, bool bypassSeshDetect = false, LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.RefuseToSend, TimeSpan? autoDeleteAfter = null) { List sentMessages = []; foreach (var message in messages) { sentMessages.Add(await SendChatMessageAsync(message, bypassSeshDetect, lengthLimitBehavior, autoDeleteAfter: autoDeleteAfter)); // Delay sending each message, hopefully this will help the issue where messages come out of order await Task.Delay(TimeSpan.FromMilliseconds(100), _cancellationToken); } return sentMessages; } public SentMessageTrackerModel GetSentMessageStatus(string reference) { var message = SentMessages.FirstOrDefault(m => m.Reference == reference); if (message == null) { throw new SentMessageNotFoundException(); } return message; } /// /// Wait for a chat message to be successfully delivered or not /// /// Reference to the message you're waiting for /// How long to wait /// Cancellation token /// True if the message was echoed, false otherwise public async Task WaitForChatMessageAsync(SentMessageTrackerModel message, TimeSpan? patience = null, CancellationToken ct = default) { if (patience == null) { patience = TimeSpan.FromSeconds(60); } var patienceEnds = DateTimeOffset.UtcNow.Add(patience.Value); while (message.ChatMessageUuid == null) { if (DateTimeOffset.UtcNow > patienceEnds) return false; if (message.Status is SentMessageTrackerStatus.Lost or SentMessageTrackerStatus.NotSending) return false; await Task.Delay(100, ct); } return true; } public class SentMessageNotFoundException : Exception; private void OnUsersJoined(object sender, List users, UsersJsonModel jsonPayload) { var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.GambaSeshUserId, BuiltIn.Keys.GambaSeshDetectEnabled, BuiltIn.Keys.BotKeesSeen]) .Result; _logger.Debug($"Received {users.Count} user join events"); _currentUsersInChat.AddRange(users); using var db = new ApplicationDbContext(); foreach (var user in users) { if (user.Id == settings[BuiltIn.Keys.GambaSeshUserId].ToType() && settings[BuiltIn.Keys.GambaSeshDetectEnabled].ToBoolean()) { _logger.Info("GambaSesh is now present"); GambaSeshPresent = true; } if (user.Id == 89776 && !settings[BuiltIn.Keys.BotKeesSeen].ToBoolean()) { _logger.Info("Kees has joined!"); SendChatMessage($":!: :!: {user.Username} has appeared! :!: :!:", true); SettingsProvider.SetValueAsBooleanAsync(BuiltIn.Keys.BotKeesSeen, true).Wait(_cancellationToken); } _logger.Info($"{user.Username} joined!"); var userDb = db.Users.FirstOrDefault(u => u.KfId == user.Id); if (userDb == null) { db.Users.Add(new UserDbModel { KfId = user.Id, KfUsername = user.Username }); _logger.Debug("Adding user to DB"); // Immediately add to DB so we can populate activity db.SaveChanges(); UpdateUserLastActivityAsync(user.Id, WhoWasActivityType.Join).Wait(_cancellationToken); continue; } // Detect a username change if (userDb.KfUsername != user.Username) { _logger.Debug("Username has updated, updating DB"); userDb.KfUsername = user.Username; } UpdateUserLastActivityAsync(user.Id, WhoWasActivityType.Join).Wait(_cancellationToken); } db.SaveChanges(); } private void OnUsersParted(object sender, List userIds) { _currentUsersInChat.RemoveAll(u => userIds.Contains(u.Id)); var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.GambaSeshUserId, BuiltIn.Keys.GambaSeshDetectEnabled]) .Result; if (userIds.Contains(settings[BuiltIn.Keys.GambaSeshUserId].ToType()) && settings[BuiltIn.Keys.GambaSeshDetectEnabled].ToBoolean()) { _logger.Info("GambaSesh is no longer present"); GambaSeshPresent = false; } foreach (var user in userIds) { UpdateUserLastActivityAsync(user, WhoWasActivityType.Part).Wait(_cancellationToken); } } private async Task UpdateUserLastActivityAsync(int kfId, WhoWasActivityType type) { await using var db = new ApplicationDbContext(); var user = await db.Users.FirstOrDefaultAsync(u => u.KfId == kfId, _cancellationToken); if (user == null) { _logger.Error($"Failed to find user with KfId = {kfId} for the purposes of updating their last activity"); return; } var activity = await db.UsersWhoWere.FirstOrDefaultAsync(u => u.User == user && u.ActivityType == type, _cancellationToken); if (activity == null) { await db.UsersWhoWere.AddAsync(new UserWhoWasDbModel { User = user, FirstOccurence = DateTimeOffset.UtcNow, ActivityType = type, LatestOccurence = DateTimeOffset.UtcNow }, _cancellationToken); await db.SaveChangesAsync(_cancellationToken); return; } activity.LatestOccurence = DateTimeOffset.UtcNow; await db.SaveChangesAsync(_cancellationToken); } private void OnKfWsDisconnected(object sender, DisconnectionInfo disconnectionInfo) { _logger.Error($"Sneedchat disconnected due to {disconnectionInfo.Type}"); _logger.Error($"Close Status => {disconnectionInfo.CloseStatus}; Close Status Description => {disconnectionInfo.CloseStatusDescription}"); _logger.Error(disconnectionInfo.Exception); _currentUsersInChat.Clear(); if (disconnectionInfo.Exception != null && disconnectionInfo.Exception.Message.Contains("status code '203'")) { _logger.Info("Chat 203'd, getting a new token"); RefreshXfToken().Wait(_cancellationToken); _logger.Info("Reconnecting"); KfClient.ReconnectAsync().Wait(_cancellationToken); } } private void OnKfWsReconnected(object sender, ReconnectionInfo reconnectionInfo) { _lastReconnectAttempt = DateTime.UtcNow; var roomId = SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsRoomId).Result.ToType(); _logger.Error($"Sneedchat reconnected due to {reconnectionInfo.Type}"); _logger.Info("Resetting GambaSesh presence so it can resync if he crashed while the bot was DC'd"); GambaSeshPresent = false; _logger.Info($"Rejoining {roomId}"); KfClient.JoinRoom(roomId); } public UserModel? FindUserByName(string username) { return _currentUsersInChat.FirstOrDefault(u => u.Username.Equals(username, StringComparison.CurrentCulture)); } public enum LengthLimitBehavior { // Append … TruncateNicely, // Truncate regardless of whether it's mid-word and don't add a ... TruncateExactly, // Set status to NotSending RefuseToSend, // Try to send the message anyway, even though Sneedchat will just silently eat it DoNothing } private class ScheduledAutoDeleteModel { public required SentMessageTrackerModel Message { get; set; } public required DateTimeOffset DeleteAt { get; set; } } /// /// Thin wrapper to decide whether to whisper or chat message respond /// /// The original message you received (so I know if it was a whisper) /// Message you want to send /// Whether to bypass gambasesh (not applicable for whispers) /// Whether to auto delete after a period of time (not applicable to whispers) /// What behavior to use for messages which exceed the length limit /// public async Task ReplyToUser(BotCommandMessageModel origMsg, string response, bool bypassGambaSesh = false, TimeSpan? autoDeleteAfter = null, LengthLimitBehavior lengthLimitBehavior = ChatBot.LengthLimitBehavior.TruncateNicely) { if (origMsg.IsWhisper) { return await SendWhisperAsync(origMsg.Author.Id, response, lengthLimitBehavior: lengthLimitBehavior); } return await SendChatMessageAsync(response, bypassGambaSesh, lengthLimitBehavior, autoDeleteAfter: autoDeleteAfter); } }