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;
    }
}