mirror of
https://github.com/barelyprofessional/KfChatDotNet.git
synced 2026-05-02 12:32:03 -04:00
Added Twitch IRC over Websocket support
This commit is contained in:
@@ -30,6 +30,7 @@ public class KickBot
|
|||||||
private readonly Twitch _twitch;
|
private readonly Twitch _twitch;
|
||||||
private Shuffle _shuffle;
|
private Shuffle _shuffle;
|
||||||
private DiscordService _discord;
|
private DiscordService _discord;
|
||||||
|
private TwitchChat _twitchChat;
|
||||||
private string? _lastDiscordStatus;
|
private string? _lastDiscordStatus;
|
||||||
private bool _isBmjLive = false;
|
private bool _isBmjLive = false;
|
||||||
private bool _isBmjLiveSynced = false;
|
private bool _isBmjLiveSynced = false;
|
||||||
@@ -101,6 +102,7 @@ public class KickBot
|
|||||||
|
|
||||||
BuildShuffle();
|
BuildShuffle();
|
||||||
BuildDiscord();
|
BuildDiscord();
|
||||||
|
BuildTwitchChat();
|
||||||
|
|
||||||
_logger.Debug("Blocking the main thread");
|
_logger.Debug("Blocking the main thread");
|
||||||
var exitEvent = new ManualResetEvent(false);
|
var exitEvent = new ManualResetEvent(false);
|
||||||
@@ -124,7 +126,7 @@ public class KickBot
|
|||||||
_logger.Info("Not building Discord as the token is not configured");
|
_logger.Info("Not building Discord as the token is not configured");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_discord = new DiscordService(_config.DiscordToken, _config.Proxy);
|
_discord = new DiscordService(_config.DiscordToken, _config.Proxy, _cancellationToken);
|
||||||
_discord.OnInvalidCredentials += DiscordOnInvalidCredentials;
|
_discord.OnInvalidCredentials += DiscordOnInvalidCredentials;
|
||||||
_discord.OnWsDisconnection += DiscordOnWsDisconnection;
|
_discord.OnWsDisconnection += DiscordOnWsDisconnection;
|
||||||
_discord.OnMessageReceived += DiscordOnMessageReceived;
|
_discord.OnMessageReceived += DiscordOnMessageReceived;
|
||||||
@@ -132,6 +134,39 @@ public class KickBot
|
|||||||
_discord.StartWsClient().Wait(_cancellationToken);
|
_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)
|
private void DiscordOnPresenceUpdated(object sender, DiscordPresenceUpdateModel presence)
|
||||||
{
|
{
|
||||||
if (presence.User.Id != _config.DiscordBmjId)
|
if (presence.User.Id != _config.DiscordBmjId)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class ConfigModel
|
|||||||
public required string KfPassword { get; set; }
|
public required string KfPassword { get; set; }
|
||||||
public string ChromiumPath { get; set; } = "chromium_install";
|
public string ChromiumPath { get; set; } = "chromium_install";
|
||||||
public int? BossmanJackTwitchId { get; set; } = null;
|
public int? BossmanJackTwitchId { get; set; } = null;
|
||||||
|
public string? BossmanJackTwitchUsername { get; set; } = "thebossmanjack";
|
||||||
// Used for testing
|
// Used for testing
|
||||||
public bool SuppressChatMessages { get; set; } = false;
|
public bool SuppressChatMessages { get; set; } = false;
|
||||||
public string? DiscordToken { get; set; } = null;
|
public string? DiscordToken { get; set; } = null;
|
||||||
|
|||||||
179
KfChatDotNetKickBot/Services/TwitchChat.cs
Normal file
179
KfChatDotNetKickBot/Services/TwitchChat.cs
Normal file
@@ -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<ClientWebSocket>(() =>
|
||||||
|
{
|
||||||
|
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(
|
||||||
|
":(?<nick>[^ ]+?)\\!(?<user>[^ ]+?)@(?<host>[^ ]+?) PRIVMSG (?<target>[^ ]+?) :(?<message>.*)");
|
||||||
|
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(
|
||||||
|
"(?::(?<Prefix>[^ ]+) +)?(?<Command>[^ :]+)(?<middle>(?: +[^ :]+))*(?<coda> +:(?<trailing>.*)?)?");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user