From 936bf743a51b49012344e34fc0d764c0251e4067 Mon Sep 17 00:00:00 2001 From: barelyprofessional <150058423+barelyprofessional@users.noreply.github.com> Date: Mon, 1 Jul 2024 00:24:07 +0800 Subject: [PATCH] Added Twitch IRC over Websocket support --- KfChatDotNetKickBot/KickBot.cs | 37 ++++- KfChatDotNetKickBot/Models/ConfigModel.cs | 1 + KfChatDotNetKickBot/Services/TwitchChat.cs | 179 +++++++++++++++++++++ 3 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 KfChatDotNetKickBot/Services/TwitchChat.cs diff --git a/KfChatDotNetKickBot/KickBot.cs b/KfChatDotNetKickBot/KickBot.cs index 345f239..bc8992a 100644 --- a/KfChatDotNetKickBot/KickBot.cs +++ b/KfChatDotNetKickBot/KickBot.cs @@ -30,6 +30,7 @@ public class KickBot private readonly Twitch _twitch; private Shuffle _shuffle; private DiscordService _discord; + private TwitchChat _twitchChat; private string? _lastDiscordStatus; private bool _isBmjLive = false; private bool _isBmjLiveSynced = false; @@ -101,6 +102,7 @@ public class KickBot BuildShuffle(); BuildDiscord(); + BuildTwitchChat(); _logger.Debug("Blocking the main thread"); var exitEvent = new ManualResetEvent(false); @@ -124,7 +126,7 @@ public class KickBot _logger.Info("Not building Discord as the token is not configured"); return; } - _discord = new DiscordService(_config.DiscordToken, _config.Proxy); + _discord = new DiscordService(_config.DiscordToken, _config.Proxy, _cancellationToken); _discord.OnInvalidCredentials += DiscordOnInvalidCredentials; _discord.OnWsDisconnection += DiscordOnWsDisconnection; _discord.OnMessageReceived += DiscordOnMessageReceived; @@ -132,6 +134,39 @@ public class KickBot _discord.StartWsClient().Wait(_cancellationToken); } + private void BuildTwitchChat() + { + _logger.Debug("Building Twitch Chat"); + if (_config.BossmanJackTwitchUsername == null) + { + _logger.Info("Not building Twitch Chat client as BMJ's username is not configured"); + return; + } + + _twitchChat = new TwitchChat($"#{_config.BossmanJackTwitchUsername}", _config.Proxy, _cancellationToken); + _twitchChat.OnMessageReceived += TwitchChatOnMessageReceived; + _twitchChat.OnWsDisconnection += TwitchChatOnWsDisconnection; + _twitchChat.StartWsClient().Wait(_cancellationToken); + } + + private void TwitchChatOnWsDisconnection(object sender, DisconnectionInfo e) + { + if (e.Type == DisconnectionType.ByServer) + { + _twitchChat.Dispose(); + BuildTwitchChat(); + } + } + + private void TwitchChatOnMessageReceived(object sender, string nick, string target, string message) + { + if (nick != _config.BossmanJackTwitchUsername) + { + return; + } + _sendChatMessage($"[img]https://i.postimg.cc/QMFVV2Xk/twitch16.png[/img] {nick}: {message}", true); + } + private void DiscordOnPresenceUpdated(object sender, DiscordPresenceUpdateModel presence) { if (presence.User.Id != _config.DiscordBmjId) diff --git a/KfChatDotNetKickBot/Models/ConfigModel.cs b/KfChatDotNetKickBot/Models/ConfigModel.cs index fbac413..63d4710 100644 --- a/KfChatDotNetKickBot/Models/ConfigModel.cs +++ b/KfChatDotNetKickBot/Models/ConfigModel.cs @@ -21,6 +21,7 @@ public class ConfigModel public required string KfPassword { get; set; } public string ChromiumPath { get; set; } = "chromium_install"; public int? BossmanJackTwitchId { get; set; } = null; + public string? BossmanJackTwitchUsername { get; set; } = "thebossmanjack"; // Used for testing public bool SuppressChatMessages { get; set; } = false; public string? DiscordToken { get; set; } = null; diff --git a/KfChatDotNetKickBot/Services/TwitchChat.cs b/KfChatDotNetKickBot/Services/TwitchChat.cs new file mode 100644 index 0000000..96c23b7 --- /dev/null +++ b/KfChatDotNetKickBot/Services/TwitchChat.cs @@ -0,0 +1,179 @@ +using System.Net; +using System.Net.WebSockets; +using System.Text.Json; +using System.Text.RegularExpressions; +using NLog; +using Websocket.Client; + +namespace KfChatDotNetKickBot.Services; + +public class TwitchChat : IDisposable +{ + private readonly Logger _logger = LogManager.GetCurrentClassLogger(); + private WebsocketClient _wsClient; + private readonly Uri _wsUri = new("wss://irc-ws.chat.twitch.tv/"); + private const int ReconnectTimeout = 600; + private readonly string? _proxy; + private readonly string _channel; + private readonly string _nick; + + public delegate void MessageReceivedEventHandler(object sender, string nick, string target, string message); + public delegate void WsDisconnectionEventHandler(object sender, DisconnectionInfo e); + public event MessageReceivedEventHandler OnMessageReceived; + public event WsDisconnectionEventHandler OnWsDisconnection; + + private readonly CancellationToken _cancellationToken = CancellationToken.None; + + public TwitchChat(string channel, string? proxy = null, CancellationToken? cancellationToken = null) + { + _proxy = proxy; + if (cancellationToken != null) _cancellationToken = cancellationToken.Value; + _channel = channel; + var justinFan = new Random().Next(10000, 99999); + _nick = $"justinfan{justinFan}"; + _logger.Debug($"Using nick {_nick}"); + _logger.Info("Twitch Chat Service created"); + } + + public async Task StartWsClient() + { + _logger.Debug("StartWsClient() called, creating client"); + await CreateWsClient(); + } + + private async Task CreateWsClient() + { + var factory = new Func(() => + { + var clientWs = new ClientWebSocket(); + if (_proxy == null) return clientWs; + _logger.Debug($"Using proxy address {_proxy}"); + clientWs.Options.Proxy = new WebProxy(_proxy); + return clientWs; + }); + + var client = new WebsocketClient(_wsUri, factory) + { + ReconnectTimeout = TimeSpan.FromSeconds(ReconnectTimeout) + }; + + client.ReconnectionHappened.Subscribe(WsReconnection); + client.MessageReceived.Subscribe(WsMessageReceived); + client.DisconnectionHappened.Subscribe(WsDisconnection); + + _wsClient = client; + + _logger.Debug("Websocket client has been built, about to start"); + await client.Start(); + _logger.Debug("Websocket client started!"); + } + + public bool IsConnected() + { + return _wsClient is { IsRunning: true }; + } + + private void WsDisconnection(DisconnectionInfo disconnectionInfo) + { + _logger.Error($"Client disconnected from Discord (or never successfully connected). Type is {disconnectionInfo.Type}"); + _logger.Error(JsonSerializer.Serialize(disconnectionInfo)); + OnWsDisconnection?.Invoke(this, disconnectionInfo); + } + + private void WsReconnection(ReconnectionInfo reconnectionInfo) + { + _logger.Error($"Websocket connection dropped and reconnected. Reconnection type is {reconnectionInfo.Type}"); + _logger.Info("Sending registration info to Twitch IRC"); + // I've found if you use the message queue then things come out of order, hence using SendInstant + _wsClient.SendInstant("CAP REQ :twitch.tv/tags twitch.tv/commands").Wait(_cancellationToken); + // Would be an oauth token if you were signed in, but this is just guest access + _wsClient.SendInstant("PASS SCHMOOPIIE").Wait(_cancellationToken); + // Guest users are just justinfan12345 where the 5 digits are random + _wsClient.SendInstant($"NICK {_nick}").Wait(_cancellationToken); + // I'm ashamed I've forgotten so much IRC protocol shit that I can't remember what the USER params mean :( + _wsClient.SendInstant($"USER {_nick} 8 * :{_nick}").Wait(_cancellationToken); + } + + private void WsMessageReceived(ResponseMessage message) + { + if (message.Text == null) + { + _logger.Info("Twitch sent a null message"); + return; + } + + _logger.Debug($"Received message from Twitch IRC: {message.Text}"); + + try + { + if (message.Text.Contains("PRIVMSG")) + { + // This regex basically ignores all the IRCv3 stuff so handles PRIVMSG fine + var privmsgRegex = + new Regex( + ":(?[^ ]+?)\\!(?[^ ]+?)@(?[^ ]+?) PRIVMSG (?[^ ]+?) :(?.*)"); + var privmsg = privmsgRegex.Match(message.Text); + if (!privmsg.Success) + { + throw new InvalidOperationException("PRIVMSG regex failed to match"); + } + + OnMessageReceived?.Invoke(this, privmsg.Groups["nick"].Value, privmsg.Groups["target"].Value, + privmsg.Groups["message"].Value); + return; + } + + // These are generally filled with IRCv3 gobbledegook and I don't care if it's not a PRIVMSG + if (message.Text.StartsWith('@')) + { + _logger.Debug("Ignoring non-PRIVMSG IRCv3 filled junk"); + return; + } + // This regex is pretty good for parsing most messages but chokes hard on some Twitch IRCv3 insanity + var ircMessageRegex = + new Regex( + "(?::(?[^ ]+) +)?(?[^ :]+)(?(?: +[^ :]+))*(? +:(?.*)?)?"); + var ircMessageMatch = ircMessageRegex.Match(message.Text); + if (!ircMessageMatch.Success) + { + throw new InvalidOperationException("Failed to match IRC message"); + } + var command = ircMessageMatch.Groups["Command"].Value; + var trailing = ircMessageMatch.Groups["trailing"].Value; + _logger.Debug($"Received command {command} with trailing: {trailing}"); + switch (command) + { + case "PING": + _logger.Debug("Received PING, sending PONG"); + _wsClient.Send("PONG"); + return; + case "JOIN": + _logger.Debug("Received JOIN response"); + return; + // MOTD + case "001": + _logger.Debug("Received MOTD. Sending JOIN"); + _wsClient.Send($"JOIN {_channel}"); + return; + default: + _logger.Debug($"Command {command} was not handled"); + return; + } + } + catch (Exception e) + { + _logger.Error("Failed to handle message from Twitch IRC"); + _logger.Error(e); + _logger.Error("--- IRC Message ---"); + _logger.Error(message.Text); + _logger.Error("--- End of IRC Message ---"); + } + } + + public void Dispose() + { + _logger.Info("Disposing Twitch Chat"); + _wsClient.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file