TwitchChat/TwitchChatConnection.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
namespace TwitchAPI;
public class TwitchChatConnection
{
/// <summary>
/// The websocket URI for Twitch chat
/// </summary>
const string WebsocketUri = "wss://irc-ws.chat.twitch.tv:443";
/// <summary>
/// The Channel this Twitch Chat is connected to
/// </summary>
public string ChannelName { get; private set; }
/// <summary>
/// The size of the message history
/// </summary>
public int MessageHistorySize { get; set; } = 200;
/// <summary>
/// Called when a message is received
/// </summary>
public Action<TwitchChatMessage> OnMessageReceived;
/// <summary>
/// Called when an individual message is removed by a moderator
/// </summary>
public Action<TwitchChatMessage> OnMessageRemoved;
/// <summary>
/// Called when the chat is cleared by a moderator
/// </summary>
public Action OnChatCleared;
/// <summary>
/// The socket connection to Twitch chat
/// </summary>
WebSocket Socket;
/// <summary>
/// The message history of the chat
/// </summary>
List<TwitchChatMessage> MessageHistory = new();
/// <summary>
/// Creates a new Twitch chat connection. This must be .Dispose()'d when finished with.
/// </summary>
/// <param name="channelName"></param>
/// <param name="hasMetadata">If true, connection will request additional metadata for each message (like badges, colours, ect)</param>
public TwitchChatConnection( string channelName, bool hasMetadata = true )
{
ChannelName = channelName;
// Initialize WebSocket
Socket = new WebSocket();
Socket.AddSubProtocol( "irc" );
// Connect to Twitch chat
Socket.Connect( WebsocketUri, CancellationToken.None );
// Login as justinfan
Socket.Send( $"NICK justinfan{Random.Shared.Int( 0, 999 )}" );
Socket.Send( $"JOIN #{ChannelName}" );
// Request Twitch-specific capabilities
if ( hasMetadata )
{
Socket.Send( $"CAP REQ :twitch.tv/membership twitch.tv/tags " );
}
// Subscribe to chat messages
Socket.OnMessageReceived += SocketMessageReceived;
Socket.OnDisconnected += SocketDisconnected;
InitHeartbeat();
}
async void InitHeartbeat()
{
while ( Socket is not null )
{
await Task.Delay( 20000 );
if ( Socket is null ) return;
await Socket.Send( "PING" );
}
}
private void SocketMessageReceived( string message )
{
Log.Info( message );
if ( message.Contains( " PRIVMSG " ) )
{
var chatMessage = message.Substring( message.IndexOf( " PRIVMSG " ) + 9 );
// Find first ":", everything before is username, everything after is message
var username = chatMessage.Substring( 0, chatMessage.IndexOf( ":" ) );
var chat = chatMessage.Substring( chatMessage.IndexOf( ":" ) + 1 );
if ( username.StartsWith( "#" ) ) username = username.Substring( 1 );
var user = new TwitchChatUser( username );
var twitchMessage = new TwitchChatMessage( user, chat );
// Parse the parts at the beginning of Message to get the badge-info,color,display-name,first-msg,ect.
var parts = message.Substring( 1, message.IndexOf( " PRIVMSG " ) - 1 ).Split( ';' );
foreach ( var part in parts )
{
var split = part.Split( '=' );
if ( split.Length == 2 )
{
var key = split[0];
var value = split[1];
switch ( key )
{
case "badge-info":
{
if ( !string.IsNullOrWhiteSpace( value ) )
{
// Parse the badge-info
var badges = value.Split( ',' );
foreach ( var badge in badges )
{
var badgeSplit = badge.Split( '/' );
if ( badgeSplit.Length == 2 )
{
var badgeName = badgeSplit[0];
var badgeVersion = badgeSplit[1];
if ( badgeName == "subscriber" )
{
user.IsSubscribed = true;
user.MonthsSubscribed = int.Parse( badgeVersion );
}
}
}
}
break;
}
case "badges":
{
if ( !string.IsNullOrWhiteSpace( value ) )
{
// Parse the badges
var badges = value.Split( ',' );
foreach ( var badge in badges )
{
var badgeSplit = badge.Split( '/' );
if ( badgeSplit.Length == 2 )
{
var badgeName = badgeSplit[0];
var badgeVersion = int.Parse( badgeSplit[1] );
var twitchBadge = new TwitchChatBadge( badgeName, badgeVersion );
user.Badges.Add( twitchBadge );
if ( badgeName == "premium" ) user.IsPrimeMember = true;
}
}
}
break;
}
case "color":
{
Color? col = Color.Parse( value.Trim() );
if ( col is not null ) user.Color = col ?? Color.White;
break;
}
case "display-name":
{
user.Name = value;
break;
}
case "emotes":
{
// Parse the emotes
var emotes = value.Split( '/' );
foreach ( var emote in emotes )
{
var emoteSplit = emote.Split( ':' );
if ( emoteSplit.Length == 2 )
{
var emoteId = emoteSplit[0];
var emoteLocations = emoteSplit[1].Split( ',' );
foreach ( var emoteLocation in emoteLocations )
{
var emoteLocationSplit = emoteLocation.Split( '-' );
if ( emoteLocationSplit.Length == 2 )
{
var startingCharacter = int.Parse( emoteLocationSplit[0] );
var endingCharacter = int.Parse( emoteLocationSplit[1] );
var emoteText = chat.Substring( startingCharacter, endingCharacter - startingCharacter + 1 );
var chatEmote = new TwitchChatEmote( emoteId, emoteText, startingCharacter, endingCharacter );
twitchMessage.Emotes.Add( chatEmote );
}
}
}
}
break;
}
case "first-msg":
{
var val = int.Parse( value );
twitchMessage.IsFirstMessage = val > 0;
break;
}
case "id":
{
twitchMessage.Id = Guid.Parse( value );
break;
}
case "mod":
{
var val = int.Parse( value );
user.IsModerator = val > 0;
break;
}
case "returning-chatter":
{
var val = int.Parse( value );
user.IsReturningChatter = val > 0;
break;
}
case "subscriber":
{
var val = int.Parse( value );
user.IsSubscribed = val > 0;
break;
}
case "tmi-sent-ts":
{
var timestamp = long.Parse( value ); // ms
// Return in Local Time
twitchMessage.Timestamp = DateTimeOffset.FromUnixTimeMilliseconds( timestamp ).LocalDateTime;
break;
}
case "turbo":
{
var val = int.Parse( value );
user.IsTurbo = val > 0;
break;
}
case "user-id":
{
user.Id = int.Parse( value );
break;
}
}
}
}
MessageHistory.Add( twitchMessage );
if ( MessageHistory.Count > MessageHistorySize ) MessageHistory.RemoveAt( 0 );
OnMessageReceived?.Invoke( twitchMessage );
}
else if ( message.Contains( " CLEARCHAT " ) )
{
OnChatCleared?.Invoke();
}
else if ( message.Contains( " CLEARMSG " ) )
{
// Parse the tags
var parts = message.Substring( 1, message.IndexOf( " CLEARMSG " ) - 1 ).Split( ';' );
var messageId = Guid.Empty;
foreach ( var part in parts )
{
var split = part.Split( '=' );
if ( split.Length == 2 )
{
var key = split[0];
var value = split[1];
if ( key == "target-msg-id" )
{
messageId = Guid.Parse( value );
break;
}
}
}
if ( messageId != Guid.Empty )
{
Log.Info( $"Removing message with ID: {messageId}" );
var messageToRemove = MessageHistory.FirstOrDefault( x => x.Id == messageId );
if ( messageToRemove is not null )
{
MessageHistory.Remove( messageToRemove );
OnMessageRemoved?.Invoke( messageToRemove );
}
}
}
}
private void SocketDisconnected( int status, string reason )
{
Log.Info( $"Disconnected from Twitch Chat #{ChannelName}!" );
}
/// <summary>
/// Returns a list of all messages in the chat history
/// </summary>
/// <returns></returns>
public List<TwitchChatMessage> GetChatHistory()
{
return MessageHistory.ToList();
}
/// <summary>
/// Disposes of the connection
/// </summary>
public void Dispose()
{
Socket?.Dispose();
Socket = null;
}
}