mirror of
https://github.com/barelyprofessional/KfChatDotNet.git
synced 2026-06-15 16:55:18 -04:00
80916c9b2d
Future plan is to basically disable automatic reconnection in the WS library and use the fast fail functionality instead since it just causes more problems than it's worth.
925 lines
43 KiB
C#
925 lines
43 KiB
C#
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<SeenMessageMetadataModel> _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<SentMessageTrackerModel> 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<ScheduledAutoDeleteModel> _scheduledDeletions = [];
|
|
private Task _scheduledAutoDeleteTask;
|
|
private List<UserModel> _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.KiwiFarmsProxy, BuiltIn.Keys.KiwiFarmsWsReconnectTimeout]).Result;
|
|
|
|
_kfTokenService = new KfTokenService(settings[BuiltIn.Keys.KiwiFarmsDomain].Value!,
|
|
settings[BuiltIn.Keys.KiwiFarmsProxy].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.KiwiFarmsProxy].Value,
|
|
ReconnectTimeout = settings[BuiltIn.Keys.KiwiFarmsWsReconnectTimeout].ToType<int>()
|
|
});
|
|
|
|
_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<int>();
|
|
_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<int>();
|
|
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;
|
|
var lastReconnect = DateTime.UtcNow - _lastReconnectAttempt;
|
|
|
|
_logger.Debug($"Last KF event was {inactivityTime:g} ago");
|
|
var inactivityTimeout = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsInactivityTimeout)).ToType<int>();
|
|
if (inactivityTime.TotalSeconds > inactivityTimeout && lastReconnect.TotalMinutes > 1)
|
|
{
|
|
_lastReconnectAttempt = DateTime.UtcNow;
|
|
_logger.Error("Forcing reconnect as bot is completely dead");
|
|
await KfClient.ReconnectAsync();
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task KfDeadBotDetectionTask()
|
|
{
|
|
var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotDeadBotDetectionInterval)).ToType<int>();
|
|
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<int>();
|
|
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(interval));
|
|
var failures = new Dictionary<string, int>();
|
|
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<ScheduledAutoDeleteModel>();
|
|
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<MessageModel> 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<int>()))
|
|
{
|
|
// 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<int>() && 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<int>() && !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<List<string>>();
|
|
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<int>() &&
|
|
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
|
|
/// <summary>
|
|
/// Async method for sending a chat message
|
|
/// </summary>
|
|
/// <param name="message">The message you wish to send</param>
|
|
/// <param name="bypassSeshDetect">Whether to bypass detecting if GambaSesh is present and send unconditionally</param>
|
|
/// <param name="lengthLimitBehavior">What behavior to use when encountering a message that exceeds the length limit</param>
|
|
/// <param name="lengthLimit">Length limit to enforce in bytes</param>
|
|
/// <param name="autoDeleteAfter">Length of time until the message is auto deleted, null to disable. Starts counting from when the message is echoed by Sneedchat</param>
|
|
/// <returns>An object you can use to check the status of the message and get its ID for editing/deleting later</returns>
|
|
public async Task<SentMessageTrackerModel> 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
|
|
/// <summary>
|
|
/// Async method for sending a whisper
|
|
/// </summary>
|
|
/// <param name="recipient">Kiwi Farms user ID of the recipient for this whisper</param>
|
|
/// <param name="message">The message you wish to whisper</param>
|
|
/// <param name="lengthLimitBehavior">What behavior to use when encountering a message that exceeds the length limit</param>
|
|
/// <param name="lengthLimit">Length limit to enforce in bytes</param>
|
|
/// <returns>An object you can use to check the status of the message</returns>
|
|
public async Task<SentMessageTrackerModel> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
/// <param name="message">The message you want to delete</param>
|
|
/// <param name="deleteAfter">When you want it deleted</param>
|
|
public void ScheduleMessageAutoDelete(SentMessageTrackerModel message, TimeSpan deleteAfter)
|
|
{
|
|
_scheduledDeletions.Add(new ScheduledAutoDeleteModel
|
|
{
|
|
Message = message,
|
|
DeleteAt = DateTimeOffset.UtcNow.Add(deleteAfter)
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
/// <param name="messageUuid">The message you want to delete where you only have a message UUID
|
|
/// NOTE: The bot doesn't check against its sent message tracker, so you can use this with messages
|
|
/// the bot was not responsible for sending or were lost due to a restart.</param>
|
|
/// <param name="deleteAfter">When you want it deleted</param>
|
|
public void ScheduleMessageAutoDelete(string messageUuid, TimeSpan deleteAfter)
|
|
{
|
|
_scheduledDeletions.Add(new ScheduledAutoDeleteModel
|
|
{
|
|
Message = new SentMessageTrackerModel
|
|
{
|
|
ChatMessageUuid = messageUuid,
|
|
Delay = TimeSpan.Zero,
|
|
LastEdited = DateTimeOffset.UtcNow,
|
|
Message = "placeholder because I'm nigger rigging this shit big time",
|
|
Reference = Guid.NewGuid().ToString(),
|
|
SentAt = DateTimeOffset.UtcNow,
|
|
Status = SentMessageTrackerStatus.ResponseReceived,
|
|
Type = SentMessageType.ChatMessage
|
|
},
|
|
DeleteAt = DateTimeOffset.UtcNow.Add(deleteAfter)
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Non-async method which wraps the async method for sending a chat message
|
|
/// </summary>
|
|
/// <param name="message">The message you wish to send</param>
|
|
/// <param name="bypassSeshDetect">Whether to bypass detecting if GambaSesh is present and send unconditionally</param>
|
|
/// <param name="lengthLimitBehavior">What behavior to use when encountering a message that exceeds the length limit</param>
|
|
/// <param name="lengthLimit">Length limit to enforce in bytes</param>
|
|
/// <param name="autoDeleteAfter">Length of time until the message is auto deleted, null to disable. Starts counting from when the message is echoed by Sneedchat</param>
|
|
/// <returns>An object you can use to check the status of the message and get its ID for editing/deleting later</returns>
|
|
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<List<SentMessageTrackerModel>> SendChatMessagesAsync(List<string> messages,
|
|
bool bypassSeshDetect = false, LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.RefuseToSend, TimeSpan? autoDeleteAfter = null)
|
|
{
|
|
List<SentMessageTrackerModel> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wait for a chat message to be successfully delivered or not
|
|
/// </summary>
|
|
/// <param name="message">Reference to the message you're waiting for</param>
|
|
/// <param name="patience">How long to wait</param>
|
|
/// <param name="ct">Cancellation token</param>
|
|
/// <returns>True if the message was echoed, false otherwise</returns>
|
|
public async Task<bool> 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<UserModel> 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<int>() && 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<int> 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<int>()) && 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);
|
|
}
|
|
|
|
if (disconnectionInfo.Exception is TaskCanceledException)
|
|
{
|
|
_logger.Error("WebSocket client is broken as the cancellation token it held onto is FUCKING DEAD. Going to dispose and restart the WS client");
|
|
KfClient.DisposeWsClient();
|
|
KfClient.StartWsClient().Wait(_cancellationToken);
|
|
}
|
|
}
|
|
|
|
private void OnKfWsReconnected(object sender, ReconnectionInfo reconnectionInfo)
|
|
{
|
|
_lastReconnectAttempt = DateTime.UtcNow;
|
|
var roomId = SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsRoomId).Result.ToType<int>();
|
|
_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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Thin wrapper to decide whether to whisper or chat message respond
|
|
/// </summary>
|
|
/// <param name="origMsg">The original message you received (so I know if it was a whisper)</param>
|
|
/// <param name="response">Message you want to send</param>
|
|
/// <param name="bypassGambaSesh">Whether to bypass gambasesh (not applicable for whispers)</param>
|
|
/// <param name="autoDeleteAfter">Whether to auto delete after a period of time (not applicable to whispers)</param>
|
|
/// <param name="lengthLimitBehavior">What behavior to use for messages which exceed the length limit</param>
|
|
/// <returns></returns>
|
|
public async Task<SentMessageTrackerModel> 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);
|
|
}
|
|
} |