about summary refs log tree commit diff
path: root/IRCStates
diff options
context:
space:
mode:
Diffstat (limited to 'IRCStates')
-rw-r--r--IRCStates/Casemap.cs38
-rw-r--r--IRCStates/Channel.cs67
-rw-r--r--IRCStates/ChannelUser.cs32
-rw-r--r--IRCStates/Commands.cs22
-rw-r--r--IRCStates/Emit.cs24
-rw-r--r--IRCStates/Extensions.cs15
-rw-r--r--IRCStates/IRCStates.csproj21
-rw-r--r--IRCStates/ISupport.cs114
-rw-r--r--IRCStates/ISupportChanModes.cs33
-rw-r--r--IRCStates/ISupportPrefix.cs44
-rw-r--r--IRCStates/Numeric.cs54
-rw-r--r--IRCStates/README.md82
-rw-r--r--IRCStates/Server.cs941
-rw-r--r--IRCStates/ServerDisconnectedException.cs8
-rw-r--r--IRCStates/ServerException.cs8
-rw-r--r--IRCStates/Tests/Cap.cs131
-rw-r--r--IRCStates/Tests/Casemap.cs58
-rw-r--r--IRCStates/Tests/Channel.cs202
-rw-r--r--IRCStates/Tests/Emit.cs117
-rw-r--r--IRCStates/Tests/ISupport.cs210
-rw-r--r--IRCStates/Tests/Mode.cs179
-rw-r--r--IRCStates/Tests/Motd.cs23
-rw-r--r--IRCStates/Tests/Sasl.cs38
-rw-r--r--IRCStates/Tests/User.cs298
-rw-r--r--IRCStates/Tests/Who.cs61
-rw-r--r--IRCStates/User.cs33
26 files changed, 2853 insertions, 0 deletions
diff --git a/IRCStates/Casemap.cs b/IRCStates/Casemap.cs
new file mode 100644
index 0000000..4546e57
--- /dev/null
+++ b/IRCStates/Casemap.cs
@@ -0,0 +1,38 @@
+using System;
+
+namespace IRCStates
+{
+    public static class Casemap
+    {
+        public enum CaseMapping
+        {
+            Rfc1459,
+            Ascii
+        }
+
+        private const string AsciiUpperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+        private const string AsciiLowerChars = "abcdefghijklmnopqrstuvwxyz";
+        private const string Rfc1459UpperChars = AsciiUpperChars + @"[]~\";
+        private const string Rfc1459LowerChars = AsciiLowerChars + @"{}^|";
+
+        private static string Replace(string s, string upper, string lower)
+        {
+            for (var i = 0; i < upper.Length; ++i) s = s.Replace(upper[i], lower[i]);
+
+            return s;
+        }
+
+        public static string CaseFold(CaseMapping mapping, string s)
+        {
+            if (s != null)
+                return mapping switch
+                {
+                    CaseMapping.Rfc1459 => Replace(s, Rfc1459UpperChars, Rfc1459LowerChars),
+                    CaseMapping.Ascii   => Replace(s, AsciiUpperChars, AsciiLowerChars),
+                    _                   => throw new ArgumentOutOfRangeException(nameof(mapping), mapping, null)
+                };
+
+            return string.Empty;
+        }
+    }
+}
diff --git a/IRCStates/Channel.cs b/IRCStates/Channel.cs
new file mode 100644
index 0000000..60ca3fb
--- /dev/null
+++ b/IRCStates/Channel.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace IRCStates
+{
+    public class Channel
+    {
+        public Channel()
+        {
+            Users     = new Dictionary<string, ChannelUser>();
+            ListModes = new Dictionary<string, List<string>>();
+            Modes     = new Dictionary<string, string>();
+        }
+
+        public string Name { get; set; }
+        public string NameLower { get; set; }
+        public Dictionary<string, ChannelUser> Users { get; set; }
+        public string Topic { get; set; }
+        public string TopicSetter { get; set; }
+        public DateTime TopicTime { get; set; }
+        public DateTime Created { get; set; }
+        public Dictionary<string, List<string>> ListModes { get; set; }
+        public Dictionary<string, string> Modes { get; set; }
+
+        public override string ToString()
+        {
+            return $"Channel(name={Name})";
+        }
+
+        public void SetName(string name, string nameLower)
+        {
+            Name      = name;
+            NameLower = nameLower;
+        }
+
+        public void AddMode(string ch, string param, bool listMode)
+        {
+            if (listMode)
+            {
+                if (!ListModes.ContainsKey(ch)) ListModes[ch] = new List<string>();
+
+                if (!ListModes[ch].Contains(param)) ListModes[ch].Add(param ?? string.Empty);
+            }
+            else
+            {
+                Modes[ch] = param;
+            }
+        }
+
+        public void RemoveMode(string ch, string param)
+        {
+            if (ListModes.ContainsKey(ch))
+            {
+                if (ListModes[ch].Contains(param))
+                {
+                    ListModes[ch].Remove(param);
+                    if (!ListModes[ch].Any()) ListModes.Remove(ch);
+                }
+            }
+            else if (Modes.ContainsKey(ch))
+            {
+                Modes.Remove(ch);
+            }
+        }
+    }
+}
diff --git a/IRCStates/ChannelUser.cs b/IRCStates/ChannelUser.cs
new file mode 100644
index 0000000..8c2298b
--- /dev/null
+++ b/IRCStates/ChannelUser.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+
+namespace IRCStates
+{
+    public class ChannelUser
+    {
+        public ChannelUser()
+        {
+            Modes = new List<string>();
+        }
+
+        public List<string> Modes { get; set; }
+
+        protected bool Equals(ChannelUser other)
+        {
+            return other != null && Equals(Modes, other.Modes);
+        }
+
+        public override bool Equals(object obj)
+        {
+            if (ReferenceEquals(null, obj)) return false;
+            if (ReferenceEquals(this, obj)) return true;
+            if (obj.GetType() != GetType()) return false;
+            return Equals((ChannelUser) obj);
+        }
+
+        public override int GetHashCode()
+        {
+            return Modes != null ? Modes.GetHashCode() : 0;
+        }
+    }
+}
diff --git a/IRCStates/Commands.cs b/IRCStates/Commands.cs
new file mode 100644
index 0000000..d9654ec
--- /dev/null
+++ b/IRCStates/Commands.cs
@@ -0,0 +1,22 @@
+namespace IRCStates
+{
+    public static class Commands
+    {
+        public const string Nick = "NICK";
+        public const string Join = "JOIN";
+        public const string Mode = "MODE";
+        public const string Part = "PART";
+        public const string Kick = "KICK";
+        public const string Quit = "QUIT";
+        public const string Error = "ERROR";
+        public const string Topic = "TOPIC";
+        public const string Privmsg = "PRIVMSG";
+        public const string Notice = "NOTICE";
+        public const string Tagmsg = "TAGMSG";
+        public const string Chghost = "CHGHOST";
+        public const string Setname = "SETNAME";
+        public const string Away = "AWAY";
+        public const string Account = "ACCOUNT";
+        public const string Cap = "CAP";
+    }
+}
diff --git a/IRCStates/Emit.cs b/IRCStates/Emit.cs
new file mode 100644
index 0000000..a5f1af5
--- /dev/null
+++ b/IRCStates/Emit.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+
+namespace IRCStates
+{
+    public class Emit
+    {
+        public string Command { get; set; }
+        public string Subcommand { get; set; }
+        public string Text { get; set; }
+        public List<string> Tokens { get; set; }
+        public bool Finished { get; set; }
+        public bool Self { get; set; }
+        public bool SelfSource { get; set; }
+        public bool SelfTarget { get; set; }
+        public User User { get; set; }
+        public User UserSource { get; set; }
+        public User UserTarget { get; set; }
+        public List<User> Users { get; set; }
+        public Channel Channel { get; set; }
+        public Channel ChannelSource { get; set; }
+        public Channel ChannelTarget { get; set; }
+        public string Target { get; set; }
+    }
+}
diff --git a/IRCStates/Extensions.cs b/IRCStates/Extensions.cs
new file mode 100644
index 0000000..c807dbb
--- /dev/null
+++ b/IRCStates/Extensions.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace IRCStates
+{
+    public static class Extensions
+    {
+        public static void UpdateWith<TKey, TValue>(this Dictionary<TKey, TValue> dict, Dictionary<TKey, TValue> other)
+        {
+            if (dict == null || other == null || !other.Any()) return;
+
+            foreach (var (key, value) in other) dict[key] = value;
+        }
+    }
+}
diff --git a/IRCStates/IRCStates.csproj b/IRCStates/IRCStates.csproj
new file mode 100644
index 0000000..cf9f190
--- /dev/null
+++ b/IRCStates/IRCStates.csproj
@@ -0,0 +1,21 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netcoreapp3.1</TargetFramework>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8">
+      <PrivateAssets>all</PrivateAssets>
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+    </PackageReference>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.0" />
+    <PackageReference Include="MSTest.TestAdapter" Version="2.1.1" />
+    <PackageReference Include="MSTest.TestFramework" Version="2.1.1" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\IRCTokens\IRCTokens.csproj" />
+  </ItemGroup>
+
+</Project>
diff --git a/IRCStates/ISupport.cs b/IRCStates/ISupport.cs
new file mode 100644
index 0000000..5fcd5b1
--- /dev/null
+++ b/IRCStates/ISupport.cs
@@ -0,0 +1,114 @@
+// ReSharper disable InconsistentNaming
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace IRCStates
+{
+    public class ISupport
+    {
+        public ISupport()
+        {
+            Raw         = new Dictionary<string, string>();
+            Modes       = 3;
+            CaseMapping = Casemap.CaseMapping.Rfc1459;
+            Prefix      = new ISupportPrefix("(ov)@+");
+            ChanModes   = new ISupportChanModes("b,k,l,imnpst");
+            ChanTypes   = new List<string> {"#"};
+            StatusMsg   = new List<string>();
+            Whox        = false;
+        }
+
+        public Dictionary<string, string> Raw { get; set; }
+        public string Network { get; set; }
+        public ISupportChanModes ChanModes { get; set; }
+        public ISupportPrefix Prefix { get; set; }
+        public int? Modes { get; set; }
+        public Casemap.CaseMapping CaseMapping { get; set; }
+        public List<string> ChanTypes { get; set; }
+        public List<string> StatusMsg { get; set; }
+        public string CallerId { get; set; }
+        public string Excepts { get; set; }
+        public string Invex { get; set; }
+        public int? Monitor { get; set; }
+        public int? Watch { get; set; }
+        public bool Whox { get; set; }
+
+        public void Parse(IEnumerable<string> tokens)
+        {
+            if (tokens == null) return;
+
+            // remove first and last
+            tokens = tokens.Skip(1).SkipLast(1);
+
+            foreach (var token in tokens)
+            {
+                var split = token.Split('=', 2);
+                var key   = split[0];
+
+                var value = string.Empty;
+                if (split.Length > 1)
+                {
+                    value = split[1];
+                    Raw[key] = value;
+                }
+                
+                switch (split[0])
+                {
+                    case "NETWORK":
+                        Network = value;
+                        break;
+                    case "CHANMODES":
+                        ChanModes = new ISupportChanModes(value);
+                        break;
+                    case "PREFIX":
+                        Prefix = new ISupportPrefix(value);
+                        break;
+                    case "STATUSMSG":
+                        StatusMsg = new List<string>();
+                        StatusMsg.AddRange(value.Select(c => c.ToString(CultureInfo.InvariantCulture)));
+                        break;
+                    case "MODES":
+                        if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var modes))
+                            Modes = modes;
+                        else
+                            Modes = -1;
+                        break;
+                    case "MONITOR":
+                        if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var monitor))
+                            Monitor = monitor;
+                        else
+                            Monitor = -1;
+                        break;
+                    case "WATCH":
+                        if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var watch))
+                            Watch = watch;
+                        else
+                            Watch = -1;
+                        break;
+                    case "CASEMAPPING":
+                        if (Enum.TryParse(value, true, out Casemap.CaseMapping caseMapping)) CaseMapping = caseMapping;
+                        break;
+                    case "CHANTYPES":
+                        ChanTypes = new List<string>();
+                        ChanTypes.AddRange(value.Select(c => c.ToString(CultureInfo.InvariantCulture)));
+                        break;
+                    case "CALLERID":
+                        CallerId = string.IsNullOrEmpty(value) ? "g" : value;
+                        break;
+                    case "EXCEPTS":
+                        Excepts = string.IsNullOrEmpty(value) ? "e" : value;
+                        break;
+                    case "INVEX":
+                        Invex = string.IsNullOrEmpty(value) ? "I" : value;
+                        break;
+                    case "WHOX":
+                        Whox = true;
+                        break;
+                }
+            }
+        }
+    }
+}
diff --git a/IRCStates/ISupportChanModes.cs b/IRCStates/ISupportChanModes.cs
new file mode 100644
index 0000000..68cfa67
--- /dev/null
+++ b/IRCStates/ISupportChanModes.cs
@@ -0,0 +1,33 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace IRCStates
+{
+    public class ISupportChanModes
+    {
+        public ISupportChanModes(string splitVal)
+        {
+            if (splitVal == null) return;
+
+            var split = splitVal.Split(',', 4);
+            
+            ListModes = new List<string>();
+            ListModes.AddRange(split[0].Select(c => c.ToString(CultureInfo.InvariantCulture)));
+            
+            SettingBModes = new List<string>();
+            SettingBModes.AddRange(split[1].Select(c => c.ToString(CultureInfo.InvariantCulture)));
+            
+            SettingCModes = new List<string>();
+            SettingCModes.AddRange(split[2].Select(c => c.ToString(CultureInfo.InvariantCulture)));
+            
+            SettingDModes = new List<string>();
+            SettingDModes.AddRange(split[3].Select(c => c.ToString(CultureInfo.InvariantCulture)));
+        }
+
+        public List<string> ListModes { get; set; }
+        public List<string> SettingBModes { get; set; }
+        public List<string> SettingCModes { get; set; }
+        public List<string> SettingDModes { get; set; }
+    }
+}
diff --git a/IRCStates/ISupportPrefix.cs b/IRCStates/ISupportPrefix.cs
new file mode 100644
index 0000000..35c5344
--- /dev/null
+++ b/IRCStates/ISupportPrefix.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace IRCStates
+{
+    public class ISupportPrefix
+    {
+        public ISupportPrefix(string splitVal)
+        {
+            if (splitVal == null) throw new ArgumentNullException(nameof(splitVal));
+
+            var split = splitVal.Substring(1).Split(')', 2);
+            Modes = new List<string>();
+            Modes.AddRange(split[0].Select(c => c.ToString(CultureInfo.InvariantCulture)));
+            Prefixes = new List<string>();
+            Prefixes.AddRange(split[1].Select(c => c.ToString(CultureInfo.InvariantCulture)));
+        }
+
+        public List<string> Modes { get; set; }
+        public List<string> Prefixes { get; set; }
+
+        public string FromMode(char mode)
+        {
+            return FromMode(mode.ToString(CultureInfo.InvariantCulture));
+        }
+
+        public string FromMode(string mode)
+        {
+            return Modes.Contains(mode) ? Prefixes[Modes.IndexOf(mode)] : null;
+        }
+
+        public string FromPrefix(char prefix)
+        {
+            return FromPrefix(prefix.ToString(CultureInfo.InvariantCulture));
+        }
+
+        public string FromPrefix(string prefix)
+        {
+            return Prefixes.Contains(prefix) ? Modes[Prefixes.IndexOf(prefix)] : null;
+        }
+    }
+}
diff --git a/IRCStates/Numeric.cs b/IRCStates/Numeric.cs
new file mode 100644
index 0000000..1ccbd76
--- /dev/null
+++ b/IRCStates/Numeric.cs
@@ -0,0 +1,54 @@
+// ReSharper disable InconsistentNaming
+
+namespace IRCStates
+{
+    public static class Numeric
+    {
+#pragma warning disable CA1707 // Identifiers should not contain underscores
+        public const string RPL_WELCOME = "001";
+        public const string RPL_ISUPPORT = "005";
+        public const string RPL_MOTD = "372";
+        public const string RPL_MOTDSTART = "375";
+        public const string RPL_UMODEIS = "221";
+        public const string RPL_VISIBLEHOST = "396";
+
+        public const string RPL_CHANNELMODEIS = "324";
+        public const string RPL_CREATIONTIME = "329";
+        public const string RPL_TOPIC = "332";
+        public const string RPL_TOPICWHOTIME = "333";
+
+        public const string RPL_WHOREPLY = "352";
+        public const string RPL_WHOSPCRPL = "354";
+        public const string RPL_ENDOFWHO = "315";
+        public const string RPL_NAMREPLY = "353";
+        public const string RPL_ENDOFNAMES = "366";
+
+        public const string RPL_BANLIST = "367";
+        public const string RPL_ENDOFBANLIST = "368";
+        public const string RPL_QUIETLIST = "728";
+        public const string RPL_ENDOFQUIETLIST = "729";
+
+        public const string RPL_LOGGEDIN = "900";
+        public const string RPL_LOGGEDOUT = "901";
+        public const string RPL_SASLSUCCESS = "903";
+        public const string ERR_SASLFAIL = "904";
+        public const string ERR_SASLTOOLONG = "905";
+        public const string ERR_SASLABORTED = "906";
+        public const string ERR_SASLALREADY = "907";
+        public const string RPL_SASLMECHS = "908";
+
+        public const string RPL_WHOISUSER = "311";
+        public const string RPL_WHOISSERVER = "312";
+        public const string RPL_WHOISOPERATOR = "313";
+        public const string RPL_WHOISIDLE = "317";
+        public const string RPL_WHOISCHANNELS = "319";
+        public const string RPL_WHOISACCOUNT = "330";
+        public const string RPL_WHOISHOST = "378";
+        public const string RPL_WHOISMODES = "379";
+        public const string RPL_WHOISSECURE = "671";
+        public const string RPL_ENDOFWHOIS = "318";
+
+        public const string ERR_NOSUCHCHANNEL = "403";
+#pragma warning restore CA1707 // Identifiers should not contain underscores
+    }
+}
diff --git a/IRCStates/README.md b/IRCStates/README.md
new file mode 100644
index 0000000..05daa8c
--- /dev/null
+++ b/IRCStates/README.md
@@ -0,0 +1,82 @@
+# IrcStates
+
+port of [jesopo/ircstates](https://github.com/jesopo/ircstates)
+
+bare bones irc client state
+
+see the full example in [StatesSample/Client.cs](../Examples/States/Client.cs)
+
+    internal class Client
+    {
+        private readonly byte[] _bytes;
+        private readonly StatefulEncoder _encoder;
+        private readonly string _host;
+        private readonly string _nick;
+        private readonly int _port;
+        private readonly Server _server;
+        private readonly Socket _socket;
+
+        public Client(string host, int port, string nick)
+        {
+            _server  = new Server("test");
+            _socket  = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+            _encoder = new StatefulEncoder();
+            _host    = host;
+            _port    = port;
+            _nick    = nick;
+            _bytes   = new byte[1024];
+        }
+
+        private void Send(string raw)
+        {
+            _encoder.Push(new Line(raw));
+        }
+
+        public void Start()
+        {
+            _socket.Connect(_host, _port);
+            while (!_socket.Connected) Thread.Sleep(1000);
+
+            Send("USER test 0 * test");
+            Send($"NICK {_nick}");
+
+            while (true)
+            {
+                while (_encoder.PendingBytes.Any())
+                {
+                    var bytesSent = _socket.Send(_encoder.PendingBytes);
+                    var sentLines = _encoder.Pop(bytesSent);
+                    foreach (var line in sentLines) Console.WriteLine($"> {line.Format()}");
+                }
+
+                var bytesReceived = _socket.Receive(_bytes);
+                if (bytesReceived == 0)
+                {
+                    Console.WriteLine("! disconnected");
+                    _socket.Shutdown(SocketShutdown.Both);
+                    _socket.Close();
+                    break;
+                }
+
+                var receivedLines = _server.Receive(_bytes, bytesReceived);
+                foreach (var (line, _) in receivedLines)
+                {
+                    Console.WriteLine($"< {line.Format()}");
+
+                    switch (line.Command)
+                    {
+                        case Commands.Privmsg:
+                            if (line.Params[1].Contains(_server.NickName))
+                                Send($"PRIVMSG {line.Params[0]} :hi {line.Hostmask.NickName}!");
+                            break;
+                        case "PING":
+                            Send($"PONG :{line.Params[0]}");
+                            break;
+                        case Numeric.RPL_WELCOME:
+                            if (!_server.HasChannel("#test")) Send("JOIN #test");
+                            break;
+                    }
+                }
+            }
+        }
+    }
\ No newline at end of file
diff --git a/IRCStates/Server.cs b/IRCStates/Server.cs
new file mode 100644
index 0000000..2615080
--- /dev/null
+++ b/IRCStates/Server.cs
@@ -0,0 +1,941 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.Design;
+using System.Globalization;
+using System.Linq;
+using IRCTokens;
+
+namespace IRCStates
+{
+    public class Server
+    {
+        public const string WhoType = "525"; // randomly generated
+        private readonly StatefulDecoder _decoder;
+
+        private Dictionary<string, string> TempCaps;
+
+        public Server(string name)
+        {
+            Name          = name;
+            Registered    = false;
+            Modes         = new List<string>();
+            Motd          = new List<string>();
+            _decoder      = new StatefulDecoder();
+            Users         = new Dictionary<string, User>();
+            Channels      = new Dictionary<string, Channel>();
+            ISupport      = new ISupport();
+            HasCap        = false;
+            TempCaps      = new Dictionary<string, string>();
+            AvailableCaps = new Dictionary<string, string>();
+            AgreedCaps    = new List<string>();
+        }
+
+        public string Name { get; set; }
+        public string NickName { get; set; }
+        public string NickNameLower { get; set; }
+        public string UserName { get; set; }
+        public string HostName { get; set; }
+        public string RealName { get; set; }
+        public string Account { get; set; }
+        public string Away { get; set; }
+
+        public bool Registered { get; set; }
+        public List<string> Modes { get; set; }
+        public List<string> Motd { get; set; }
+        public Dictionary<string, User> Users { get; set; }
+        public Dictionary<string, Channel> Channels { get; set; }
+        public Dictionary<string, string> AvailableCaps { get; set; }
+        public List<string> AgreedCaps { get; set; }
+
+        public ISupport ISupport { get; set; }
+        public bool HasCap { get; set; }
+
+        public override string ToString()
+        {
+            return $"Server(name={Name})";
+        }
+
+        public string CaseFold(string str)
+        {
+            return Casemap.CaseFold(ISupport.CaseMapping, str);
+        }
+
+        private bool CaseFoldEquals(string s1, string s2)
+        {
+            return CaseFold(s1) == CaseFold(s2);
+        }
+
+        private bool IsMe(string nickname)
+        {
+            return CaseFold(nickname) == NickNameLower;
+        }
+
+        private bool HasUser(string nickname)
+        {
+            return Users.ContainsKey(CaseFold(nickname));
+        }
+
+        private User AddUser(string nickname, string nicknameLower)
+        {
+            var user = CreateUser(nickname, nicknameLower);
+            Users[nicknameLower] = user;
+            return user;
+        }
+
+        private User CreateUser(string nickname, string nicknameLower)
+        {
+            var user = new User();
+            user.SetNickName(nickname, nicknameLower);
+            return user;
+        }
+
+        private bool IsChannel(string target)
+        {
+            return !string.IsNullOrEmpty(target) &&
+                   ISupport.ChanTypes.Contains(target[0].ToString(CultureInfo.InvariantCulture));
+        }
+
+        public bool HasChannel(string name)
+        {
+            return Channels.ContainsKey(CaseFold(name));
+        }
+
+        private Channel GetChannel(string name)
+        {
+            return HasChannel(name) ? Channels[name] : null;
+        }
+
+        private ChannelUser UserJoin(Channel channel, User user)
+        {
+            var channelUser = new ChannelUser();
+            user.Channels.Add(CaseFold(channel.Name));
+            channel.Users[user.NickNameLower] = channelUser;
+            return channelUser;
+        }
+
+        private void SelfHostmask(Hostmask hostmask)
+        {
+            NickName = hostmask.NickName;
+            if (hostmask.UserName != null) UserName = hostmask.UserName;
+            if (hostmask.HostName != null) HostName = hostmask.HostName;
+        }
+
+        private (Emit, User) UserPart(Line line, string nickName, string channelName, int reasonIndex)
+        {
+            var emit                                            = new Emit();
+            var channelLower                                    = CaseFold(channelName);
+            if (line.Params.Count >= reasonIndex + 1) emit.Text = line.Params[reasonIndex];
+
+            User user = null;
+            if (HasChannel(channelName))
+            {
+                var channel = Channels[channelLower];
+                emit.Channel = channel;
+                var nickLower = CaseFold(nickName);
+                if (HasUser(nickLower))
+                {
+                    user = Users[nickLower];
+                    user.Channels.Remove(channelLower);
+                    channel.Users.Remove(nickLower);
+                    if (!user.Channels.Any()) Users.Remove(nickLower);
+                }
+
+                if (IsMe(nickName))
+                {
+                    Channels.Remove(channelLower);
+                    foreach (var userToRemove in channel.Users.Keys.Select(u => Users[u]))
+                    {
+                        userToRemove.Channels.Remove(channelLower);
+                        if (!userToRemove.Channels.Any()) Users.Remove(userToRemove.NickNameLower);
+                    }
+                }
+            }
+
+            return (emit, user);
+        }
+
+        private void SetChannelModes(Channel channel, IEnumerable<(bool, string)> modes, IList<string> parameters)
+        {
+            foreach (var (add, c) in modes)
+            {
+                var listMode = ISupport.ChanModes.ListModes.Contains(c);
+                if (ISupport.Prefix.Modes.Contains(c))
+                {
+                    var nicknameLower = CaseFold(parameters.First());
+                    parameters.RemoveAt(0);
+                    if (!HasUser(nicknameLower)) continue;
+
+                    var channelUser = channel.Users[nicknameLower];
+                    if (add)
+                    {
+                        if (!channelUser.Modes.Contains(c))
+                        {
+                            channelUser.Modes.Add(c);
+                        }
+                    }
+                    else if (channelUser.Modes.Contains(c))
+                    {
+                        channelUser.Modes.Remove(c);
+                    }
+                }
+                else if (add && (listMode ||
+                                 ISupport.ChanModes.SettingBModes.Contains(c) ||
+                                 ISupport.ChanModes.SettingCModes.Contains(c)))
+                {
+                    channel.AddMode(c, parameters.First(), listMode);
+                    parameters.RemoveAt(0);
+                }
+                else if (!add && (listMode || ISupport.ChanModes.SettingBModes.Contains(c)))
+                {
+                    channel.RemoveMode(c, parameters.First());
+                    parameters.RemoveAt(0);
+                }
+                else if (add)
+                {
+                    channel.AddMode(c, null, false);
+                }
+                else
+                {
+                    channel.RemoveMode(c, null);
+                }
+            }
+        }
+
+        public IEnumerable<(Line, Emit)> Receive(byte[] data, int length)
+        {
+            if (data == null) return null;
+
+            var lines = _decoder.Push(data, length);
+            if (lines == null) throw new ServerDisconnectedException();
+
+            return lines.Select(l => (l, Parse(l))).ToList();
+        }
+
+        public Emit Parse(Line line)
+        {
+            if (line == null) return null;
+
+            var emit = line.Command switch
+            {
+                Numeric.RPL_WELCOME       => HandleWelcome(line),
+                Numeric.RPL_ISUPPORT      => HandleISupport(line),
+                Numeric.RPL_MOTDSTART     => HandleMotd(line),
+                Numeric.RPL_MOTD          => HandleMotd(line),
+                Commands.Nick             => HandleNick(line),
+                Commands.Join             => HandleJoin(line),
+                Commands.Part             => HandlePart(line),
+                Commands.Kick             => HandleKick(line),
+                Commands.Quit             => HandleQuit(line),
+                Commands.Error            => HandleError(line),
+                Numeric.RPL_NAMREPLY      => HandleNames(line),
+                Numeric.RPL_CREATIONTIME  => HandleCreationTime(line),
+                Commands.Topic            => HandleTopic(line),
+                Numeric.RPL_TOPIC         => HandleTopicNumeric(line),
+                Numeric.RPL_TOPICWHOTIME  => HandleTopicTime(line),
+                Commands.Mode             => HandleMode(line),
+                Numeric.RPL_CHANNELMODEIS => HandleChannelModeIs(line),
+                Numeric.RPL_UMODEIS       => HandleUModeIs(line),
+                Commands.Privmsg          => HandleMessage(line),
+                Commands.Notice           => HandleMessage(line),
+                Commands.Tagmsg           => HandleMessage(line),
+                Numeric.RPL_VISIBLEHOST   => HandleVisibleHost(line),
+                Numeric.RPL_WHOREPLY      => HandleWhoReply(line),
+                Numeric.RPL_WHOSPCRPL     => HandleWhox(line),
+                Numeric.RPL_WHOISUSER     => HandleWhoIsUser(line),
+                Commands.Chghost          => HandleChghost(line),
+                Commands.Setname          => HandleSetname(line),
+                Commands.Away             => HandleAway(line),
+                Commands.Account          => HandleAccount(line),
+                Commands.Cap              => HandleCap(line),
+                Numeric.RPL_LOGGEDIN      => HandleLoggedIn(line),
+                Numeric.RPL_LOGGEDOUT     => HandleLoggedOut(line),
+                _                         => null
+            };
+
+            if (emit != null)
+                emit.Command = line.Command;
+            else
+                emit = new Emit();
+
+            return emit;
+        }
+
+        private Emit HandleSetname(Line line)
+        {
+            var emit          = new Emit();
+            var realname      = line.Params[0];
+            var nicknameLower = CaseFold(line.Hostmask.NickName);
+
+            if (IsMe(nicknameLower))
+            {
+                emit.Self = true;
+                RealName  = realname;
+            }
+
+            if (Users.ContainsKey(nicknameLower))
+            {
+                var user = Users[nicknameLower];
+                emit.User     = user;
+                user.RealName = realname;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleAway(Line line)
+        {
+            var emit          = new Emit();
+            var away          = line.Params.FirstOrDefault();
+            var nicknameLower = CaseFold(line.Hostmask.NickName);
+
+            if (IsMe(nicknameLower))
+            {
+                emit.Self = true;
+                Away      = away;
+            }
+
+            if (Users.ContainsKey(nicknameLower))
+            {
+                var user = Users[nicknameLower];
+                emit.User = user;
+                user.Away = away;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleAccount(Line line)
+        {
+            var emit          = new Emit();
+            var account       = line.Params[0].Trim('*');
+            var nicknameLower = CaseFold(line.Hostmask.NickName);
+
+            if (IsMe(nicknameLower))
+            {
+                emit.Self = true;
+                Account   = account;
+            }
+
+            if (Users.ContainsKey(nicknameLower))
+            {
+                var user = Users[nicknameLower];
+                emit.User    = user;
+                user.Account = account;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleCap(Line line)
+        {
+            HasCap = true;
+            var subcommand = line.Params[1].ToUpperInvariant();
+            var multiline  = line.Params[2] == "*";
+            var caps       = line.Params[multiline ? 3 : 2];
+
+            var tokens    = new Dictionary<string, string>();
+            var tokensStr = new List<string>();
+            foreach (var cap in caps.Split(' ', StringSplitOptions.RemoveEmptyEntries))
+            {
+                tokensStr.Add(cap);
+                var kv = cap.Split('=', 2);
+                tokens[kv[0]] = kv.Length > 1 ? kv[1] : string.Empty;
+            }
+
+            var emit = new Emit {Subcommand = subcommand, Finished = !multiline, Tokens = tokensStr};
+
+            switch (subcommand)
+            {
+                case "LS":
+                    TempCaps.UpdateWith(tokens);
+                    if (!multiline)
+                    {
+                        AvailableCaps.UpdateWith(TempCaps);
+                        TempCaps.Clear();
+                    }
+
+                    break;
+                case "NEW":
+                    AvailableCaps.UpdateWith(tokens);
+                    break;
+                case "DEL":
+                    foreach (var key in tokens.Keys.Where(key => AvailableCaps.ContainsKey(key)))
+                    {
+                        AvailableCaps.Remove(key);
+                        if (AgreedCaps.Contains(key)) AgreedCaps.Remove(key);
+                    }
+
+                    break;
+                case "ACK":
+                    foreach (var key in tokens.Keys)
+                        if (key.StartsWith('-'))
+                        {
+                            var k = key.Substring(1);
+                            if (AgreedCaps.Contains(k)) AgreedCaps.Remove(k);
+                        }
+                        else if (!AgreedCaps.Contains(key) && AvailableCaps.ContainsKey(key))
+                        {
+                            AgreedCaps.Add(key);
+                        }
+
+                    break;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleLoggedIn(Line line)
+        {
+            SelfHostmask(new Hostmask(line.Params[1]));
+            Account = line.Params[2];
+            return new Emit();
+        }
+
+        private Emit HandleChghost(Line line)
+        {
+            var emit          = new Emit();
+            var username      = line.Params[0];
+            var hostname      = line.Params[1];
+            var nicknameLower = CaseFold(line.Hostmask.NickName);
+
+            if (IsMe(nicknameLower))
+            {
+                emit.Self = true;
+                UserName  = username;
+                HostName  = hostname;
+            }
+
+            if (Users.ContainsKey(nicknameLower))
+            {
+                var user = Users[nicknameLower];
+                emit.User     = user;
+                user.UserName = username;
+                user.HostName = hostname;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleWhoIsUser(Line line)
+        {
+            var emit     = new Emit();
+            var nickname = line.Params[1];
+            var username = line.Params[2];
+            var hostname = line.Params[3];
+            var realname = line.Params[5];
+
+            if (IsMe(nickname))
+            {
+                emit.Self = true;
+                UserName  = username;
+                HostName  = hostname;
+                RealName  = realname;
+            }
+
+            if (HasUser(nickname))
+            {
+                var user = Users[CaseFold(nickname)];
+                emit.User     = user;
+                user.UserName = username;
+                user.HostName = hostname;
+                user.RealName = realname;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleWhox(Line line)
+        {
+            var emit = new Emit();
+            if (line.Params[1] == WhoType && line.Params.Count == 8)
+            {
+                var nickname = line.Params[5];
+                var username = line.Params[2];
+                var hostname = line.Params[4];
+                var realname = line.Params[7];
+                var account  = line.Params[6] == "0" ? null : line.Params[6];
+
+                if (IsMe(nickname))
+                {
+                    emit.Self = true;
+                    UserName  = username;
+                    HostName  = hostname;
+                    RealName  = realname;
+                    Account   = account;
+                }
+
+                if (HasUser(nickname))
+                {
+                    var user = Users[CaseFold(nickname)];
+                    emit.User     = user;
+                    user.UserName = username;
+                    user.HostName = hostname;
+                    user.RealName = realname;
+                    user.Account  = account;
+                }
+            }
+
+            return emit;
+        }
+
+        private Emit HandleWhoReply(Line line)
+        {
+            var emit     = new Emit {Target = line.Params[1]};
+            var nickname = line.Params[5];
+            var username = line.Params[2];
+            var hostname = line.Params[3];
+            var realname = line.Params[7].Split(' ', 2)[1];
+
+            if (IsMe(nickname))
+            {
+                emit.Self = true;
+                UserName  = username;
+                HostName  = hostname;
+                RealName  = realname;
+            }
+
+            if (HasUser(nickname))
+            {
+                var user = Users[CaseFold(nickname)];
+                emit.User     = user;
+                user.UserName = username;
+                user.HostName = hostname;
+                user.RealName = realname;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleVisibleHost(Line line)
+        {
+            var split = line.Params[1].Split('@', 2);
+            switch (split.Length)
+            {
+                case 1:
+                    HostName = split[0];
+                    break;
+                case 2:
+                    HostName = split[1];
+                    UserName = split[0];
+                    break;
+            }
+
+            return new Emit();
+        }
+
+        private Emit HandleMessage(Line line)
+        {
+            var emit                       = new Emit();
+            var message                    = line.Params.Count > 1 ? line.Params[1] : null;
+            if (message != null) emit.Text = message;
+
+            var nickLower = CaseFold(line.Hostmask.NickName);
+            if (IsMe(nickLower))
+            {
+                emit.SelfSource = true;
+                SelfHostmask(line.Hostmask);
+            }
+
+            var user = HasUser(nickLower)
+                ? Users[nickLower]
+                : AddUser(line.Hostmask.NickName, nickLower);
+            emit.User = user;
+
+            if (line.Hostmask.UserName != null) user.UserName = line.Hostmask.UserName;
+            if (line.Hostmask.HostName != null) user.HostName = line.Hostmask.HostName;
+
+            var target    = line.Params[0];
+            var statusMsg = new List<string>();
+            while (target.Length > 0)
+            {
+                var t = target[0].ToString(CultureInfo.InvariantCulture);
+                if (ISupport.StatusMsg.Contains(t))
+                {
+                    statusMsg.Add(t);
+                    target = target.Substring(1);
+                }
+                else
+                    break;
+            }
+
+            emit.Target = line.Params[0];
+
+            if (IsChannel(target) && HasChannel(target))
+            {
+                emit.Channel = Channels[CaseFold(target)];
+            }
+            else if (IsMe(target))
+            {
+                emit.SelfTarget = true;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleUModeIs(Line line)
+        {
+            foreach (var c in line.Params[1]
+                .TrimStart('+')
+                .Select(m => m.ToString(CultureInfo.InvariantCulture))
+                .Where(m => !Modes.Contains(m)))
+            {
+                Modes.Add(c);
+            }
+
+            return new Emit();
+        }
+
+        private Emit HandleChannelModeIs(Line line)
+        {
+            var emit = new Emit();
+            if (HasChannel(line.Params[1]))
+            {
+                var channel = Channels[CaseFold(line.Params[1])];
+                emit.Channel = channel;
+                var modes = line.Params[2]
+                    .TrimStart('+')
+                    .Select(p => (true, p.ToString(CultureInfo.InvariantCulture)));
+                var parameters = line.Params.Skip(3).ToList();
+                SetChannelModes(channel, modes, parameters);
+            }
+
+            return emit;
+        }
+
+        private Emit HandleMode(Line line)
+        {
+            var emit       = new Emit();
+            var target     = line.Params[0];
+            var modeString = line.Params[1];
+            var parameters = line.Params.Skip(2).ToList();
+
+            var modifier = '+';
+            var modes    = new List<(bool, string)>();
+            var tokens   = new List<string>();
+
+            foreach (var c in modeString)
+            {
+                if (new[] {'+', '-'}.Contains(c))
+                {
+                    modifier = c;
+                }
+                else
+                {
+                    modes.Add((modifier == '+', c.ToString(CultureInfo.InvariantCulture)));
+                    tokens.Add($"{modifier}{c}");
+                }
+            }
+
+            emit.Tokens = tokens;
+
+            if (IsMe(target))
+            {
+                emit.SelfTarget = true;
+                foreach (var (add, c) in modes)
+                {
+                    if (add && !Modes.Contains(c))
+                        Modes.Add(c);
+                    else if (Modes.Contains(c)) Modes.Remove(c);
+                }
+            }
+            else if (HasChannel(target))
+            {
+                var channel = GetChannel(CaseFold(target));
+                emit.Channel = channel;
+                SetChannelModes(channel, modes, parameters);
+            }
+
+            return emit;
+        }
+
+        private Emit HandleTopicTime(Line line)
+        {
+            var emit         = new Emit();
+            var channelLower = CaseFold(line.Params[1]);
+            if (Channels.ContainsKey(channelLower))
+            {
+                var channel = Channels[channelLower];
+                emit.Channel        = channel;
+                channel.TopicSetter = line.Params[2];
+                channel.TopicTime = DateTimeOffset
+                    .FromUnixTimeSeconds(int.Parse(line.Params[3], CultureInfo.InvariantCulture)).DateTime;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleTopicNumeric(Line line)
+        {
+            var emit         = new Emit();
+            var channelLower = CaseFold(line.Params[1]);
+            if (Channels.ContainsKey(channelLower))
+            {
+                var channel = Channels[channelLower];
+                emit.Channel                 = channel;
+                Channels[channelLower].Topic = line.Params[2];
+            }
+
+            return emit;
+        }
+
+        private Emit HandleTopic(Line line)
+        {
+            var emit         = new Emit();
+            var channelLower = CaseFold(line.Params[0]);
+            if (Channels.ContainsKey(channelLower))
+            {
+                var channel = Channels[channelLower];
+                emit.Channel        = channel;
+                channel.Topic       = line.Params[1];
+                channel.TopicSetter = line.Hostmask.ToString();
+                channel.TopicTime   = DateTime.UtcNow;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleCreationTime(Line line)
+        {
+            var emit         = new Emit();
+            var channelLower = CaseFold(line.Params[1]);
+            if (Channels.ContainsKey(channelLower))
+            {
+                var channel = Channels[channelLower];
+                emit.Channel = channel;
+                channel.Created = DateTimeOffset
+                    .FromUnixTimeSeconds(int.Parse(line.Params[2], CultureInfo.InvariantCulture)).DateTime;
+            }
+
+            return emit;
+        }
+
+        private Emit HandleNames(Line line)
+        {
+            var emit         = new Emit();
+            var channelLower = CaseFold(line.Params[2]);
+
+            if (!Channels.ContainsKey(channelLower)) return emit;
+            var channel = Channels[channelLower];
+            emit.Channel = channel;
+            var nicknames = line.Params[3].Split(' ', StringSplitOptions.RemoveEmptyEntries);
+            var users     = new List<User>();
+            emit.Users = users;
+
+            foreach (var nick in nicknames)
+            {
+                var modes = "";
+                foreach (var c in nick)
+                {
+                    var mode = ISupport.Prefix.FromPrefix(c);
+                    if (mode != null)
+                        modes += mode;
+                    else
+                        break;
+                }
+
+                var hostmask  = new Hostmask(nick.Substring(modes.Length));
+                var nickLower = CaseFold(hostmask.NickName);
+                if (!Users.ContainsKey(nickLower)) AddUser(hostmask.NickName, nickLower);
+
+                var user = Users[nickLower];
+                users.Add(user);
+                var channelUser = UserJoin(channel, user);
+
+                if (hostmask.UserName != null) user.UserName = hostmask.UserName;
+                if (hostmask.HostName != null) user.HostName = hostmask.HostName;
+
+                if (IsMe(nickLower)) SelfHostmask(hostmask);
+
+                foreach (var mode in modes.Select(c => c.ToString(CultureInfo.InvariantCulture)))
+                    if (!channelUser.Modes.Contains(mode))
+                        channelUser.Modes.Add(mode);
+            }
+
+            return emit;
+        }
+
+        private Emit HandleError(Line line)
+        {
+            Users.Clear();
+            Channels.Clear();
+            return new Emit();
+        }
+
+        private Emit HandleQuit(Line line)
+        {
+            var emit                         = new Emit();
+            var nickLower                    = CaseFold(line.Hostmask.NickName);
+            if (line.Params.Any()) emit.Text = line.Params[0];
+
+            if (IsMe(nickLower) || line.Source == null)
+            {
+                emit.Self = true;
+                Users.Clear();
+                Channels.Clear();
+            }
+            else if (Users.ContainsKey(nickLower))
+            {
+                var user = Users[nickLower];
+                Users.Remove(nickLower);
+                emit.User = user;
+                foreach (var channel in user.Channels.Select(c => Channels[c]))
+                    channel.Users.Remove(user.NickNameLower);
+            }
+
+            return emit;
+        }
+
+        private Emit HandleLoggedOut(Line line)
+        {
+            Account = null;
+            SelfHostmask(new Hostmask(line.Params[1]));
+            return new Emit();
+        }
+
+        private Emit HandleKick(Line line)
+        {
+            var (emit, kicked) = UserPart(line, line.Params[1], line.Params[0], 2);
+            if (kicked != null)
+            {
+                emit.UserTarget = kicked;
+                if (IsMe(kicked.NickName)) emit.Self = true;
+
+                var kickerLower                        = CaseFold(line.Hostmask.NickName);
+                if (IsMe(kickerLower)) emit.SelfSource = true;
+
+                emit.UserSource = Users.ContainsKey(kickerLower)
+                    ? Users[kickerLower]
+                    : CreateUser(line.Hostmask.NickName, kickerLower);
+            }
+
+            return emit;
+        }
+
+        private Emit HandlePart(Line line)
+        {
+            var (emit, user) = UserPart(line, line.Hostmask.NickName, line.Params[0], 1);
+            if (user != null)
+            {
+                emit.User = user;
+                if (IsMe(user.NickName)) emit.Self = true;
+            }
+
+            return emit;
+        }
+
+
+        private Emit HandleJoin(Line line)
+        {
+            var extended = line.Params.Count == 3;
+            var account  = extended ? line.Params[1].Trim('*') : null;
+            var realname = extended ? line.Params[2] : null;
+            var emit     = new Emit();
+
+            var channelLower = CaseFold(line.Params[0]);
+            var nickLower    = CaseFold(line.Hostmask.NickName);
+
+            // handle own join
+            if (IsMe(nickLower))
+            {
+                emit.Self = true;
+                if (!HasChannel(channelLower))
+                {
+                    var channel = new Channel();
+                    channel.SetName(line.Params[0], channelLower);
+                    Channels[channelLower] = channel;
+                }
+
+                SelfHostmask(line.Hostmask);
+                if (extended)
+                {
+                    Account  = account;
+                    RealName = realname;
+                }
+            }
+
+            if (HasChannel(channelLower))
+            {
+                var channel = Channels[channelLower];
+                emit.Channel = channel;
+
+                if (!HasUser(nickLower)) AddUser(line.Hostmask.NickName, nickLower);
+
+                var user = Users[nickLower];
+                emit.User = user;
+                if (line.Hostmask.UserName != null) user.UserName = line.Hostmask.UserName;
+                if (line.Hostmask.HostName != null) user.HostName = line.Hostmask.HostName;
+                if (extended)
+                {
+                    user.Account  = account;
+                    user.RealName = realname;
+                }
+
+                UserJoin(channel, user);
+            }
+
+            return emit;
+        }
+
+
+        private Emit HandleNick(Line line)
+        {
+            var nick      = line.Params[0];
+            var nickLower = CaseFold(line.Hostmask.NickName);
+
+            var emit = new Emit();
+
+            if (Users.ContainsKey(nickLower))
+            {
+                var user = Users[nickLower];
+                Users.Remove(nickLower);
+                emit.User = user;
+
+                var oldNickLower = user.NickNameLower;
+                var newNickLower = CaseFold(nick);
+                user.SetNickName(nick, newNickLower);
+                Users[newNickLower] = user;
+                foreach (var channelLower in user.Channels)
+                {
+                    var channel     = Channels[channelLower];
+                    var channelUser = channel.Users[oldNickLower];
+                    channel.Users.Remove(oldNickLower);
+                    channel.Users[newNickLower] = channelUser;
+                }
+            }
+
+            if (IsMe(nickLower))
+            {
+                emit.Self     = true;
+                NickName      = nick;
+                NickNameLower = CaseFold(nick);
+            }
+
+            return emit;
+        }
+
+        private Emit HandleMotd(Line line)
+        {
+            if (line.Command == Numeric.RPL_MOTDSTART) Motd.Clear();
+
+            var emit = new Emit {Text = line.Params[1]};
+            Motd.Add(line.Params[1]);
+            return emit;
+        }
+
+        private Emit HandleISupport(Line line)
+        {
+            ISupport = new ISupport();
+            ISupport.Parse(line.Params);
+            return new Emit();
+        }
+
+
+        private Emit HandleWelcome(Line line)
+        {
+            NickName      = line.Params[0];
+            NickNameLower = CaseFold(line.Params[0]);
+            Registered    = true;
+            return new Emit();
+        }
+    }
+}
diff --git a/IRCStates/ServerDisconnectedException.cs b/IRCStates/ServerDisconnectedException.cs
new file mode 100644
index 0000000..4d0bab6
--- /dev/null
+++ b/IRCStates/ServerDisconnectedException.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace IRCStates
+{
+    public class ServerDisconnectedException : Exception
+    {
+    }
+}
diff --git a/IRCStates/ServerException.cs b/IRCStates/ServerException.cs
new file mode 100644
index 0000000..0f44a88
--- /dev/null
+++ b/IRCStates/ServerException.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace IRCStates
+{
+    public class ServerException : Exception
+    {
+    }
+}
diff --git a/IRCStates/Tests/Cap.cs b/IRCStates/Tests/Cap.cs
new file mode 100644
index 0000000..3c0faba
--- /dev/null
+++ b/IRCStates/Tests/Cap.cs
@@ -0,0 +1,131 @@
+using System.Collections.Generic;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Cap
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+        }
+
+        [TestMethod]
+        public void LSOneLine()
+        {
+            Assert.IsFalse(_server.HasCap);
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), _server.AvailableCaps);
+            _server.Parse(new Line("CAP * LS :a b"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"a", ""}, {"b", ""}}, _server.AvailableCaps);
+        }
+
+        [TestMethod]
+        public void LSTwoLines()
+        {
+            _server.Parse(new Line("CAP * LS * :a b"));
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), _server.AvailableCaps);
+            _server.Parse(new Line("CAP * LS :c"));
+            Assert.IsTrue(_server.AvailableCaps.ContainsKey("a"));
+            Assert.IsTrue(_server.AvailableCaps.ContainsKey("b"));
+            Assert.IsTrue(_server.AvailableCaps.ContainsKey("c"));
+        }
+
+        [TestMethod]
+        public void LSValues()
+        {
+            _server.Parse(new Line("CAP * LS :a b= c=1"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"a", ""}, {"b", ""}, {"c", "1"}},
+                _server.AvailableCaps);
+        }
+
+        [TestMethod]
+        public void ACKOneLine()
+        {
+            _server.Parse(new Line("CAP * LS :a b"));
+            _server.Parse(new Line("CAP * ACK :a b"));
+            CollectionAssert.AreEqual(new List<string> {"a", "b"}, _server.AgreedCaps);
+        }
+
+        [TestMethod]
+        public void ACKTwoLines()
+        {
+            _server.Parse(new Line("CAP * LS :a b c"));
+            _server.Parse(new Line("CAP * ACK * :a b"));
+            _server.Parse(new Line("CAP * ACK :c"));
+            CollectionAssert.AreEqual(new List<string> {"a", "b", "c"}, _server.AgreedCaps);
+        }
+
+        [TestMethod]
+        public void ACKNotLS()
+        {
+            _server.Parse(new Line("CAP * LS a"));
+            _server.Parse(new Line("CAP * ACK b"));
+            CollectionAssert.AreEqual(new List<string>(), _server.AgreedCaps);
+        }
+
+        [TestMethod]
+        public void NEWNoLS()
+        {
+            _server.Parse(new Line("CAP * NEW :a"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"a", ""}}, _server.AvailableCaps);
+        }
+
+        [TestMethod]
+        public void NEWOneLine()
+        {
+            _server.Parse(new Line("CAP * LS :a"));
+            _server.Parse(new Line("CAP * NEW :b"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"a", ""}, {"b", ""}}, _server.AvailableCaps);
+        }
+
+        [TestMethod]
+        public void NEWTwoLines()
+        {
+            _server.Parse(new Line("CAP * LS :a"));
+            _server.Parse(new Line("CAP * NEW :b c"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"a", ""}, {"b", ""}, {"c", ""}},
+                _server.AvailableCaps);
+        }
+
+        [TestMethod]
+        public void DELNotAcked()
+        {
+            _server.Parse(new Line("CAP * DEL a"));
+        }
+
+        [TestMethod]
+        public void DELOneLS()
+        {
+            _server.Parse(new Line("CAP * LS :a"));
+            _server.Parse(new Line("CAP * ACK :a"));
+            _server.Parse(new Line("CAP * DEL :a"));
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), _server.AvailableCaps);
+            CollectionAssert.AreEqual(new List<string>(), _server.AgreedCaps);
+        }
+
+        [TestMethod]
+        public void DELTwoLS()
+        {
+            _server.Parse(new Line("CAP * LS :a b"));
+            _server.Parse(new Line("CAP * ACK :a b"));
+            _server.Parse(new Line("CAP * DEL :a"));
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"b", ""}}, _server.AvailableCaps);
+            CollectionAssert.AreEqual(new List<string> {"b"}, _server.AgreedCaps);
+        }
+
+        [TestMethod]
+        public void DELTwoDEL()
+        {
+            _server.Parse(new Line("CAP * LS :a b"));
+            _server.Parse(new Line("CAP * ACK :a b"));
+            _server.Parse(new Line("CAP * DEL :a b"));
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), _server.AvailableCaps);
+            CollectionAssert.AreEqual(new List<string>(), _server.AgreedCaps);
+        }
+    }
+}
diff --git a/IRCStates/Tests/Casemap.cs b/IRCStates/Tests/Casemap.cs
new file mode 100644
index 0000000..4a02444
--- /dev/null
+++ b/IRCStates/Tests/Casemap.cs
@@ -0,0 +1,58 @@
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Casemap
+    {
+        [TestMethod]
+        public void Rfc1459()
+        {
+            var lower = IRCStates.Casemap.CaseFold(IRCStates.Casemap.CaseMapping.Rfc1459, @"ÀTEST[]~\");
+            Assert.AreEqual("Àtest{}^|", lower);
+        }
+
+        [TestMethod]
+        public void Ascii()
+        {
+            var lower = IRCStates.Casemap.CaseFold(IRCStates.Casemap.CaseMapping.Ascii, @"ÀTEST[]~\");
+            Assert.AreEqual(@"Àtest[]~\", lower);
+        }
+
+        [TestMethod]
+        public void CommandJoin()
+        {
+            var server = new Server("test");
+            server.Parse(new Line("001 nickname"));
+            server.Parse(new Line(":Nickname JOIN #Chan"));
+            server.Parse(new Line(":Other JOIN #Chan"));
+
+            Assert.IsTrue(server.Users.ContainsKey("nickname"));
+            Assert.IsFalse(server.Users.ContainsKey("Nickname"));
+            Assert.IsTrue(server.Users.ContainsKey("other"));
+            Assert.IsFalse(server.Users.ContainsKey("Other"));
+            Assert.IsTrue(server.Channels.ContainsKey("#chan"));
+            Assert.IsFalse(server.Channels.ContainsKey("#Chan"));
+
+            var channel = server.Channels["#chan"];
+            Assert.AreEqual("#Chan", channel.Name);
+        }
+
+        [TestMethod]
+        public void CommandNick()
+        {
+            var server = new Server("test");
+            server.Parse(new Line("001 nickname"));
+            server.Parse(new Line(":nickname JOIN #chan"));
+            var user = server.Users["nickname"];
+            server.Parse(new Line(":nickname NICK NewNickname"));
+            Assert.AreEqual(1, server.Users.Count);
+            Assert.IsTrue(server.Users.ContainsKey("newnickname"));
+            Assert.AreEqual("NewNickname", user.NickName);
+            Assert.AreEqual("newnickname", user.NickNameLower);
+            Assert.AreEqual("NewNickname", server.NickName);
+            Assert.AreEqual("newnickname", server.NickNameLower);
+        }
+    }
+}
diff --git a/IRCStates/Tests/Channel.cs b/IRCStates/Tests/Channel.cs
new file mode 100644
index 0000000..6868e0f
--- /dev/null
+++ b/IRCStates/Tests/Channel.cs
@@ -0,0 +1,202 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Channel
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+            _server.Parse(new Line(":nickname JOIN #chan"));
+        }
+
+        [TestMethod]
+        public void JoinSelf()
+        {
+            Assert.IsTrue(_server.Channels.ContainsKey("#chan"));
+            Assert.IsTrue(_server.Users.ContainsKey("nickname"));
+            Assert.AreEqual(1, _server.Channels.Count);
+            Assert.AreEqual(1, _server.Users.Count);
+
+            var user = _server.Users["nickname"];
+            var chan = _server.Channels["#chan"];
+            Assert.IsTrue(chan.Users.ContainsKey(user.NickNameLower));
+            var chanUser = chan.Users[user.NickNameLower];
+            CollectionAssert.AreEqual(new List<string> {chan.NameLower}, user.Channels.ToList());
+        }
+
+        [TestMethod]
+        public void JoinOther()
+        {
+            _server.Parse(new Line(":other JOIN #chan"));
+
+            Assert.AreEqual(2, _server.Users.Count);
+            Assert.IsTrue(_server.Users.ContainsKey("other"));
+
+            var channel = _server.Channels["#chan"];
+            Assert.AreEqual(2, channel.Users.Count);
+
+            var user = _server.Users["other"];
+            CollectionAssert.AreEqual(new List<string> {channel.NameLower}, user.Channels.ToList());
+        }
+
+        [TestMethod]
+        public void PartSelf()
+        {
+            _server.Parse(new Line(":nickname PART #chan"));
+
+            Assert.AreEqual(0, _server.Users.Count);
+            Assert.AreEqual(0, _server.Channels.Count);
+        }
+
+        [TestMethod]
+        public void PartOther()
+        {
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":other PART #chan"));
+
+            var user     = _server.Users["nickname"];
+            var channel  = _server.Channels["#chan"];
+            var chanUser = channel.Users[user.NickNameLower];
+
+            Assert.AreEqual(channel.NameLower, user.Channels.Single());
+            CollectionAssert.AreEqual(new Dictionary<string, IRCStates.User> {{"nickname", user}}, _server.Users);
+            CollectionAssert.AreEqual(new Dictionary<string, IRCStates.Channel> {{"#chan", channel}}, _server.Channels);
+            CollectionAssert.AreEqual(new Dictionary<string, ChannelUser> {{"nickname", chanUser}}, channel.Users);
+        }
+
+        [TestMethod]
+        public void KickSelf()
+        {
+            _server.Parse(new Line(":nickname KICK #chan nickname"));
+
+            Assert.AreEqual(0, _server.Users.Count);
+            Assert.AreEqual(0, _server.Channels.Count);
+        }
+
+        [TestMethod]
+        public void KickOther()
+        {
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":nickname KICK #chan other"));
+
+            var user     = _server.Users["nickname"];
+            var channel  = _server.Channels["#chan"];
+            var chanUser = channel.Users[user.NickNameLower];
+
+            Assert.AreEqual(1, _server.Users.Count);
+            Assert.AreEqual(1, _server.Channels.Count);
+            Assert.AreEqual(channel.NameLower, user.Channels.Single());
+            CollectionAssert.AreEqual(new Dictionary<string, ChannelUser> {{user.NickNameLower, chanUser}},
+                channel.Users);
+        }
+
+        [TestMethod]
+        public void QuitSelf()
+        {
+            _server.Parse(new Line("QUIT :i'm outta here"));
+            Assert.IsFalse(_server.Users.Any());
+            Assert.IsFalse(_server.Channels.Any());
+        }
+
+        [TestMethod]
+        public void QuitSelfWithSource()
+        {
+            _server.Parse(new Line(":nickname QUIT :i'm outta here"));
+            Assert.IsFalse(_server.Users.Any());
+            Assert.IsFalse(_server.Channels.Any());
+        }
+
+        [TestMethod]
+        public void QuitOther()
+        {
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":other QUIT :see ya"));
+            Assert.IsFalse(_server.Users.ContainsKey("other"));
+        }
+
+        [TestMethod]
+        public void TopicText()
+        {
+            _server.Parse(new Line("332 * #chan :test"));
+            Assert.AreEqual("test", _server.Channels["#chan"].Topic);
+        }
+
+        [TestMethod]
+        public void TopicSetByAt()
+        {
+            var dt = DateTimeOffset.FromUnixTimeSeconds(1584023277).DateTime;
+            _server.Parse(new Line("333 * #chan other 1584023277"));
+
+            var channel = _server.Channels["#chan"];
+
+            Assert.AreEqual("other", channel.TopicSetter);
+            Assert.AreEqual(dt, channel.TopicTime);
+        }
+
+        [TestMethod]
+        public void TopicCommand()
+        {
+            _server.Parse(new Line("TOPIC #chan :hello there"));
+            Assert.AreEqual("hello there", _server.Channels["#chan"].Topic);
+        }
+
+        [TestMethod]
+        public void CreationDate()
+        {
+            _server.Parse(new Line("329 * #chan 1584041889"));
+            Assert.AreEqual(DateTimeOffset.FromUnixTimeSeconds(1584041889).DateTime, _server.Channels["#chan"].Created);
+        }
+
+        [TestMethod]
+        public void NamesCommand()
+        {
+            _server.Parse(new Line("353 * * #chan :nickname @+other"));
+            Assert.IsTrue(_server.Users.ContainsKey("nickname"));
+            Assert.IsTrue(_server.Users.ContainsKey("other"));
+
+            var user      = _server.Users["other"];
+            var channel   = _server.Channels["#chan"];
+            var chanUser1 = channel.Users[user.NickNameLower];
+            var chanUser2 = channel.Users[_server.NickNameLower];
+
+            Assert.AreEqual(2, channel.Users.Count);
+            CollectionAssert.AreEqual(chanUser1.Modes, channel.Users[user.NickNameLower].Modes);
+            CollectionAssert.AreEqual(chanUser2.Modes, channel.Users[_server.NickNameLower].Modes);
+            CollectionAssert.AreEqual(new List<string> {"o", "v"}, chanUser1.Modes);
+            Assert.AreEqual(channel.NameLower, user.Channels.Single());
+        }
+
+        [TestMethod]
+        public void UserhostInNames()
+        {
+            _server.Parse(new Line("353 * * #chan :nickname!user@host other!user2@host2"));
+            Assert.AreEqual("user", _server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+
+            var user = _server.Users["other"];
+            Assert.AreEqual("user2", user.UserName);
+            Assert.AreEqual("host2", user.HostName);
+        }
+
+        [TestMethod]
+        public void NickAfterJoin()
+        {
+            var user     = _server.Users["nickname"];
+            var channel  = _server.Channels["#chan"];
+            var chanUser = channel.Users[user.NickNameLower];
+            _server.Parse(new Line(":nickname NICK nickname2"));
+            CollectionAssert.AreEqual(new Dictionary<string, ChannelUser> {{user.NickNameLower, chanUser}},
+                channel.Users);
+        }
+    }
+}
diff --git a/IRCStates/Tests/Emit.cs b/IRCStates/Tests/Emit.cs
new file mode 100644
index 0000000..07fea8c
--- /dev/null
+++ b/IRCStates/Tests/Emit.cs
@@ -0,0 +1,117 @@
+using System.Collections.Generic;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Emit
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+        }
+
+        [TestMethod]
+        public void EmitJoin()
+        {
+            var emit = _server.Parse(new Line(":nickname JOIN #chan"));
+
+            Assert.AreEqual("JOIN", emit.Command);
+            Assert.IsTrue(emit.Self);
+            Assert.AreEqual(_server.Users["nickname"], emit.User);
+            Assert.AreEqual(_server.Channels["#chan"], emit.Channel);
+
+            emit = _server.Parse(new Line(":other JOIN #chan"));
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("JOIN", emit.Command);
+            Assert.IsFalse(emit.Self);
+            Assert.AreEqual(_server.Users["other"], emit.User);
+            Assert.AreEqual(_server.Channels["#chan"], emit.Channel);
+        }
+
+        [TestMethod]
+        public void EmitPrivmsg()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            var emit = _server.Parse(new Line(":nickname PRIVMSG #chan :hello"));
+
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("PRIVMSG", emit.Command);
+            Assert.AreEqual("hello", emit.Text);
+            Assert.IsTrue(emit.SelfSource);
+            Assert.AreEqual(_server.Users["nickname"], emit.User);
+            Assert.AreEqual(_server.Channels["#chan"], emit.Channel);
+
+            _server.Parse(new Line(":other JOIN #chan"));
+            emit = _server.Parse(new Line(":other PRIVMSG #chan :hello2"));
+
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("PRIVMSG", emit.Command);
+            Assert.AreEqual("hello2", emit.Text);
+            Assert.IsFalse(emit.SelfSource);
+            Assert.AreEqual(_server.Users["other"], emit.User);
+            Assert.AreEqual(_server.Channels["#chan"], emit.Channel);
+        }
+
+        [TestMethod]
+        public void EmitPrivmsgNoJoin()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            var emit = _server.Parse(new Line(":other PRIVMSG #chan :hello"));
+
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("PRIVMSG", emit.Command);
+            Assert.AreEqual("hello", emit.Text);
+            Assert.IsFalse(emit.SelfSource);
+            Assert.IsNotNull(emit.User);
+
+            var channel = _server.Channels["#chan"];
+            Assert.AreEqual(channel, emit.Channel);
+        }
+
+        [TestMethod]
+        public void EmitKick()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+
+            var user    = _server.Users["nickname"];
+            var channel = _server.Channels["#chan"];
+            _server.Parse(new Line(":other JOIN #chan"));
+            var userOther = _server.Users["other"];
+            var emit      = _server.Parse(new Line(":nickname KICK #chan other :reason"));
+
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("KICK", emit.Command);
+            Assert.AreEqual("reason", emit.Text);
+            Assert.IsTrue(emit.SelfSource);
+            Assert.AreEqual(user, emit.UserSource);
+            Assert.AreEqual(userOther, emit.UserTarget);
+            Assert.AreEqual(channel, emit.Channel);
+        }
+
+        [TestMethod]
+        public void EmitMode()
+        {
+            var emit = _server.Parse(new Line("MODE nickname x+i-i+wi-wi"));
+
+            Assert.IsNotNull(emit);
+            Assert.AreEqual("MODE", emit.Command);
+            Assert.IsTrue(emit.SelfTarget);
+            CollectionAssert.AreEqual(new List<string>
+            {
+                "+x",
+                "+i",
+                "-i",
+                "+w",
+                "+i",
+                "-w",
+                "-i"
+            }, emit.Tokens);
+        }
+    }
+}
diff --git a/IRCStates/Tests/ISupport.cs b/IRCStates/Tests/ISupport.cs
new file mode 100644
index 0000000..5cdcc61
--- /dev/null
+++ b/IRCStates/Tests/ISupport.cs
@@ -0,0 +1,210 @@
+using System.Collections.Generic;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+// ReSharper disable InconsistentNaming
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class ISupport
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+        }
+
+        [TestMethod]
+        public void ChanModes()
+        {
+            CollectionAssert.AreEqual(new List<string> {"b"}, _server.ISupport.ChanModes.ListModes);
+            CollectionAssert.AreEqual(new List<string> {"k"}, _server.ISupport.ChanModes.SettingBModes);
+            CollectionAssert.AreEqual(new List<string> {"l"}, _server.ISupport.ChanModes.SettingCModes);
+            CollectionAssert.AreEqual(new List<string>
+            {
+                "i",
+                "m",
+                "n",
+                "p",
+                "s",
+                "t"
+            }, _server.ISupport.ChanModes.SettingDModes);
+
+            _server.Parse(new Line("005 * CHANMODES=a,b,c,d *"));
+
+            CollectionAssert.AreEqual(new List<string> {"a"}, _server.ISupport.ChanModes.ListModes);
+            CollectionAssert.AreEqual(new List<string> {"b"}, _server.ISupport.ChanModes.SettingBModes);
+            CollectionAssert.AreEqual(new List<string> {"c"}, _server.ISupport.ChanModes.SettingCModes);
+            CollectionAssert.AreEqual(new List<string> {"d"}, _server.ISupport.ChanModes.SettingDModes);
+        }
+
+        [TestMethod]
+        public void Prefix()
+        {
+            CollectionAssert.AreEqual(new List<string> {"o", "v"}, _server.ISupport.Prefix.Modes);
+            CollectionAssert.AreEqual(new List<string> {"@", "+"}, _server.ISupport.Prefix.Prefixes);
+
+            Assert.AreEqual("@", _server.ISupport.Prefix.FromMode("o"));
+            Assert.IsNull(_server.ISupport.Prefix.FromMode("a"));
+            Assert.AreEqual("o", _server.ISupport.Prefix.FromPrefix("@"));
+            Assert.IsNull(_server.ISupport.Prefix.FromPrefix("&"));
+
+            _server.Parse(new Line("005 * PREFIX=(qaohv)~&@%+ *"));
+            CollectionAssert.AreEqual(new List<string>
+            {
+                "q",
+                "a",
+                "o",
+                "h",
+                "v"
+            }, _server.ISupport.Prefix.Modes);
+            CollectionAssert.AreEqual(new List<string>
+            {
+                "~",
+                "&",
+                "@",
+                "%",
+                "+"
+            }, _server.ISupport.Prefix.Prefixes);
+            Assert.AreEqual("&", _server.ISupport.Prefix.FromMode("a"));
+            Assert.AreEqual("a", _server.ISupport.Prefix.FromPrefix("&"));
+        }
+
+        [TestMethod]
+        public void ChanTypes()
+        {
+            CollectionAssert.AreEqual(new List<string> {"#"}, _server.ISupport.ChanTypes);
+            _server.Parse(new Line("005 * CHANTYPES=#& *"));
+            CollectionAssert.AreEqual(new List<string> {"#", "&"}, _server.ISupport.ChanTypes);
+        }
+
+        [TestMethod]
+        public void Modes()
+        {
+            Assert.AreEqual(3, _server.ISupport.Modes);
+
+            _server.Parse(new Line("005 * MODES *"));
+            Assert.AreEqual(-1, _server.ISupport.Modes);
+
+            _server.Parse(new Line("005 * MODES=5 *"));
+            Assert.AreEqual(5, _server.ISupport.Modes);
+        }
+
+        [TestMethod]
+        public void Rfc1459()
+        {
+            Assert.AreEqual(IRCStates.Casemap.CaseMapping.Rfc1459, _server.ISupport.CaseMapping);
+            _server.Parse(new Line("005 * CASEMAPPING=rfc1459 *"));
+            Assert.AreEqual(IRCStates.Casemap.CaseMapping.Rfc1459, _server.ISupport.CaseMapping);
+            var lower = _server.CaseFold(@"ÀTEST[]~\");
+            Assert.AreEqual("Àtest{}^|", lower);
+        }
+
+        [TestMethod]
+        public void Ascii()
+        {
+            _server.Parse(new Line("005 * CASEMAPPING=ascii *"));
+            Assert.AreEqual(IRCStates.Casemap.CaseMapping.Ascii, _server.ISupport.CaseMapping);
+            var lower = _server.CaseFold(@"ÀTEST[]~\");
+            Assert.AreEqual(@"Àtest[]~\", lower);
+        }
+
+        [TestMethod]
+        public void FallbackToRfc1459()
+        {
+            _server.Parse(new Line("005 * CASEMAPPING=nonexistent *"));
+            Assert.AreEqual(IRCStates.Casemap.CaseMapping.Rfc1459, _server.ISupport.CaseMapping);
+            var lower = _server.CaseFold(@"ÀTEST[]~\");
+            Assert.AreEqual("Àtest{}^|", lower);
+        }
+
+        [TestMethod]
+        public void Network()
+        {
+            Assert.IsNull(_server.ISupport.Network);
+            _server.Parse(new Line("005 * NETWORK=testnet *"));
+            Assert.AreEqual("testnet", _server.ISupport.Network);
+        }
+
+        [TestMethod]
+        public void StatusMsg()
+        {
+            CollectionAssert.AreEqual(new List<string>(), _server.ISupport.StatusMsg);
+            _server.Parse(new Line("005 * STATUSMSG=&@ *"));
+            CollectionAssert.AreEqual(new List<string> {"&", "@"}, _server.ISupport.StatusMsg);
+        }
+
+        [TestMethod]
+        public void CallerId()
+        {
+            Assert.IsNull(_server.ISupport.CallerId);
+
+            _server.Parse(new Line("005 * CALLERID=U *"));
+            Assert.AreEqual("U", _server.ISupport.CallerId);
+
+            _server.Parse(new Line("005 * CALLERID *"));
+            Assert.AreEqual("g", _server.ISupport.CallerId);
+        }
+
+        [TestMethod]
+        public void Excepts()
+        {
+            Assert.IsNull(_server.ISupport.Excepts);
+
+            _server.Parse(new Line("005 * EXCEPTS=U *"));
+            Assert.AreEqual("U", _server.ISupport.Excepts);
+
+            _server.Parse(new Line("005 * EXCEPTS *"));
+            Assert.AreEqual("e", _server.ISupport.Excepts);
+        }
+
+        [TestMethod]
+        public void Invex()
+        {
+            Assert.IsNull(_server.ISupport.Invex);
+
+            _server.Parse(new Line("005 * INVEX=U *"));
+            Assert.AreEqual("U", _server.ISupport.Invex);
+
+            _server.Parse(new Line("005 * INVEX *"));
+            Assert.AreEqual("I", _server.ISupport.Invex);
+        }
+
+        [TestMethod]
+        public void Whox()
+        {
+            Assert.IsFalse(_server.ISupport.Whox);
+
+            _server.Parse(new Line("005 * WHOX *"));
+            Assert.IsTrue(_server.ISupport.Whox);
+        }
+
+        [TestMethod]
+        public void Monitor()
+        {
+            Assert.IsNull(_server.ISupport.Monitor);
+
+            _server.Parse(new Line("005 * MONITOR=123 *"));
+            Assert.AreEqual(123, _server.ISupport.Monitor);
+
+            _server.Parse(new Line("005 * MONITOR *"));
+            Assert.AreEqual(-1, _server.ISupport.Monitor);
+        }
+
+        [TestMethod]
+        public void Watch()
+        {
+            Assert.IsNull(_server.ISupport.Watch);
+
+            _server.Parse(new Line("005 * WATCH=123 *"));
+            Assert.AreEqual(123, _server.ISupport.Watch);
+
+            _server.Parse(new Line("005 * WATCH *"));
+            Assert.AreEqual(-1, _server.ISupport.Watch);
+        }
+    }
+}
diff --git a/IRCStates/Tests/Mode.cs b/IRCStates/Tests/Mode.cs
new file mode 100644
index 0000000..90763fa
--- /dev/null
+++ b/IRCStates/Tests/Mode.cs
@@ -0,0 +1,179 @@
+using System.Collections.Generic;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Mode
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+        }
+
+        [TestMethod]
+        public void UModeAdd()
+        {
+            _server.Parse(new Line("MODE nickname +i"));
+            CollectionAssert.AreEqual(new List<string> {"i"}, _server.Modes);
+        }
+
+        [TestMethod]
+        public void UModeRemove()
+        {
+            _server.Parse(new Line("MODE nickname +i"));
+            _server.Parse(new Line("MODE nickname -i"));
+            CollectionAssert.AreEqual(new List<string>(), _server.Modes);
+        }
+
+        [TestMethod]
+        public void PrefixAdd()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +ov nickname nickname"));
+
+            var user        = _server.Users["nickname"];
+            var channel     = _server.Channels["#chan"];
+            var channelUser = channel.Users[user.NickNameLower];
+            CollectionAssert.AreEqual(new List<string> {"o", "v"}, channelUser.Modes);
+        }
+
+        [TestMethod]
+        public void PrefixRemove()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +ov nickname nickname"));
+            _server.Parse(new Line("MODE #chan -ov nickname nickname"));
+
+            var user        = _server.Users["nickname"];
+            var channel     = _server.Channels["#chan"];
+            var channelUser = channel.Users[user.NickNameLower];
+            CollectionAssert.AreEqual(new List<string>(), channelUser.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelListAdd()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +b asd!*@*"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new List<string> {"asd!*@*"}, channel.ListModes["b"]);
+        }
+
+        [TestMethod]
+        public void ChannelListRemove()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +b asd!*@*"));
+            _server.Parse(new Line("MODE #chan -b asd!*@*"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, List<string>>(), channel.ListModes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeBAdd()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +k password"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"k", "password"}}, channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeBRemove()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +k password"));
+            _server.Parse(new Line("MODE #chan -k *"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeCAdd()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +l 100"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"l", "100"}}, channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeCRemove()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +l 100"));
+            _server.Parse(new Line("MODE #chan -l"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeDAdd()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +i"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"i", null}}, channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelTypeDRemove()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("MODE #chan +i"));
+            _server.Parse(new Line("MODE #chan -i"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string>(), channel.Modes);
+        }
+
+        [TestMethod]
+        public void ChannelNumeric()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("324 * #chan +bkli *!*@* pass 10"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"k", "pass"}, {"l", "10"}, {"i", null}},
+                channel.Modes);
+            CollectionAssert.AreEqual(new List<string> {"*!*@*"}, channel.ListModes["b"]);
+        }
+
+        [TestMethod]
+        public void ChannelNumericWithoutPlus()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("324 * #chan il 10"));
+
+            var channel = _server.Channels["#chan"];
+            CollectionAssert.AreEqual(new Dictionary<string, string> {{"i", null}, {"l", "10"}}, channel.Modes);
+        }
+
+        [TestMethod]
+        public void UserNumeric()
+        {
+            _server.Parse(new Line("221 * +iw"));
+            CollectionAssert.AreEqual(new List<string> {"i", "w"}, _server.Modes);
+        }
+
+        [TestMethod]
+        public void UserNumericWithoutPlus()
+        {
+            _server.Parse(new Line("221 * iw"));
+            CollectionAssert.AreEqual(new List<string> {"i", "w"}, _server.Modes);
+        }
+    }
+}
diff --git a/IRCStates/Tests/Motd.cs b/IRCStates/Tests/Motd.cs
new file mode 100644
index 0000000..2d75982
--- /dev/null
+++ b/IRCStates/Tests/Motd.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Motd
+    {
+        [TestMethod]
+        public void MessageOfTheDay()
+        {
+            var server = new Server("test");
+            server.Parse(new Line("001 nickname"));
+            server.Parse(new Line("375 * :start of motd"));
+            server.Parse(new Line("372 * :first line of motd"));
+            server.Parse(new Line("372 * :second line of motd"));
+
+            CollectionAssert.AreEqual(new List<string> {"start of motd", "first line of motd", "second line of motd"},
+                server.Motd);
+        }
+    }
+}
diff --git a/IRCStates/Tests/Sasl.cs b/IRCStates/Tests/Sasl.cs
new file mode 100644
index 0000000..151ccdf
--- /dev/null
+++ b/IRCStates/Tests/Sasl.cs
@@ -0,0 +1,38 @@
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Sasl
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("900 * nick!user@host account"));
+        }
+
+        [TestMethod]
+        public void LoggedIn()
+        {
+            Assert.AreEqual("nick", _server.NickName);
+            Assert.AreEqual("user", _server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+            Assert.AreEqual("account", _server.Account);
+        }
+
+        [TestMethod]
+        public void LoggedOut()
+        {
+            _server.Parse(new Line("901 * nick1!user1@host1"));
+
+            Assert.AreEqual("nick1", _server.NickName);
+            Assert.AreEqual("user1", _server.UserName);
+            Assert.AreEqual("host1", _server.HostName);
+            Assert.IsTrue(string.IsNullOrEmpty(_server.Account));
+        }
+    }
+}
diff --git a/IRCStates/Tests/User.cs b/IRCStates/Tests/User.cs
new file mode 100644
index 0000000..61d7157
--- /dev/null
+++ b/IRCStates/Tests/User.cs
@@ -0,0 +1,298 @@
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class User
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+        }
+
+        [TestMethod]
+        public void Welcome()
+        {
+            Assert.AreEqual("test", _server.Name);
+            Assert.AreEqual("nickname", _server.NickName);
+        }
+
+        [TestMethod]
+        public void NicknameChange()
+        {
+            _server.Parse(new Line(":nickname NICK nickname2"));
+            Assert.AreEqual("nickname2", _server.NickName);
+
+            _server.Parse(new Line(":nickname2 JOIN #chan"));
+            _server.Parse(new Line(":other JOIN #chan"));
+            Assert.IsTrue(_server.Users.ContainsKey("other"));
+
+            _server.Parse(new Line(":other NICK other2"));
+            Assert.IsFalse(_server.Users.ContainsKey("other"));
+            Assert.IsTrue(_server.Users.ContainsKey("other2"));
+        }
+
+        [TestMethod]
+        public void HostmaskJoinBoth()
+        {
+            _server.Parse(new Line(":nickname!user@host JOIN #chan"));
+            Assert.AreEqual("user", _server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+
+            _server.Parse(new Line(":other!user@host JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("user", user.UserName);
+            Assert.AreEqual("host", user.HostName);
+        }
+
+        [TestMethod]
+        public void HostmaskJoinUser()
+        {
+            _server.Parse(new Line(":nickname!user JOIN #chan"));
+            Assert.AreEqual("user", _server.UserName);
+            Assert.IsNull(_server.HostName);
+
+            _server.Parse(new Line(":other!user JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("user", user.UserName);
+            Assert.IsNull(user.HostName);
+        }
+
+        [TestMethod]
+        public void HostmaskJoinHost()
+        {
+            _server.Parse(new Line(":nickname@host JOIN #chan"));
+            Assert.IsNull(_server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+
+            _server.Parse(new Line(":other@host JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.IsNull(user.UserName);
+            Assert.AreEqual("host", user.HostName);
+        }
+
+        [TestMethod]
+        public void ExtendedJoinWithoutExtendedJoin()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            Assert.IsNull(_server.Account);
+            Assert.IsNull(_server.RealName);
+
+            _server.Parse(new Line(":other JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.IsNull(user.Account);
+            Assert.IsNull(user.RealName);
+        }
+
+        [TestMethod]
+        public void ExtendedJoinWithAccount()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan acc :realname"));
+            Assert.AreEqual("acc", _server.Account);
+            Assert.AreEqual("realname", _server.RealName);
+
+            _server.Parse(new Line(":other JOIN #chan acc2 :realname2"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("acc2", user.Account);
+            Assert.AreEqual("realname2", user.RealName);
+        }
+
+        [TestMethod]
+        public void ExtendedJoinWithoutAccount()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan * :realname"));
+            Assert.AreEqual("", _server.Account);
+            Assert.AreEqual("realname", _server.RealName);
+
+            _server.Parse(new Line(":other JOIN #chan * :realname2"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("", user.Account);
+            Assert.AreEqual("realname2", user.RealName);
+        }
+
+        [TestMethod]
+        public void AccountNotifyWithAccount()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":nickname ACCOUNT acc"));
+            Assert.AreEqual("acc", _server.Account);
+
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":other ACCOUNT acc2"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("acc2", user.Account);
+        }
+
+        [TestMethod]
+        public void AccountNotifyWithoutAccount()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":nickname ACCOUNT *"));
+            Assert.AreEqual("", _server.Account);
+
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":other ACCOUNT *"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("", user.Account);
+        }
+
+        [TestMethod]
+        public void HostmaskPrivmsgBoth()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":nickname!user@host PRIVMSG #chan :hi"));
+            Assert.AreEqual("user", _server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+
+            _server.Parse(new Line(":other!user@host PRIVMSG #chan :hi"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("user", user.UserName);
+            Assert.AreEqual("host", user.HostName);
+        }
+
+        [TestMethod]
+        public void HostmaskPrivmsgUser()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":nickname!user PRIVMSG #chan :hi"));
+            Assert.AreEqual("user", _server.UserName);
+            Assert.IsNull(_server.HostName);
+
+            _server.Parse(new Line(":other!user PRIVMSG #chan :hi"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("user", user.UserName);
+            Assert.IsNull(user.HostName);
+        }
+
+        [TestMethod]
+        public void HostmaskPrivmsgHost()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":nickname@host PRIVMSG #chan :hi"));
+            Assert.IsNull(_server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+
+            _server.Parse(new Line(":other@host PRIVMSG #chan :hi"));
+            var user = _server.Users["other"];
+            Assert.IsNull(user.UserName);
+            Assert.AreEqual("host", user.HostName);
+        }
+
+        [TestMethod]
+        public void VisibleHostWithoutUsername()
+        {
+            _server.Parse(new Line("396 * hostname"));
+            Assert.IsNull(_server.UserName);
+            Assert.AreEqual("hostname", _server.HostName);
+        }
+
+        [TestMethod]
+        public void VisibleHostWithUsername()
+        {
+            _server.Parse(new Line("396 * username@hostname"));
+            Assert.AreEqual("username", _server.UserName);
+            Assert.AreEqual("hostname", _server.HostName);
+        }
+
+        [TestMethod]
+        public void Who()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line("352 * #chan user host * nickname * :0 real"));
+            _server.Parse(new Line("352 * #chan user2 host2 * other * :0 real2"));
+
+            Assert.AreEqual("user", _server.UserName);
+            Assert.AreEqual("host", _server.HostName);
+            Assert.AreEqual("real", _server.RealName);
+
+            var user = _server.Users["other"];
+            Assert.AreEqual("user2", user.UserName);
+            Assert.AreEqual("host2", user.HostName);
+            Assert.AreEqual("real2", user.RealName);
+        }
+
+        [TestMethod]
+        public void Chghost()
+        {
+            _server.Parse(new Line(":nickname!user@host JOIN #chan"));
+            _server.Parse(new Line(":nickname CHGHOST u h"));
+            Assert.AreEqual("u", _server.UserName);
+            Assert.AreEqual("h", _server.HostName);
+
+            _server.Parse(new Line(":other!user2@host2 JOIN #chan"));
+            _server.Parse(new Line(":other CHGHOST u2 h2"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("u2", user.UserName);
+            Assert.AreEqual("h2", user.HostName);
+        }
+
+        [TestMethod]
+        public void Whois()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line("311 * nickname u h * :r"));
+            Assert.AreEqual("u", _server.UserName);
+            Assert.AreEqual("h", _server.HostName);
+            Assert.AreEqual("r", _server.RealName);
+
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":other CHGHOST u2 h2"));
+            _server.Parse(new Line("311 * other u2 h2 * :r2"));
+            var user = _server.Users["other"];
+            Assert.AreEqual("u2", user.UserName);
+            Assert.AreEqual("h2", user.HostName);
+            Assert.AreEqual("r2", user.RealName);
+        }
+
+        [TestMethod]
+        public void AwaySet()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":other JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.IsNull(_server.Away);
+            Assert.IsNull(user.Away);
+
+            _server.Parse(new Line(":nickname AWAY :bye bye"));
+            _server.Parse(new Line(":other AWAY :ich geh weg"));
+            Assert.AreEqual("bye bye", _server.Away);
+            Assert.AreEqual("ich geh weg", user.Away);
+        }
+
+        [TestMethod]
+        public void AwayUnset()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":other JOIN #chan"));
+            _server.Parse(new Line(":nickname AWAY :bye bye"));
+            _server.Parse(new Line(":nickname AWAY"));
+            _server.Parse(new Line(":other AWAY :ich geh weg"));
+            _server.Parse(new Line(":other AWAY"));
+
+            var user = _server.Users["other"];
+            Assert.IsNull(_server.Away);
+            Assert.IsNull(user.Away);
+        }
+
+        [TestMethod]
+        public void Setname()
+        {
+            _server.Parse(new Line(":nickname JOIN #chan"));
+            _server.Parse(new Line(":other JOIN #chan"));
+            var user = _server.Users["other"];
+            Assert.IsNull(user.RealName);
+            Assert.IsNull(_server.RealName);
+
+            _server.Parse(new Line(":nickname SETNAME :new now know how"));
+            _server.Parse(new Line(":other SETNAME :tyrannosaurus hex"));
+            Assert.AreEqual("new now know how", _server.RealName);
+            Assert.AreEqual("tyrannosaurus hex", user.RealName);
+        }
+    }
+}
diff --git a/IRCStates/Tests/Who.cs b/IRCStates/Tests/Who.cs
new file mode 100644
index 0000000..d091785
--- /dev/null
+++ b/IRCStates/Tests/Who.cs
@@ -0,0 +1,61 @@
+using IRCTokens;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace IRCStates.Tests
+{
+    [TestClass]
+    public class Who
+    {
+        private Server _server;
+
+        [TestInitialize]
+        public void TestInitialize()
+        {
+            _server = new Server("test");
+            _server.Parse(new Line("001 nickname"));
+            _server.Parse(new Line(":nickname JOIN #chan"));
+        }
+
+        [TestMethod]
+        public void WhoResponse()
+        {
+            _server.Parse(new Line("352 * #chan user host server nickname * :0 real"));
+            var user = _server.Users["nickname"];
+
+            Assert.AreEqual("user", user.UserName);
+            Assert.AreEqual("host", _server.HostName);
+            Assert.AreEqual("real", user.RealName);
+
+            Assert.AreEqual(user.UserName, _server.UserName);
+            Assert.AreEqual(user.HostName, _server.HostName);
+            Assert.AreEqual(user.RealName, _server.RealName);
+        }
+
+        [TestMethod]
+        public void Whox()
+        {
+            _server.Parse(new Line($"354 * {Server.WhoType} user realip host nickname account :real"));
+            var user = _server.Users["nickname"];
+
+            Assert.AreEqual("user", user.UserName);
+            Assert.AreEqual("host", user.HostName);
+            Assert.AreEqual("real", user.RealName);
+            Assert.AreEqual("account", user.Account);
+
+            Assert.AreEqual(user.UserName, _server.UserName);
+            Assert.AreEqual(user.HostName, _server.HostName);
+            Assert.AreEqual(user.RealName, _server.RealName);
+            Assert.AreEqual(user.Account, _server.Account);
+        }
+
+        [TestMethod]
+        public void WhoxNoAccount()
+        {
+            _server.Parse(new Line($"354 * {Server.WhoType} user realip host nickname 0 :real"));
+            var user = _server.Users["nickname"];
+
+            Assert.IsNull(user.Account);
+            Assert.AreEqual(user.Account, _server.Account);
+        }
+    }
+}
diff --git a/IRCStates/User.cs b/IRCStates/User.cs
new file mode 100644
index 0000000..5e18443
--- /dev/null
+++ b/IRCStates/User.cs
@@ -0,0 +1,33 @@
+using System.Collections.Generic;
+
+namespace IRCStates
+{
+    public class User
+    {
+        public User()
+        {
+            Channels = new HashSet<string>();
+        }
+
+        public string NickName { get; set; }
+        public string NickNameLower { get; set; }
+
+        public string UserName { get; set; }
+        public string HostName { get; set; }
+        public string RealName { get; set; }
+        public string Account { get; set; }
+        public string Away { get; set; }
+        public HashSet<string> Channels { get; set; }
+
+        public override string ToString()
+        {
+            return $"User(nickname={NickName})";
+        }
+
+        public void SetNickName(string nick, string nickLower)
+        {
+            NickName      = nick;
+            NickNameLower = nickLower;
+        }
+    }
+}