From 32d3731be9722273e9d3ab2b5de87b4fd089b9b8 Mon Sep 17 00:00:00 2001 From: Ben Harris Date: Fri, 19 Apr 2024 14:52:01 -0400 Subject: [PATCH] add more robust parsing tests --- ChatSharp/Events/IrcNoticeEventArgs.cs | 4 +- ChatSharp/Handlers/CapabilityHandlers.cs | 6 +- ChatSharp/Handlers/ChannelHandlers.cs | 16 +- ChatSharp/Handlers/ListingHandlers.cs | 8 +- ChatSharp/Handlers/MOTDHandlers.cs | 2 +- ChatSharp/Handlers/MessageHandlers.cs | 15 +- ChatSharp/Handlers/SaslHandlers.cs | 9 +- ChatSharp/Handlers/ServerHandlers.cs | 17 +- ChatSharp/Handlers/UserHandlers.cs | 8 +- ChatSharp/IrcClient.Commands.cs | 8 +- ChatSharp/IrcClient.cs | 20 +- ChatSharp/IrcMessage.cs | 251 ++++++++++++----- ChatSharp/IrcUser.cs | 58 ++-- ChatSharp/PrivateMessage.cs | 2 +- ChatSharp/ServerInfo.cs | 4 +- ChatSharpTests/ChatSharp.Tests.csproj | 13 + ChatSharpTests/Data/JoinModel.cs | 28 ++ ChatSharpTests/Data/SplitModel.cs | 12 + ChatSharpTests/Data/msg-join.yaml | 221 +++++++++++++++ ChatSharpTests/Data/msg-split.yaml | 343 +++++++++++++++++++++++ ChatSharpTests/IrcMessageTests.cs | 263 +++++++++-------- ChatSharpTests/IrcUserTests.cs | 101 ++++--- ChatSharpTests/ParsingTests.cs | 57 ++++ Example/Example.csproj | 4 + Example/Program.cs | 7 +- 25 files changed, 1119 insertions(+), 358 deletions(-) create mode 100644 ChatSharpTests/Data/JoinModel.cs create mode 100644 ChatSharpTests/Data/SplitModel.cs create mode 100644 ChatSharpTests/Data/msg-join.yaml create mode 100644 ChatSharpTests/Data/msg-split.yaml create mode 100644 ChatSharpTests/ParsingTests.cs diff --git a/ChatSharp/Events/IrcNoticeEventArgs.cs b/ChatSharp/Events/IrcNoticeEventArgs.cs index e8e64b9..a81fb0c 100644 --- a/ChatSharp/Events/IrcNoticeEventArgs.cs +++ b/ChatSharp/Events/IrcNoticeEventArgs.cs @@ -9,7 +9,7 @@ namespace ChatSharp.Events { internal IrcNoticeEventArgs(IrcMessage message) { - if (message.Parameters.Length != 2) + if (message.Parameters.Count != 2) throw new IrcProtocolException("NOTICE was delivered in incorrect format"); Message = message; } @@ -29,6 +29,6 @@ namespace ChatSharp.Events /// The source of the notice (often a user). /// /// The source. - public string Source => Message.Prefix; + public string Source => Message.Source; } } \ No newline at end of file diff --git a/ChatSharp/Handlers/CapabilityHandlers.cs b/ChatSharp/Handlers/CapabilityHandlers.cs index d5a87d3..800b486 100644 --- a/ChatSharp/Handlers/CapabilityHandlers.cs +++ b/ChatSharp/Handlers/CapabilityHandlers.cs @@ -59,7 +59,7 @@ namespace ChatSharp.Handlers // Check if the enabled capabilities count is the same as the ones // acknowledged by the server. if (client.IsNegotiatingCapabilities && - client.Capabilities.Enabled.Count() == acceptedCaps.Count() && !client.IsAuthenticatingSasl) + client.Capabilities.Enabled.Count() == acceptedCaps.Length && !client.IsAuthenticatingSasl) { client.SendRawMessage("CAP END"); client.IsNegotiatingCapabilities = false; @@ -76,7 +76,7 @@ namespace ChatSharp.Handlers // Check if the disabled capabilities count is the same as the ones // rejected by the server. if (client.IsNegotiatingCapabilities && - client.Capabilities.Disabled.Count() == rejectedCaps.Count()) + client.Capabilities.Disabled.Count() == rejectedCaps.Length) { client.SendRawMessage("CAP END"); client.IsNegotiatingCapabilities = false; @@ -103,7 +103,7 @@ namespace ChatSharp.Handlers wantCaps.AddRange(newCaps.Where(cap => client.Capabilities.Contains(cap) && !client.Capabilities[cap].IsEnabled)); - client.SendRawMessage(string.Format("CAP REQ :{0}", string.Join(" ", wantCaps))); + client.SendRawMessage($"CAP REQ :{string.Join(" ", wantCaps)}"); break; case "DEL": var disabledCaps = message.Parameters[2].Split(new[] { " " }, StringSplitOptions.RemoveEmptyEntries) diff --git a/ChatSharp/Handlers/ChannelHandlers.cs b/ChatSharp/Handlers/ChannelHandlers.cs index 9df3844..91d5728 100644 --- a/ChatSharp/Handlers/ChannelHandlers.cs +++ b/ChatSharp/Handlers/ChannelHandlers.cs @@ -10,7 +10,7 @@ namespace ChatSharp.Handlers { public static void HandleJoin(IrcClient client, IrcMessage message) { - var user = client.Users.GetOrAdd(message.Prefix); + var user = client.Users.GetOrAdd(message.Source); var channel = client.Channels.GetOrAdd(message.Parameters[0]); if (channel != null) @@ -51,7 +51,7 @@ namespace ChatSharp.Handlers if (!client.Channels.Contains(message.Parameters[0])) return; // we aren't in this channel, ignore - var user = client.Users.Get(message.Prefix); + var user = client.Users.Get(message.Source); var channel = client.Channels[message.Parameters[0]]; if (user.Channels.Contains(channel)) @@ -92,10 +92,7 @@ namespace ChatSharp.Handlers if (!user.Channels.Contains(channel)) user.Channels.Add(channel); - if (!user.ChannelModes.ContainsKey(channel)) - user.ChannelModes.Add(channel, modes); - else - user.ChannelModes[channel] = modes; + user.ChannelModes[channel] = modes; } } else @@ -117,10 +114,7 @@ namespace ChatSharp.Handlers if (!user.Channels.Contains(channel)) user.Channels.Add(channel); - if (!user.ChannelModes.ContainsKey(channel)) - user.ChannelModes.Add(channel, modes); - else - user.ChannelModes[channel] = modes; + user.ChannelModes[channel] = modes; } } } @@ -165,7 +159,7 @@ namespace ChatSharp.Handlers var kicked = channel.Users[message.Parameters[1]]; if (kicked.Channels.Contains(channel)) kicked.Channels.Remove(channel); - client.OnUserKicked(new KickEventArgs(channel, new IrcUser(message.Prefix), + client.OnUserKicked(new KickEventArgs(channel, new IrcUser(message.Source), kicked, message.Parameters[2])); } } diff --git a/ChatSharp/Handlers/ListingHandlers.cs b/ChatSharp/Handlers/ListingHandlers.cs index 151207a..9414bf4 100644 --- a/ChatSharp/Handlers/ListingHandlers.cs +++ b/ChatSharp/Handlers/ListingHandlers.cs @@ -4,7 +4,7 @@ namespace ChatSharp.Handlers { public static void HandleBanListPart(IrcClient client, IrcMessage message) { - var parameterString = message.RawMessage[message.RawMessage.IndexOf(' ')..]; + var parameterString = message.Format()[message.Format().IndexOf(' ')..]; var parameters = parameterString[parameterString.IndexOf(' ')..].Split(' '); var request = client.RequestManager.PeekOperation("GETMODE b " + parameters[1]); var list = (MaskCollection)request.State; @@ -20,7 +20,7 @@ namespace ChatSharp.Handlers public static void HandleExceptionListPart(IrcClient client, IrcMessage message) { - var parameterString = message.RawMessage[(message.RawMessage.IndexOf(' ') + 1)..]; + var parameterString = message.Format()[(message.Format().IndexOf(' ') + 1)..]; var parameters = parameterString[(parameterString.IndexOf(' ') + 1)..].Split(' '); var request = client.RequestManager.PeekOperation("GETMODE e " + parameters[1]); var list = (MaskCollection)request.State; @@ -36,7 +36,7 @@ namespace ChatSharp.Handlers public static void HandleInviteListPart(IrcClient client, IrcMessage message) { - var parameterString = message.RawMessage[(message.RawMessage.IndexOf(' ') + 1)..]; + var parameterString = message.Format()[(message.Format().IndexOf(' ') + 1)..]; var parameters = parameterString[(parameterString.IndexOf(' ') + 1)..].Split(' '); var request = client.RequestManager.PeekOperation("GETMODE I " + parameters[1]); var list = (MaskCollection)request.State; @@ -52,7 +52,7 @@ namespace ChatSharp.Handlers public static void HandleQuietListPart(IrcClient client, IrcMessage message) { - var parameterString = message.RawMessage[(message.RawMessage.IndexOf(' ') + 1)..]; + var parameterString = message.Format()[(message.Format().IndexOf(' ') + 1)..]; var parameters = parameterString[(parameterString.IndexOf(' ') + 1)..].Split(' '); var request = client.RequestManager.PeekOperation("GETMODE q " + parameters[1]); var list = (MaskCollection)request.State; diff --git a/ChatSharp/Handlers/MOTDHandlers.cs b/ChatSharp/Handlers/MOTDHandlers.cs index e75c056..0e5e248 100644 --- a/ChatSharp/Handlers/MOTDHandlers.cs +++ b/ChatSharp/Handlers/MOTDHandlers.cs @@ -14,7 +14,7 @@ namespace ChatSharp.Handlers public static void HandleMOTD(IrcClient client, IrcMessage message) { - if (message.Parameters.Length != 2) + if (message.Parameters.Count != 2) throw new IrcProtocolException("372 MOTD message is incorrectly formatted."); var part = message.Parameters[1][2..]; MOTD += part + Environment.NewLine; diff --git a/ChatSharp/Handlers/MessageHandlers.cs b/ChatSharp/Handlers/MessageHandlers.cs index e13d65d..3c6ce6c 100644 --- a/ChatSharp/Handlers/MessageHandlers.cs +++ b/ChatSharp/Handlers/MessageHandlers.cs @@ -92,7 +92,7 @@ namespace ChatSharp.Handlers public static void HandleNick(IrcClient client, IrcMessage message) { - var user = client.Users.Get(message.Prefix); + var user = client.Users.Get(message.Source); var oldNick = user.Nick; user.Nick = message.Parameters[0]; @@ -106,7 +106,7 @@ namespace ChatSharp.Handlers public static void HandleQuit(IrcClient client, IrcMessage message) { - var user = new IrcUser(message.Prefix); + var user = new IrcUser(message.Source); if (client.User.Nick != user.Nick) { client.Users.Remove(user); @@ -182,17 +182,16 @@ namespace ChatSharp.Handlers continue; } - if (channel.Mode == null) - channel.Mode = string.Empty; + channel.Mode ??= string.Empty; // TODO: Support the ones here that aren't done properly if (client.ServerInfo.SupportedChannelModes.ParameterizedSettings.Contains(c)) { - client.OnModeChanged(new ModeChangeEventArgs(channel.Name, new IrcUser(message.Prefix), + client.OnModeChanged(new ModeChangeEventArgs(channel.Name, new IrcUser(message.Source), (add ? "+" : "-") + c + " " + message.Parameters[i++])); } else if (client.ServerInfo.SupportedChannelModes.ChannelLists.Contains(c)) { - client.OnModeChanged(new ModeChangeEventArgs(channel.Name, new IrcUser(message.Prefix), + client.OnModeChanged(new ModeChangeEventArgs(channel.Name, new IrcUser(message.Source), (add ? "+" : "-") + c + " " + message.Parameters[i++])); } else if (client.ServerInfo.SupportedChannelModes.ChannelUserModes.Contains(c)) @@ -218,7 +217,7 @@ namespace ChatSharp.Handlers user.ChannelModes[channel] = null; } - client.OnModeChanged(new ModeChangeEventArgs(channel.Name, new IrcUser(message.Prefix), + client.OnModeChanged(new ModeChangeEventArgs(channel.Name, new IrcUser(message.Source), (add ? "+" : "-") + c + " " + message.Parameters[i++])); } @@ -234,7 +233,7 @@ namespace ChatSharp.Handlers channel.Mode = channel.Mode.Replace(c.ToString(), string.Empty); } - client.OnModeChanged(new ModeChangeEventArgs(channel.Name, new IrcUser(message.Prefix), + client.OnModeChanged(new ModeChangeEventArgs(channel.Name, new IrcUser(message.Source), (add ? "+" : "-") + c)); } } diff --git a/ChatSharp/Handlers/SaslHandlers.cs b/ChatSharp/Handlers/SaslHandlers.cs index 19bd880..ac2ffd3 100644 --- a/ChatSharp/Handlers/SaslHandlers.cs +++ b/ChatSharp/Handlers/SaslHandlers.cs @@ -19,13 +19,12 @@ namespace ChatSharp.Handlers { var chunk = b64Bytes.Take(400).ToArray(); b64Bytes = b64Bytes.Skip(400).ToArray(); - client.SendRawMessage(string.Format("AUTHENTICATE {0}", Encoding.UTF8.GetString(chunk))); + client.SendRawMessage($"AUTHENTICATE {Encoding.UTF8.GetString(chunk)}"); } - if (b64Bytes.Length > 0) - client.SendRawMessage(string.Format("AUTHENTICATE {0}", Encoding.UTF8.GetString(b64Bytes))); - else - client.SendRawMessage("AUTHENTICATE +"); + client.SendRawMessage(b64Bytes.Length > 0 + ? $"AUTHENTICATE {Encoding.UTF8.GetString(b64Bytes)}" + : "AUTHENTICATE +"); client.IsAuthenticatingSasl = false; } diff --git a/ChatSharp/Handlers/ServerHandlers.cs b/ChatSharp/Handlers/ServerHandlers.cs index f9ff955..016b2cf 100644 --- a/ChatSharp/Handlers/ServerHandlers.cs +++ b/ChatSharp/Handlers/ServerHandlers.cs @@ -6,8 +6,7 @@ namespace ChatSharp.Handlers { public static void HandleISupport(IrcClient client, IrcMessage message) { - if (client.ServerInfo == null) - client.ServerInfo = new ServerInfo(); + client.ServerInfo ??= new ServerInfo(); foreach (var item in message.Parameters) { string key, value; @@ -86,12 +85,11 @@ namespace ChatSharp.Handlers break; } else - switch (key.ToUpper()) + client.ServerInfo.ExtendedWho = key.ToUpper() switch { - case "WHOX": - client.ServerInfo.ExtendedWho = true; - break; - } + "WHOX" => true, + _ => client.ServerInfo.ExtendedWho + }; } client.OnServerInfoReceived(new SupportsEventArgs(client.ServerInfo)); @@ -101,9 +99,8 @@ namespace ChatSharp.Handlers { // 004 sendak.freenode.net ircd-seven-1.1.3 DOQRSZaghilopswz CFILMPQbcefgijklmnopqrstvz bkloveqjfI // TODO: Figure out how to properly handle this message - if (client.ServerInfo == null) - client.ServerInfo = new ServerInfo(); - if (message.Parameters.Length >= 5) + client.ServerInfo ??= new ServerInfo(); + if (message.Parameters.Count >= 5) foreach (var c in message.Parameters[4]) if (!client.ServerInfo.SupportedChannelModes.ChannelUserModes.Contains(c)) client.ServerInfo.SupportedChannelModes.ChannelUserModes += c.ToString(); diff --git a/ChatSharp/Handlers/UserHandlers.cs b/ChatSharp/Handlers/UserHandlers.cs index f9f963b..dc5dd4f 100644 --- a/ChatSharp/Handlers/UserHandlers.cs +++ b/ChatSharp/Handlers/UserHandlers.cs @@ -9,7 +9,7 @@ namespace ChatSharp.Handlers { public static void HandleWhoIsUser(IrcClient client, IrcMessage message) { - if (message.Parameters != null && message.Parameters.Length >= 6) + if (message.Parameters != null && message.Parameters.Count >= 6) { var whois = (WhoIs)client.RequestManager.PeekOperation("WHOIS " + message.Parameters[1]).State; whois.User.Nick = message.Parameters[1]; @@ -212,7 +212,7 @@ namespace ChatSharp.Handlers whox.User.RealName = message.Parameters[fieldIdx]; fieldIdx++; } - } while (fieldIdx < message.Parameters.Length - 1); + } while (fieldIdx < message.Parameters.Count - 1); whoxList.Add(whox); } @@ -255,13 +255,13 @@ namespace ChatSharp.Handlers public static void HandleAccount(IrcClient client, IrcMessage message) { - var user = client.Users.GetOrAdd(message.Prefix); + var user = client.Users.GetOrAdd(message.Source); user.Account = message.Parameters[0]; } public static void HandleChangeHost(IrcClient client, IrcMessage message) { - var user = client.Users.Get(message.Prefix); + var user = client.Users.Get(message.Source); // Only handle CHGHOST for users we know if (user != null) diff --git a/ChatSharp/IrcClient.Commands.cs b/ChatSharp/IrcClient.Commands.cs index 2bf35b8..845614c 100644 --- a/ChatSharp/IrcClient.Commands.cs +++ b/ChatSharp/IrcClient.Commands.cs @@ -85,9 +85,9 @@ namespace ChatSharp if (Channels.Contains(channel)) throw new InvalidOperationException("Client is already present in channel."); - var joinCmd = string.Format("JOIN {0}", channel); + var joinCmd = $"JOIN {channel}"; if (!string.IsNullOrEmpty(key)) - joinCmd += string.Format(" {0}", key); + joinCmd += $" {key}"; SendRawMessage(joinCmd, channel); @@ -187,7 +187,7 @@ namespace ChatSharp var whoQuery = string.Format("WHO {0} {1}%{2},{3}", target, flags.AsString(), _fields.AsString(), queryType); - var queryKey = string.Format("WHO {0} {1} {2:D}", target, queryType, _fields); + var queryKey = $"WHO {target} {queryType} {_fields:D}"; RequestManager.QueueOperation(queryKey, new RequestOperation(whox, ro => { callback?.Invoke((List)ro.State); })); @@ -197,7 +197,7 @@ namespace ChatSharp { var whox = new List(); - var whoQuery = string.Format("WHO {0}", target); + var whoQuery = $"WHO {target}"; RequestManager.QueueOperation(whoQuery, new RequestOperation(whox, ro => { callback?.Invoke((List)ro.State); })); diff --git a/ChatSharp/IrcClient.cs b/ChatSharp/IrcClient.cs index ef54af7..3cb5acc 100644 --- a/ChatSharp/IrcClient.cs +++ b/ChatSharp/IrcClient.cs @@ -91,10 +91,7 @@ namespace ChatSharp if (parts.Length > 2 || parts.Length == 0) throw new FormatException("Server address is not in correct format ('hostname:port')"); ServerHostname = parts[0]; - if (parts.Length > 1) - ServerPort = int.Parse(parts[1]); - else - ServerPort = 6667; + ServerPort = parts.Length > 1 ? int.Parse(parts[1]) : 6667; } } @@ -197,7 +194,7 @@ namespace ChatSharp /// public void ConnectAsync() { - if (Socket != null && Socket.Connected) + if (Socket is { Connected: true }) throw new InvalidOperationException("Socket is already connected to server."); Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); ReadBuffer = new byte[ReadBufferLength]; @@ -259,10 +256,9 @@ namespace ChatSharp NetworkStream = new NetworkStream(Socket); if (UseSSL) { - if (IgnoreInvalidSSL) - NetworkStream = new SslStream(NetworkStream, false, (a, b, c, d) => true); - else - NetworkStream = new SslStream(NetworkStream); + NetworkStream = IgnoreInvalidSSL + ? new SslStream(NetworkStream, false, (a, b, c, d) => true) + : new SslStream(NetworkStream); ((SslStream)NetworkStream).AuthenticateAsClient(ServerHostname); } @@ -334,9 +330,9 @@ namespace ChatSharp { OnRawMessageReceived(new RawMessageEventArgs(rawMessage, false)); var message = new IrcMessage(rawMessage); - if (Handlers.ContainsKey(message.Command.ToUpper())) + if (Handlers.TryGetValue(message.Command, out var handler)) { - Handlers[message.Command.ToUpper()](this, message); + handler(this, message); } } @@ -370,7 +366,7 @@ namespace ChatSharp /// public void SendIrcMessage(IrcMessage message) { - SendRawMessage(message.RawMessage); + SendRawMessage(message.Format()); } private void MessageSent(IAsyncResult result) diff --git a/ChatSharp/IrcMessage.cs b/ChatSharp/IrcMessage.cs index e15f49f..c1f0992 100644 --- a/ChatSharp/IrcMessage.cs +++ b/ChatSharp/IrcMessage.cs @@ -1,130 +1,243 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Text; namespace ChatSharp { + /// /// Represents a raw IRC message. This is a low-level construct - PrivateMessage is used /// to represent messages sent from users. /// - public class IrcMessage + public class IrcMessage : IEquatable { - /// - /// Initializes and decodes an IRC message, given the raw message from the server. - /// - public IrcMessage(string rawMessage) + private static readonly string[] TagUnescaped = {"\\", " ", ";", "\r", "\n"}; + private static readonly string[] TagEscaped = {@"\\", "\\s", "\\:", "\\r", "\\n"}; + + public IrcMessage() { - RawMessage = rawMessage; - Tags = Array.Empty>(); + } + + public IrcMessage(string command, params string[] parameters) + { + Command = command.ToUpperInvariant(); + Parameters = parameters.ToList(); + } + + /// + /// Parse and tokenize an IRC message, given the raw message from the server. + /// + public IrcMessage(string line) + { + if (string.IsNullOrWhiteSpace(line)) throw new ArgumentNullException(nameof(line)); - if (rawMessage.StartsWith("@")) + string[] split; + + if (line.StartsWith('@')) { - var rawTags = rawMessage[1..rawMessage.IndexOf(' ')]; - rawMessage = rawMessage[(rawMessage.IndexOf(' ') + 1)..]; + Tags = new Dictionary(); - // Parse tags as key value pairs - var tags = new List>(); - foreach (var rawTag in rawTags.Split(';')) - { - var replacedTag = rawTag.Replace(@"\:", ";"); - // The spec declares `@a=` as a tag with an empty value, while `@b;` as a tag with a null value - KeyValuePair tag = new KeyValuePair(replacedTag, null); + split = line.Split(" ", 2); + var messageTags = split[0]; + line = split[1]; - if (replacedTag.Contains("=")) + foreach (var part in messageTags[1..].Split(';')) + if (part.Contains('=', StringComparison.Ordinal)) { - var key = replacedTag.Substring(0, replacedTag.IndexOf("=", StringComparison.Ordinal)); - var value = replacedTag[(replacedTag.IndexOf("=", StringComparison.Ordinal) + 1)..]; - tag = new KeyValuePair(key, value); + split = part.Split('=', 2); + Tags[split[0]] = UnescapeTag(split[1]); + } + else + { + Tags[part] = null; } - - tags.Add(tag); - } - - Tags = tags.ToArray(); } - if (rawMessage.StartsWith(":")) + string trailing; + if (line.Contains(" :", StringComparison.Ordinal)) { - Prefix = rawMessage[1..rawMessage.IndexOf(' ')]; - rawMessage = rawMessage[(rawMessage.IndexOf(' ') + 1)..]; - } - - if (rawMessage.Contains(' ')) - { - Command = rawMessage.Remove(rawMessage.IndexOf(' ')); - rawMessage = rawMessage[(rawMessage.IndexOf(' ') + 1)..]; - // Parse parameters - var parameters = new List(); - while (!string.IsNullOrEmpty(rawMessage)) - { - if (rawMessage.StartsWith(":")) - { - parameters.Add(rawMessage[1..]); - break; - } - - if (!rawMessage.Contains(' ')) - { - parameters.Add(rawMessage); - rawMessage = string.Empty; - break; - } - - parameters.Add(rawMessage.Remove(rawMessage.IndexOf(' '))); - rawMessage = rawMessage[(rawMessage.IndexOf(' ') + 1)..]; - } - - Parameters = parameters.ToArray(); + split = line.Split(" :", 2); + line = split[0]; + trailing = split[1]; } else { - // Violates RFC 1459, but we'll parse it anyway - Command = rawMessage; - Parameters = Array.Empty(); + trailing = null; } + Parameters = line.Contains(' ', StringComparison.Ordinal) + ? line.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList() + : new List {line}; + + if (Parameters[0].StartsWith(':')) + { + Source = Parameters[0][1..]; + Parameters.RemoveAt(0); + } + + if (Parameters.Count > 0) + { + Command = Parameters[0].ToUpper(CultureInfo.InvariantCulture); + Parameters.RemoveAt(0); + } + + if (trailing != null) Parameters.Add(trailing); + // Parse server-time message tag. // Fallback to server-info if both znc.in/server-info and the former exists. // // znc.in/server-time tag - if (Tags.Any(tag => tag.Key == "t")) + if (Tags?.Any(tag => tag.Key == "t") ?? false) { var tag = Tags.SingleOrDefault(x => x.Key == "t"); Timestamp = new Timestamp(tag.Value, true); } // server-time tag - else if (Tags.Any(tag => tag.Key == "time")) + else if (Tags?.Any(tag => tag.Key == "time") ?? false) { var tag = Tags.SingleOrDefault(x => x.Key == "time"); Timestamp = new Timestamp(tag.Value); } } + + public bool Equals(IrcMessage other) + { + if (other == null) return false; + + return Format() == other.Format(); + } + + public override int GetHashCode() + { + return Format().GetHashCode(StringComparison.Ordinal); + } + + public override bool Equals(object obj) + { + return Equals(obj as IrcMessage); + } + + + /// + /// Unescape ircv3 tag + /// + /// escaped string + /// unescaped string + private static string UnescapeTag(string val) + { + var unescaped = new StringBuilder(); + + var graphemeIterator = StringInfo.GetTextElementEnumerator(val); + graphemeIterator.Reset(); + + while (graphemeIterator.MoveNext()) + { + var current = graphemeIterator.GetTextElement(); + + if (current == @"\") + try + { + graphemeIterator.MoveNext(); + var next = graphemeIterator.GetTextElement(); + var pair = current + next; + unescaped.Append(TagEscaped.Contains(pair) + ? TagUnescaped[Array.IndexOf(TagEscaped, pair)] + : next); + } + catch (InvalidOperationException) + { + // ignored + } + else + unescaped.Append(current); + } + + return unescaped.ToString(); + } /// - /// The unparsed message. + /// Escape strings for use in ircv3 tags /// - public string RawMessage { get; } + /// string to escape + /// escaped string + private static string EscapeTag(string val) + { + for (var i = 0; i < TagUnescaped.Length; ++i) + val = val?.Replace(TagUnescaped[i], TagEscaped[i], StringComparison.Ordinal); + + return val; + } + + /// + /// Formats self as a standards-compliant IRC line + /// + /// formatted irc line + public string Format() + { + var outs = new List(); + + if (Tags != null && Tags.Any()) + { + var tags = Tags.Keys + .OrderBy(k => k) + .Select(key => + string.IsNullOrWhiteSpace(Tags[key]) ? key : $"{key}={EscapeTag(Tags[key])}") + .ToList(); + + outs.Add($"@{string.Join(";", tags)}"); + } + + if (Source != null) outs.Add($":{Source}"); + + outs.Add(Command); + + if (Parameters != null && Parameters.Any()) + { + var last = Parameters[^1]; + var withoutLast = Parameters.SkipLast(1).ToList(); + + foreach (var p in withoutLast) + { + if (p.Contains(' ', StringComparison.Ordinal)) + throw new ArgumentException("non-last parameters cannot have spaces", p); + + if (p.StartsWith(':')) + throw new ArgumentException("non-last parameters cannot start with colon", p); + } + + outs.AddRange(withoutLast); + + if (string.IsNullOrWhiteSpace(last) || last.Contains(' ', StringComparison.Ordinal) || + last.StartsWith(':')) + last = $":{last}"; + + outs.Add(last); + } + + return string.Join(" ", outs); + } /// - /// The message prefix. + /// The message source. /// - public string Prefix { get; } + public string Source { get; set; } /// /// The message command. /// - public string Command { get; } + public string Command { get; set; } /// /// Additional parameters supplied with the message. /// - public string[] Parameters { get; } + public List Parameters { get; set; } /// /// The message tags. /// - public KeyValuePair[] Tags { get; } + public Dictionary Tags { get; set; } /// /// The message timestamp in ISO 8601 format. diff --git a/ChatSharp/IrcUser.cs b/ChatSharp/IrcUser.cs index 866b716..af40d56 100644 --- a/ChatSharp/IrcUser.cs +++ b/ChatSharp/IrcUser.cs @@ -8,6 +8,7 @@ namespace ChatSharp /// public class IrcUser : IEquatable { + private readonly string _source; internal IrcUser() { Channels = new ChannelCollection(); @@ -18,18 +19,29 @@ namespace ChatSharp /// /// Constructs an IrcUser given a hostmask or nick. /// - public IrcUser(string host) : this() + public IrcUser(string source) : this() { - if (!host.Contains("@") && !host.Contains("!")) + if (source == null) return; + + _source = source; + + if (source.Contains('@', StringComparison.Ordinal)) { - Nick = host; + var split = source.Split('@'); + + Nick = split[0]; + Hostname = split[1]; } else { - var mask = host.Split('@', '!'); - Nick = mask[0]; - User = mask[1]; - Hostname = mask.Length <= 2 ? "" : mask[2]; + Nick = source; + } + + if (Nick.Contains('!', StringComparison.Ordinal)) + { + var userSplit = Nick.Split('!'); + Nick = userSplit[0]; + User = userSplit[1]; } } @@ -44,22 +56,6 @@ namespace ChatSharp Mode = string.Empty; } - /// - /// Constructs an IRC user given a nick, user, and password. - /// - public IrcUser(string nick, string user, string password) : this(nick, user) - { - Password = password; - } - - /// - /// Constructs an IRC user given a nick, user, password, and real name. - /// - public IrcUser(string nick, string user, string password, string realName) : this(nick, user, password) - { - RealName = realName; - } - /// /// The user's nick. /// @@ -110,14 +106,15 @@ namespace ChatSharp /// /// This user's hostmask (nick!user@host). /// - public string Hostmask => Nick + "!" + User + "@" + Hostname; + public string Hostmask => $"{Nick}!{User}@{Hostname}"; /// /// True if this user is equal to another (compares hostmasks). /// public bool Equals(IrcUser other) { - return other.Hostmask == Hostmask; + if (other == null) return false; + return other._source == _source; } /// @@ -143,8 +140,7 @@ namespace ChatSharp /// public static bool Match(string mask, string value) { - if (value == null) - value = string.Empty; + value ??= string.Empty; var i = 0; var j = 0; for (; j < value.Length && i < mask.Length; j++) @@ -179,9 +175,7 @@ namespace ChatSharp /// public override bool Equals(object obj) { - if (obj is IrcUser user) - return Equals(user); - return false; + return Equals(obj as IrcUser); } /// @@ -189,7 +183,7 @@ namespace ChatSharp /// public override int GetHashCode() { - return Hostmask.GetHashCode(); + return _source.GetHashCode(StringComparison.Ordinal); } /// @@ -197,7 +191,7 @@ namespace ChatSharp /// public override string ToString() { - return Hostmask; + return _source; } } } \ No newline at end of file diff --git a/ChatSharp/PrivateMessage.cs b/ChatSharp/PrivateMessage.cs index 8404748..a420d33 100644 --- a/ChatSharp/PrivateMessage.cs +++ b/ChatSharp/PrivateMessage.cs @@ -12,7 +12,7 @@ namespace ChatSharp Source = message.Parameters[0]; Message = message.Parameters[1]; - User = client.Users.GetOrAdd(message.Prefix); + User = client.Users.GetOrAdd(message.Source); if (serverInfo.ChannelTypes.Any(c => Source.StartsWith(c.ToString()))) IsChannelMessage = true; else diff --git a/ChatSharp/ServerInfo.cs b/ChatSharp/ServerInfo.cs index bb5d5c1..b94b4c1 100644 --- a/ChatSharp/ServerInfo.cs +++ b/ChatSharp/ServerInfo.cs @@ -115,8 +115,8 @@ namespace ChatSharp public List GetModesForNick(string nick) { var supportedPrefixes = Prefixes[1]; - List modeList = new List(); - List nickPrefixes = new List(); + var modeList = new List(); + var nickPrefixes = new List(); foreach (var prefix in supportedPrefixes) if (nick.Contains(prefix)) diff --git a/ChatSharpTests/ChatSharp.Tests.csproj b/ChatSharpTests/ChatSharp.Tests.csproj index a0d8259..e93613f 100644 --- a/ChatSharpTests/ChatSharp.Tests.csproj +++ b/ChatSharpTests/ChatSharp.Tests.csproj @@ -2,7 +2,20 @@ net8.0 + + + + + + + + + + + PreserveNewest + + \ No newline at end of file diff --git a/ChatSharpTests/Data/JoinModel.cs b/ChatSharpTests/Data/JoinModel.cs new file mode 100644 index 0000000..417bfe9 --- /dev/null +++ b/ChatSharpTests/Data/JoinModel.cs @@ -0,0 +1,28 @@ +using YamlDotNet.Serialization; + +namespace ChatSharp.Tests.Data; + +public class JoinModel +{ + public List Tests { get; set; } + + public class Test + { + [YamlMember(Alias = "desc")] public string Description { get; set; } + + public Atoms Atoms { get; set; } + + public List Matches { get; set; } + } + + public class Atoms + { + public Dictionary Tags { get; set; } + + public string Source { get; set; } + + public string Verb { get; set; } + + public List Params { get; set; } + } +} \ No newline at end of file diff --git a/ChatSharpTests/Data/SplitModel.cs b/ChatSharpTests/Data/SplitModel.cs new file mode 100644 index 0000000..3a73911 --- /dev/null +++ b/ChatSharpTests/Data/SplitModel.cs @@ -0,0 +1,12 @@ +namespace ChatSharp.Tests.Data; + +public class SplitModel +{ + public List Tests { get; set; } + + public class Test + { + public string Input { get; set; } + public JoinModel.Atoms Atoms { get; set; } + } +} \ No newline at end of file diff --git a/ChatSharpTests/Data/msg-join.yaml b/ChatSharpTests/Data/msg-join.yaml new file mode 100644 index 0000000..d1d7429 --- /dev/null +++ b/ChatSharpTests/Data/msg-join.yaml @@ -0,0 +1,221 @@ +# IRC parser tests +# joining atoms into sendable messages + +# Written in 2015 by Daniel Oaks +# +# To the extent possible under law, the author(s) have dedicated all copyright +# and related and neighboring rights to this software to the public domain +# worldwide. This software is distributed without any warranty. +# +# You should have received a copy of the CC0 Public Domain Dedication along +# with this software. If not, see +# . + +# some of the tests here originate from grawity's test vectors, which is WTFPL v2 licensed +# https://github.com/grawity/code/tree/master/lib/tests +# some of the tests here originate from Mozilla's test vectors, which is public domain +# https://dxr.mozilla.org/comm-central/source/chat/protocols/irc/test/test_ircMessage.js +# some of the tests here originate from SaberUK's test vectors, which he's indicated I am free to include here +# https://github.com/SaberUK/ircparser/tree/master/test + +tests: + # the desc string holds a description of the test, if it exists + + # the atoms dict has the keys: + # * tags: tags dict + # tags with no value are an empty string + # * source: source string, without single leading colon + # * verb: verb string + # * params: params split up as a list + # if the params key does not exist, assume it is empty + # if any other keys do no exist, assume they are null + # a key that is null does not exist or is not specified with the + # given input string + + # matches is a list of messages that match + + # simple tests + - desc: Simple test with verb and params. + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "asdf" + matches: + - "foo bar baz asdf" + - "foo bar baz :asdf" + + # with no regular params + - desc: Simple test with source and no params. + atoms: + source: "src" + verb: "AWAY" + matches: + - ":src AWAY" + + - desc: Simple test with source and empty trailing param. + atoms: + source: "src" + verb: "AWAY" + params: + - "" + matches: + - ":src AWAY :" + + # with source + - desc: Simple test with source. + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "asdf" + matches: + - ":coolguy foo bar baz asdf" + - ":coolguy foo bar baz :asdf" + + # with trailing param + - desc: Simple test with trailing param. + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "asdf quux" + matches: + - "foo bar baz :asdf quux" + + - desc: Simple test with empty trailing param. + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "" + matches: + - "foo bar baz :" + + - desc: Simple test with trailing param containing colon. + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - ":asdf" + matches: + - "foo bar baz ::asdf" + + # with source and trailing param + - desc: Test with source and trailing param. + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "asdf quux" + matches: + - ":coolguy foo bar baz :asdf quux" + + - desc: Test with trailing containing beginning+end whitespace. + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - " asdf quux " + matches: + - ":coolguy foo bar baz : asdf quux " + + - desc: Test with trailing containing what looks like another trailing param. + atoms: + source: "coolguy" + verb: "PRIVMSG" + params: + - "bar" + - "lol :) " + matches: + - ":coolguy PRIVMSG bar :lol :) " + + - desc: Simple test with source and empty trailing. + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "" + matches: + - ":coolguy foo bar baz :" + + - desc: Trailing contains only spaces. + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - " " + matches: + - ":coolguy foo bar baz : " + + - desc: Param containing tab (tab is not considered SPACE for message splitting). + atoms: + source: "coolguy" + verb: "foo" + params: + - "b\tar" + - "baz" + matches: + - ":coolguy foo b\tar baz" + - ":coolguy foo b\tar :baz" + + # with tags + - desc: Tag with no value and space-filled trailing. + atoms: + tags: + "asd": "" + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - " " + matches: + - "@asd :coolguy foo bar baz : " + + - desc: Tags with escaped values. + atoms: + verb: "foo" + tags: + "a": "b\\and\nk" + "d": "gh;764" + matches: + - "@a=b\\\\and\\nk;d=gh\\:764 foo" + - "@d=gh\\:764;a=b\\\\and\\nk foo" + + - desc: Tags with escaped values and params. + atoms: + verb: "foo" + tags: + "a": "b\\and\nk" + "d": "gh;764" + params: + - "par1" + - "par2" + matches: + - "@a=b\\\\and\\nk;d=gh\\:764 foo par1 par2" + - "@a=b\\\\and\\nk;d=gh\\:764 foo par1 :par2" + - "@d=gh\\:764;a=b\\\\and\\nk foo par1 par2" + - "@d=gh\\:764;a=b\\\\and\\nk foo par1 :par2" + + - desc: Tag with long, strange values (including LF and newline). + atoms: + tags: + foo: "\\\\;\\s \r\n" + verb: "COMMAND" + matches: + - "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND" diff --git a/ChatSharpTests/Data/msg-split.yaml b/ChatSharpTests/Data/msg-split.yaml new file mode 100644 index 0000000..ed0a8e0 --- /dev/null +++ b/ChatSharpTests/Data/msg-split.yaml @@ -0,0 +1,343 @@ +# IRC parser tests +# splitting messages into usable atoms + +# Written in 2015 by Daniel Oaks +# +# To the extent possible under law, the author(s) have dedicated all copyright +# and related and neighboring rights to this software to the public domain +# worldwide. This software is distributed without any warranty. +# +# You should have received a copy of the CC0 Public Domain Dedication along +# with this software. If not, see +# . + +# some of the tests here originate from grawity's test vectors, which is WTFPL v2 licensed +# https://github.com/grawity/code/tree/master/lib/tests +# some of the tests here originate from Mozilla's test vectors, which is public domain +# https://dxr.mozilla.org/comm-central/source/chat/protocols/irc/test/test_ircMessage.js +# some of the tests here originate from SaberUK's test vectors, which he's indicated I am free to include here +# https://github.com/SaberUK/ircparser/tree/master/test + +# we follow RFC1459 with regards to multiple ascii spaces splitting atoms: +# The prefix, command, and all parameters are +# separated by one (or more) ASCII space character(s) (0x20). +# because doing it as RFC2812 says (strictly as a single ascii space) isn't sane + +tests: + # input is the string coming directly from the server to parse + + # the atoms dict has the keys: + # * tags: tags dict + # tags with no value are an empty string + # * source: source string, without single leading colon + # * verb: verb string + # * params: params split up as a list + # if the params key does not exist, assume it is empty + # if any other keys do no exist, assume they are null + # a key that is null does not exist or is not specified with the + # given input string + + # simple + - input: "foo bar baz asdf" + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "asdf" + + # with source + - input: ":coolguy foo bar baz asdf" + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "asdf" + + # with trailing param + - input: "foo bar baz :asdf quux" + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "asdf quux" + + - input: "foo bar baz :" + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - "" + + - input: "foo bar baz ::asdf" + atoms: + verb: "foo" + params: + - "bar" + - "baz" + - ":asdf" + + # with source and trailing param + - input: ":coolguy foo bar baz :asdf quux" + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "asdf quux" + + - input: ":coolguy foo bar baz : asdf quux " + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - " asdf quux " + + - input: ":coolguy PRIVMSG bar :lol :) " + atoms: + source: "coolguy" + verb: "PRIVMSG" + params: + - "bar" + - "lol :) " + + - input: ":coolguy foo bar baz :" + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - "" + + - input: ":coolguy foo bar baz : " + atoms: + source: "coolguy" + verb: "foo" + params: + - "bar" + - "baz" + - " " + + # with tags + - input: "@a=b;c=32;k;rt=ql7 foo" + atoms: + verb: "foo" + tags: + "a": "b" + "c": "32" + "k": + "rt": "ql7" + + # with escaped tags + - input: "@a=b\\\\and\\nk;c=72\\s45;d=gh\\:764 foo" + atoms: + verb: "foo" + tags: + "a": "b\\and\nk" + "c": "72 45" + "d": "gh;764" + + # with tags and source + - input: "@c;h=;a=b :quux ab cd" + atoms: + tags: + "c": + "h": "" + "a": "b" + source: "quux" + verb: "ab" + params: + - "cd" + + # different forms of last param + - input: ":src JOIN #chan" + atoms: + source: "src" + verb: "JOIN" + params: + - "#chan" + + - input: ":src JOIN :#chan" + atoms: + source: "src" + verb: "JOIN" + params: + - "#chan" + + # with and without last param + - input: ":src AWAY" + atoms: + source: "src" + verb: "AWAY" + + - input: ":src AWAY " + atoms: + source: "src" + verb: "AWAY" + + # tab is not considered + - input: ":cool\tguy foo bar baz" + atoms: + source: "cool\tguy" + verb: "foo" + params: + - "bar" + - "baz" + + # with weird control codes in the source + - input: ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz" + atoms: + source: "coolguy!ag@net\x035w\x03ork.admin" + verb: "PRIVMSG" + params: + - "foo" + - "bar baz" + + - input: ":coolguy!~ag@n\x02et\x0305w\x0fork.admin PRIVMSG foo :bar baz" + atoms: + source: "coolguy!~ag@n\x02et\x0305w\x0fork.admin" + verb: "PRIVMSG" + params: + - "foo" + - "bar baz" + + - input: "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :irc.example.com COMMAND param1 param2 :param3 param3" + atoms: + tags: + tag1: "value1" + tag2: + vendor1/tag3: "value2" + vendor2/tag4: "" + source: "irc.example.com" + verb: "COMMAND" + params: + - "param1" + - "param2" + - "param3 param3" + + - input: ":irc.example.com COMMAND param1 param2 :param3 param3" + atoms: + source: "irc.example.com" + verb: "COMMAND" + params: + - "param1" + - "param2" + - "param3 param3" + + - input: "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 COMMAND param1 param2 :param3 param3" + atoms: + tags: + tag1: "value1" + tag2: + vendor1/tag3: "value2" + vendor2/tag4: + verb: "COMMAND" + params: + - "param1" + - "param2" + - "param3 param3" + + - input: "COMMAND" + atoms: + verb: "COMMAND" + + # yaml encoding + slashes is fun + - input: "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND" + atoms: + tags: + foo: "\\\\;\\s \r\n" + verb: "COMMAND" + + # broken messages from unreal + - input: ":gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters" + atoms: + source: "gravel.mozilla.org" + verb: "432" + params: + - "#momo" + - "Erroneous Nickname: Illegal characters" + + - input: ":gravel.mozilla.org MODE #tckk +n " + atoms: + source: "gravel.mozilla.org" + verb: "MODE" + params: + - "#tckk" + - "+n" + + - input: ":services.esper.net MODE #foo-bar +o foobar " + atoms: + source: "services.esper.net" + verb: "MODE" + params: + - "#foo-bar" + - "+o" + - "foobar" + + # tag values should be parsed char-at-a-time to prevent wayward replacements. + - input: "@tag1=value\\\\ntest COMMAND" + atoms: + tags: + tag1: "value\\ntest" + verb: "COMMAND" + + # If a tag value has a slash followed by a character which doesn't need + # to be escaped, the slash should be dropped. + - input: "@tag1=value\\1 COMMAND" + atoms: + tags: + tag1: "value1" + verb: "COMMAND" + + # A slash at the end of a tag value should be dropped + - input: "@tag1=value1\\ COMMAND" + atoms: + tags: + tag1: "value1" + verb: "COMMAND" + + # Duplicate tags: Parsers SHOULD disregard all but the final occurence + - input: "@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND" + atoms: + tags: + tag1: "5" + tag2: "3" + tag3: "4" + verb: "COMMAND" + + # vendored tags can have the same name as a non-vendored tag + - input: "@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND" + atoms: + tags: + tag1: "5" + tag2: "3" + tag3: "4" + vendor/tag2: "8" + verb: "COMMAND" + + # Some parsers handle /MODE in a special way, make sure they do it right + - input: ":SomeOp MODE #channel :+i" + atoms: + source: "SomeOp" + verb: "MODE" + params: + - "#channel" + - "+i" + + - input: ":SomeOp MODE #channel +oo SomeUser :AnotherUser" + atoms: + source: "SomeOp" + verb: "MODE" + params: + - "#channel" + - "+oo" + - "SomeUser" + - "AnotherUser" diff --git a/ChatSharpTests/IrcMessageTests.cs b/ChatSharpTests/IrcMessageTests.cs index df92090..2f61886 100644 --- a/ChatSharpTests/IrcMessageTests.cs +++ b/ChatSharpTests/IrcMessageTests.cs @@ -1,143 +1,138 @@ -using System; -using System.Collections.Generic; -using Microsoft.VisualStudio.TestTools.UnitTesting; +namespace ChatSharp.Tests; -namespace ChatSharp.Tests +[TestClass] +public class IrcMessageTests { - [TestClass] - public class IrcMessageTests + [TestMethod] + public void NewValidMessage() { - [TestMethod] - public void NewValidMessage() + try { - try - { - _ = new IrcMessage(":user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); - } - catch (Exception e) - { - Assert.Fail("Expected no exception, got: {0}", e.Message); - } + _ = new IrcMessage(":user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); } - - [TestMethod] - public void NewValidMessage_Command() + catch (Exception e) { - IrcMessage fromMessage = new(":user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); - Assert.AreEqual(fromMessage.Command, "PRIVMSG"); - } - - [TestMethod] - public void NewValidMessage_Prefix() - { - IrcMessage fromMessage = new(":user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); - Assert.AreEqual(fromMessage.Prefix, "user!~ident@host"); - } - - [TestMethod] - public void NewValidMessage_Params() - { - IrcMessage fromMessage = new(@":user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); - var compareParams = new[] { "target", "Lorem ipsum dolor sit amet" }; - CollectionAssert.AreEqual(fromMessage.Parameters, compareParams); - } - - [TestMethod] - public void NewValidMessage_Tags() - { - IrcMessage fromMessage = - new("@a=123;b=456;c=789 :user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); - var compareTags = new[] - { - new KeyValuePair("a", "123"), - new KeyValuePair("b", "456"), - new KeyValuePair("c", "789") - }; - CollectionAssert.AreEqual(fromMessage.Tags, compareTags); - } - - [TestMethod] - public void NewValidMessage_Tags02() - { - IrcMessage fromMessage = new("@aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello"); - var compareTags = new[] - { - new KeyValuePair("aaa", "bbb"), - new KeyValuePair("ccc", null), - new KeyValuePair("example.com/ddd", "eee") - }; - CollectionAssert.AreEqual(fromMessage.Tags, compareTags); - } - - [TestMethod] - public void NewValidMessage_TagsWithSemicolon() - { - IrcMessage fromMessage = - new(@"@a=123\:456;b=456\:789;c=789\:123 :user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); - var compareTags = new[] - { - new KeyValuePair("a", "123;456"), - new KeyValuePair("b", "456;789"), - new KeyValuePair("c", "789;123") - }; - CollectionAssert.AreEqual(fromMessage.Tags, compareTags); - } - - [TestMethod] - public void NewValidMessage_TagsNoValue() - { - IrcMessage fromMessage = new("@a=;b :nick!ident@host.com PRIVMSG me :Hello"); - var compareTags = new[] - { - new KeyValuePair("a", ""), - new KeyValuePair("b", null) - }; - CollectionAssert.AreEqual(fromMessage.Tags, compareTags); - } - - [TestMethod] - public void Timestamp_CompareISOString() - { - IrcMessage[] messages = - { - new("@time=2011-10-19T16:40:51.620Z :Angel!angel@example.org PRIVMSG Wiz :Hello"), - new("@time=2012-06-30T23:59:59.419Z :John!~john@1.2.3.4 JOIN #chan") - }; - - string[] timestamps = - { - "2011-10-19T16:40:51.620Z", - "2012-06-30T23:59:59.419Z" - }; - - Assert.AreEqual(messages[0].Timestamp.ToISOString(), timestamps[0]); - Assert.AreEqual(messages[1].Timestamp.ToISOString(), timestamps[1]); - } - - [TestMethod] - public void Timestamp_FromTimestamp() - { - IrcMessage[] messages = - [ - new("@t=1504923966 :Angel!angel@example.org PRIVMSG Wiz :Hello"), - new("@t=1504923972 :John!~john@1.2.3.4 JOIN #chan") - ]; - - string[] timestamps = - [ - "2017-09-09T02:26:06.000Z", - "2017-09-09T02:26:12.000Z" - ]; - - Assert.AreEqual(messages[0].Timestamp.ToISOString(), timestamps[0]); - Assert.AreEqual(messages[1].Timestamp.ToISOString(), timestamps[1]); - } - - [TestMethod] - public void Timestamp_FailOnLeap() - { - Assert.ThrowsException(() => - new IrcMessage("@time=2012-06-30T23:59:60.419Z :John!~john@1.2.3.4 JOIN #chan")); + Assert.Fail("Expected no exception, got: {0}", e.Message); } } + + [TestMethod] + public void NewValidMessage_Command() + { + IrcMessage fromMessage = new(":user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); + Assert.AreEqual("PRIVMSG", fromMessage.Command); + } + + [TestMethod] + public void NewValidMessage_Prefix() + { + IrcMessage fromMessage = new(":user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); + Assert.AreEqual("user!~ident@host", fromMessage.Source); + } + + [TestMethod] + public void NewValidMessage_Params() + { + IrcMessage fromMessage = new(":user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); + var compareParams = new[] { "target", "Lorem ipsum dolor sit amet" }; + CollectionAssert.AreEqual(compareParams, fromMessage.Parameters); + } + + [TestMethod] + public void NewValidMessage_Tags() + { + IrcMessage fromMessage = + new("@a=123;b=456;c=789 :user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); + var compareTags = new[] + { + new KeyValuePair("a", "123"), + new KeyValuePair("b", "456"), + new KeyValuePair("c", "789") + }; + CollectionAssert.AreEqual(compareTags, fromMessage.Tags); + } + + [TestMethod] + public void NewValidMessage_Tags02() + { + IrcMessage fromMessage = new("@aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello"); + var compareTags = new[] + { + new KeyValuePair("aaa", "bbb"), + new KeyValuePair("ccc", null), + new KeyValuePair("example.com/ddd", "eee") + }; + CollectionAssert.AreEqual(fromMessage.Tags, compareTags); + } + + [TestMethod] + public void NewValidMessage_TagsWithSemicolon() + { + IrcMessage fromMessage = + new(@"@a=123\:456;b=456\:789;c=789\:123 :user!~ident@host PRIVMSG target :Lorem ipsum dolor sit amet"); + var compareTags = new[] + { + new KeyValuePair("a", "123;456"), + new KeyValuePair("b", "456;789"), + new KeyValuePair("c", "789;123") + }; + CollectionAssert.AreEqual(fromMessage.Tags, compareTags); + } + + [TestMethod] + public void NewValidMessage_TagsNoValue() + { + IrcMessage fromMessage = new("@a=;b :nick!ident@host.com PRIVMSG me :Hello"); + var compareTags = new[] + { + new KeyValuePair("a", ""), + new KeyValuePair("b", null) + }; + CollectionAssert.AreEqual(fromMessage.Tags, compareTags); + } + + [TestMethod] + public void Timestamp_CompareISOString() + { + IrcMessage[] messages = + [ + new("@time=2011-10-19T16:40:51.620Z :Angel!angel@example.org PRIVMSG Wiz :Hello"), + new("@time=2012-06-30T23:59:59.419Z :John!~john@1.2.3.4 JOIN #chan") + ]; + + string[] timestamps = + [ + "2011-10-19T16:40:51.620Z", + "2012-06-30T23:59:59.419Z" + ]; + + Assert.AreEqual(messages[0].Timestamp.ToISOString(), timestamps[0]); + Assert.AreEqual(messages[1].Timestamp.ToISOString(), timestamps[1]); + } + + [TestMethod] + public void Timestamp_FromTimestamp() + { + IrcMessage[] messages = + [ + new("@t=1504923966 :Angel!angel@example.org PRIVMSG Wiz :Hello"), + new("@t=1504923972 :John!~john@1.2.3.4 JOIN #chan") + ]; + + string[] timestamps = + [ + "2017-09-09T02:26:06.000Z", + "2017-09-09T02:26:12.000Z" + ]; + + Assert.AreEqual(messages[0].Timestamp.ToISOString(), timestamps[0]); + Assert.AreEqual(messages[1].Timestamp.ToISOString(), timestamps[1]); + } + + [TestMethod] + public void Timestamp_FailOnLeap() + { + Assert.ThrowsException(() => + new IrcMessage("@time=2012-06-30T23:59:60.419Z :John!~john@1.2.3.4 JOIN #chan")); + } } \ No newline at end of file diff --git a/ChatSharpTests/IrcUserTests.cs b/ChatSharpTests/IrcUserTests.cs index bc6b229..7d8036d 100644 --- a/ChatSharpTests/IrcUserTests.cs +++ b/ChatSharpTests/IrcUserTests.cs @@ -1,74 +1,71 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +namespace ChatSharp.Tests; -namespace ChatSharp.Tests +[TestClass] +public class IrcUserTests { - [TestClass] - public class IrcUserTests + [TestMethod] + public void GetUserModes_NotNull_FiveModes() { - [TestMethod] - public void GetUserModes_NotNull_FiveModes() - { - IrcUser user = new("~&@%+aji", "user"); - IrcClient client = new("irc.address", user); + IrcUser user = new("~&@%+aji", "user"); + IrcClient client = new("irc.address", user); - var userModes = client.ServerInfo.GetModesForNick(user.Nick); + var userModes = client.ServerInfo.GetModesForNick(user.Nick); - Assert.IsTrue(userModes.Count == 5); - } + Assert.IsTrue(userModes.Count == 5); + } - [TestMethod] - public void GetUserModes_NotNull_FourModes() - { - IrcUser user = new("&@%+aji", "user"); - IrcClient client = new("irc.address", user); + [TestMethod] + public void GetUserModes_NotNull_FourModes() + { + IrcUser user = new("&@%+aji", "user"); + IrcClient client = new("irc.address", user); - var userModes = client.ServerInfo.GetModesForNick(user.Nick); + var userModes = client.ServerInfo.GetModesForNick(user.Nick); - Assert.IsTrue(userModes.Count == 4); - } + Assert.IsTrue(userModes.Count == 4); + } - [TestMethod] - public void GetUserModes_NotNull_ThreeModes() - { - IrcUser user = new("@%+aji", "user"); - IrcClient client = new("irc.address", user); + [TestMethod] + public void GetUserModes_NotNull_ThreeModes() + { + IrcUser user = new("@%+aji", "user"); + IrcClient client = new("irc.address", user); - var userModes = client.ServerInfo.GetModesForNick(user.Nick); + var userModes = client.ServerInfo.GetModesForNick(user.Nick); - Assert.IsTrue(userModes.Count == 3); - } + Assert.IsTrue(userModes.Count == 3); + } - [TestMethod] - public void GetUserModes_NotNull_TwoModes() - { - IrcUser user = new("%+aji", "user"); - IrcClient client = new("irc.address", user); + [TestMethod] + public void GetUserModes_NotNull_TwoModes() + { + IrcUser user = new("%+aji", "user"); + IrcClient client = new("irc.address", user); - var userModes = client.ServerInfo.GetModesForNick(user.Nick); + var userModes = client.ServerInfo.GetModesForNick(user.Nick); - Assert.IsTrue(userModes.Count == 2); - } + Assert.IsTrue(userModes.Count == 2); + } - [TestMethod] - public void GetUserModes_NotNull_OneMode() - { - IrcUser user = new("+aji", "user"); - IrcClient client = new("irc.address", user); + [TestMethod] + public void GetUserModes_NotNull_OneMode() + { + IrcUser user = new("+aji", "user"); + IrcClient client = new("irc.address", user); - var userModes = client.ServerInfo.GetModesForNick(user.Nick); + var userModes = client.ServerInfo.GetModesForNick(user.Nick); - Assert.IsTrue(userModes.Count == 1); - } + Assert.IsTrue(userModes.Count == 1); + } - [TestMethod] - public void GetUserModes_IsNull() - { - IrcUser user = new("aji", "user"); - IrcClient client = new("irc.address", user); + [TestMethod] + public void GetUserModes_IsNull() + { + IrcUser user = new("aji", "user"); + IrcClient client = new("irc.address", user); - var userModes = client.ServerInfo.GetModesForNick(user.Nick); + var userModes = client.ServerInfo.GetModesForNick(user.Nick); - Assert.IsTrue(userModes.Count == 0); - } + Assert.IsTrue(userModes.Count == 0); } } \ No newline at end of file diff --git a/ChatSharpTests/ParsingTests.cs b/ChatSharpTests/ParsingTests.cs new file mode 100644 index 0000000..398d7af --- /dev/null +++ b/ChatSharpTests/ParsingTests.cs @@ -0,0 +1,57 @@ +using System.Globalization; +using System.IO; +using ChatSharp.Tests.Data; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace ChatSharp.Tests; + +[TestClass] +public class ParsingTests +{ + private static T LoadYaml(string path) + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + return deserializer.Deserialize(File.ReadAllText(path)); + } + + [TestMethod] + public void Split() + { + foreach (var test in LoadYaml("Data/msg-split.yaml").Tests) + { + var message = new IrcMessage(test.Input); + var atoms = test.Atoms; + + Assert.AreEqual(atoms.Verb.ToUpper(CultureInfo.InvariantCulture), message.Command, + $"command failed on: '{test.Input}'"); + Assert.AreEqual(atoms.Source, message.Source, + $"source failed on: '{test.Input}' "); + CollectionAssert.AreEqual(atoms.Tags, message.Tags, + $"tags failed on: '{test.Input}' "); + CollectionAssert.AreEqual(atoms.Params ?? [], message.Parameters, + $"params failed on: '{test.Input}' "); + } + } + + [TestMethod] + public void Join() + { + foreach (var test in LoadYaml("Data/msg-join.yaml").Tests) + { + var atoms = test.Atoms; + var line = new IrcMessage + { + Command = atoms.Verb, + Parameters = atoms.Params, + Source = atoms.Source, + Tags = atoms.Tags, + }.Format(); + + Assert.IsTrue(test.Matches.Contains(line), test.Description); + } + } +} \ No newline at end of file diff --git a/Example/Example.csproj b/Example/Example.csproj index 07398ed..4a3c26f 100644 --- a/Example/Example.csproj +++ b/Example/Example.csproj @@ -6,6 +6,10 @@ enable enable + + + + diff --git a/Example/Program.cs b/Example/Program.cs index 96dd77e..238c38a 100644 --- a/Example/Program.cs +++ b/Example/Program.cs @@ -1,6 +1,4 @@ -using ChatSharp; - -var client = new IrcClient("irc.libera.chat", new("chatsharp", "chatsharp")); +var client = new IrcClient("irc.libera.chat", new("chatsharp")); client.ConnectionComplete += (_, _) => client.JoinChannel("##chatsharp"); client.ChannelMessageReceived += (s, e) => @@ -12,7 +10,7 @@ client.ChannelMessageReceived += (s, e) => } else if (e.PrivateMessage.Message.StartsWith(".ban ")) { - if (!channel.UsersByMode['@'].Contains(client.User)) + if (!channel.UsersByMode?['@'].Contains(client.User) ?? false) { channel.SendMessage("I'm not an op here!"); return; @@ -21,6 +19,7 @@ client.ChannelMessageReceived += (s, e) => var target = e.PrivateMessage.Message[5..]; client.WhoIs(target, whois => channel.ChangeMode($"+b *!*@{whois.User.Hostname}")); } + Console.WriteLine($"> {e.IrcMessage.Format()}"); }; client.ConnectAsync();