diff --git a/const.go b/const.go deleted file mode 100644 index 77b1c71..0000000 --- a/const.go +++ /dev/null @@ -1,177 +0,0 @@ -package main - -type command string - -type channelType int - -const ( - REGULAR channelType = iota - LOCAL -) - -// client commands -const ( - CAP command = "CAP" - AUTHENTICATE command = "AUTHENTICATE" - PASS command = "PASS" - NICK command = "NICK" - USER command = "USER" - OPER command = "OPER" - QUIT command = "QUIT" - - JOIN command = "JOIN" - PART command = "PART" - TOPIC command = "TOPIC" - NAMES command = "NAMES" - LIST command = "LIST" - INVITE command = "INVITE" - KICK command = "KICK" - - MOTD command = "MOTD" - VERSION command = "VERSION" - ADMIN command = "ADMIN" - CONNECT command = "CONNECT" - TIME command = "TIME" - STATS command = "STATS" - INFO command = "INFO" - MODE command = "MODE" - - PRIVMSG command = "PRIVMSG" - NOTICE command = "NOTICE" - - KILL command = "KILL" - - AWAY command = "AWAY" - USERHOST command = "USERHOST" -) - -// numerics -const ( - RPL_WELCOME command = "001" - RPL_YOURHOST command = "002" - RPL_CREATED command = "003" - RPL_MYINFO command = "004" - RPL_ISUPPORT command = "005" - - RPL_BOUNCE command = "010" - - RPL_UMODEIS command = "221" - RPL_LUSERCLIENT command = "251" - RPL_LUSEROP command = "252" - RPL_LUSERUNKNOWN command = "253" - RPL_LUSERCHANNELS command = "254" - RPL_LUSERME command = "255" - RPL_ADMINME command = "256" - RPL_ADMINLOC1 command = "257" - RPL_ADMINLOC2 command = "258" - RPL_ADMINEMAIL command = "259" - - RPL_TRYAGAIN command = "263" - - RPL_LOCALUSERS command = "265" - RPL_GLOBALUSERS command = "266" - - RPL_WHOISCERTFP command = "276" - - RPL_NONE command = "300" - - RPL_AWAY command = "301" - RPL_USERHOST command = "302" - RPL_ISON command = "303" - RPL_UNAWAY command = "305" - RPL_NOWAWAY command = "306" - - RPL_WHOISUSER command = "311" - RPL_WHOISSERVER command = "312" - RPL_WHOISOPERATOR command = "313" - RPL_WHOWASUSER command = "314" - RPL_WHOISIDLE command = "317" - RPL_ENDOFWHOIS command = "318" - RPL_WHOISCHANNELS command = "319" - - RPL_LISTSTART command = "321" - RPL_LIST command = "322" - RPL_LISTEND command = "323" - - RPL_CHANNELMODEIS command = "324" - RPL_CREATIONTIME command = "329" - RPL_NOTOPIC command = "331" - RPL_TOPIC command = "332" - RPL_TOPICWHOTIME command = "333" - - RPL_INVITING command = "341" - RPL_INVITELIST command = "346" - RPL_ENDOFINVITELIST command = "347" - RPL_EXCEPTLIST command = "348" - RPL_ENDOFEXCEPTLIST command = "349" - - RPL_VERSION command = "351" - - RPL_NAMREPLY command = "353" - RPL_ENDOFNAMES command = "366" - RPL_BANLIST command = "367" - RPL_ENDOFBANLIST command = "368" - RPL_ENDOFWHOWAS command = "369" - - RPL_MOTD command = "372" - RPL_MOTDSTART command = "375" - RPL_ENDOFMOTD command = "376" // see also ERR_NOMOTD - - RPL_YOUREOPER command = "381" - - RPL_REHASHING command = "382" - - ERR_UNKNOWNERROR command = "400" - ERR_NOSUCHNICK command = "401" - ERR_NOSUCHSERVER command = "402" - ERR_NOSUCHCHANNEL command = "403" - ERR_CANNOTSENDTOCHAN command = "404" - ERR_TOOMANYCHANNELS command = "405" - - ERR_UNKNOWNCOMMAND command = "421" - - ERR_NOMOTD command = "422" - - ERR_ERRONEUSNICKNAME command = "432" - ERR_NICKNAMEINUSE command = "433" - - ERR_USERNOTINCHANNEL command = "441" - ERR_NOTONCHANNEL command = "442" - ERR_USERONCHANNEL command = "443" - - ERR_NOTREGISTERED command = "451" - - ERR_NEEDMOREPARAMS command = "461" - ERR_ALREADYREGISTERED command = "462" - ERR_PASSWDMISMATCH command = "464" - ERR_YOUREBANNEDCREEP command = "465" - ERR_CHANNELISFULL command = "471" - ERR_UNKNOWNMODE command = "472" - ERR_INVITEONLYCHAN command = "473" - ERR_BANNEDFROMCHAN command = "474" - ERR_BADCHANNELKEY command = "475" - ERR_BADCHANMASK command = "476" - - ERR_NOPRIVILEGES command = "481" - ERR_CHANOPRIVSNEEDED command = "482" - ERR_CANTKILLSERVER command = "483" - ERR_NOOPERHOST command = "491" - - ERR_UMODEUNKNOWNFLAG command = "501" - ERR_USERSDONTMATCH command = "502" - - RPL_STARTTLS command = "670" - ERR_STARTTLS command = "691" - - ERR_NOPRIVS command = "723" - - RPL_LOGGEDIN command = "900" - RPL_LOGGEDOUT command = "901" - ERR_NICKLOCKED command = "902" - RPL_SASLSUCCESS command = "903" - ERR_SASLFAIL command = "904" - ERR_SASLTOOLONG command = "905" - ERR_SASLABORTED command = "906" - ERR_SASLALREADY command = "907" - RPL_SASLMECHS command = "908" -) diff --git a/message/const.go b/message/const.go new file mode 100644 index 0000000..788d543 --- /dev/null +++ b/message/const.go @@ -0,0 +1,170 @@ +package message + +type Command string + +// client commands +const ( + CAP Command = "CAP" + AUTHENTICATE Command = "AUTHENTICATE" + PASS Command = "PASS" + NICK Command = "NICK" + USER Command = "USER" + OPER Command = "OPER" + QUIT Command = "QUIT" + + JOIN Command = "JOIN" + PART Command = "PART" + TOPIC Command = "TOPIC" + NAMES Command = "NAMES" + LIST Command = "LIST" + INVITE Command = "INVITE" + KICK Command = "KICK" + + MOTD Command = "MOTD" + VERSION Command = "VERSION" + ADMIN Command = "ADMIN" + CONNECT Command = "CONNECT" + TIME Command = "TIME" + STATS Command = "STATS" + INFO Command = "INFO" + MODE Command = "MODE" + + PRIVMSG Command = "PRIVMSG" + NOTICE Command = "NOTICE" + + KILL Command = "KILL" + + AWAY Command = "AWAY" + USERHOST Command = "USERHOST" +) + +// numerics +const ( + RPL_WELCOME Command = "001" + RPL_YOURHOST Command = "002" + RPL_CREATED Command = "003" + RPL_MYINFO Command = "004" + RPL_ISUPPORT Command = "005" + + RPL_BOUNCE Command = "010" + + RPL_UMODEIS Command = "221" + RPL_LUSERCLIENT Command = "251" + RPL_LUSEROP Command = "252" + RPL_LUSERUNKNOWN Command = "253" + RPL_LUSERCHANNELS Command = "254" + RPL_LUSERME Command = "255" + RPL_ADMINME Command = "256" + RPL_ADMINLOC1 Command = "257" + RPL_ADMINLOC2 Command = "258" + RPL_ADMINEMAIL Command = "259" + + RPL_TRYAGAIN Command = "263" + + RPL_LOCALUSERS Command = "265" + RPL_GLOBALUSERS Command = "266" + + RPL_WHOISCERTFP Command = "276" + + RPL_NONE Command = "300" + + RPL_AWAY Command = "301" + RPL_USERHOST Command = "302" + RPL_ISON Command = "303" + RPL_UNAWAY Command = "305" + RPL_NOWAWAY Command = "306" + + RPL_WHOISUSER Command = "311" + RPL_WHOISSERVER Command = "312" + RPL_WHOISOPERATOR Command = "313" + RPL_WHOWASUSER Command = "314" + RPL_WHOISIDLE Command = "317" + RPL_ENDOFWHOIS Command = "318" + RPL_WHOISCHANNELS Command = "319" + + RPL_LISTSTART Command = "321" + RPL_LIST Command = "322" + RPL_LISTEND Command = "323" + + RPL_CHANNELMODEIS Command = "324" + RPL_CREATIONTIME Command = "329" + RPL_NOTOPIC Command = "331" + RPL_TOPIC Command = "332" + RPL_TOPICWHOTIME Command = "333" + + RPL_INVITING Command = "341" + RPL_INVITELIST Command = "346" + RPL_ENDOFINVITELIST Command = "347" + RPL_EXCEPTLIST Command = "348" + RPL_ENDOFEXCEPTLIST Command = "349" + + RPL_VERSION Command = "351" + + RPL_NAMREPLY Command = "353" + RPL_ENDOFNAMES Command = "366" + RPL_BANLIST Command = "367" + RPL_ENDOFBANLIST Command = "368" + RPL_ENDOFWHOWAS Command = "369" + + RPL_MOTD Command = "372" + RPL_MOTDSTART Command = "375" + RPL_ENDOFMOTD Command = "376" // see also ERR_NOMOTD + + RPL_YOUREOPER Command = "381" + + RPL_REHASHING Command = "382" + + ERR_UNKNOWNERROR Command = "400" + ERR_NOSUCHNICK Command = "401" + ERR_NOSUCHSERVER Command = "402" + ERR_NOSUCHCHANNEL Command = "403" + ERR_CANNOTSENDTOCHAN Command = "404" + ERR_TOOMANYCHANNELS Command = "405" + + ERR_UNKNOWNCOMMAND Command = "421" + + ERR_NOMOTD Command = "422" + + ERR_ERRONEUSNICKNAME Command = "432" + ERR_NICKNAMEINUSE Command = "433" + + ERR_USERNOTINCHANNEL Command = "441" + ERR_NOTONCHANNEL Command = "442" + ERR_USERONCHANNEL Command = "443" + + ERR_NOTREGISTERED Command = "451" + + ERR_NEEDMOREPARAMS Command = "461" + ERR_ALREADYREGISTERED Command = "462" + ERR_PASSWDMISMATCH Command = "464" + ERR_YOUREBANNEDCREEP Command = "465" + ERR_CHANNELISFULL Command = "471" + ERR_UNKNOWNMODE Command = "472" + ERR_INVITEONLYCHAN Command = "473" + ERR_BANNEDFROMCHAN Command = "474" + ERR_BADCHANNELKEY Command = "475" + ERR_BADCHANMASK Command = "476" + + ERR_NOPRIVILEGES Command = "481" + ERR_CHANOPRIVSNEEDED Command = "482" + ERR_CANTKILLSERVER Command = "483" + ERR_NOOPERHOST Command = "491" + + ERR_UMODEUNKNOWNFLAG Command = "501" + ERR_USERSDONTMATCH Command = "502" + + RPL_STARTTLS Command = "670" + ERR_STARTTLS Command = "691" + + ERR_NOPRIVS Command = "723" + + RPL_LOGGEDIN Command = "900" + RPL_LOGGEDOUT Command = "901" + ERR_NICKLOCKED Command = "902" + RPL_SASLSUCCESS Command = "903" + ERR_SASLFAIL Command = "904" + ERR_SASLTOOLONG Command = "905" + ERR_SASLABORTED Command = "906" + ERR_SASLALREADY Command = "907" + RPL_SASLMECHS Command = "908" +) diff --git a/message/message.go b/message/message.go new file mode 100644 index 0000000..2b29e4f --- /dev/null +++ b/message/message.go @@ -0,0 +1,273 @@ +package message + +import ( + "bytes" + "strings" +) + +type Tag struct { + Key string + Value string +} + +// Tags is an key-order-preserving, last-insert-wins "set" of Tag{}s +type Tags struct { + kv map[string]int // quick lookups for inserts + tags []Tag +} + +// Insert adds the given tag to the set, overriding any existing tag with that Key +func (t *Tags) Insert(new Tag) { + if t.kv == nil { + t.kv = map[string]int{} + } + i, ok := t.kv[new.Key] + if ok { + t.tags[i] = new + } else { + t.kv[new.Key] = len(t.tags) + t.tags = append(t.tags, new) + } +} + +// GetSlice returns a simple slice of tags from the set +func (t *Tags) GetSlice() []Tag { + return t.tags +} + +// A Message represents a parsed set of Tags/Source/Command/Parameters from a Raw string. +// +// Tags/Source/Command/Parameters are all set from the Raw after ParseMessage() is called. +// Calling ParseMessage multiple times (without changing Raw) should be a no-op. +// +// Canonicalize() returns Tags/Source/Command/Parameters transformed back into an RFC-compliant []byte. +// +// Reusing a message struct to parse multiple Raw messages is possible, but frowned upon. +// +// Tag values are automatically escaped during parsing. +type Message struct { + Tags []Tag // An ordered slice of Key=Value pairs + Source string // An optional string denoting the source of the message + Command Command // A command/numeric for the message + Parameters []string // Zero or more parameters for the Command. + Raw string + + parseIndex int +} + +// ParseMessage will attempt to parse whatever it's given as an IRC Message, +// and populate Tags/Source/Command/Parameters with the results. +// Garbage in, garbage out, though. The parser tries its best, but doesn't aggressively +// validate messages. +// +// Tag values are automatically escaped during parsing, as needed. +func (m *Message) ParseMessage() { + m.parseIndex = 0 + m.consumeSpaces() + if m.consume('@') { + m.Tags = m.parseTags() + } + m.consumeSpaces() + if m.consume(':') { + m.Source = m.parseSource() + } + m.consumeSpaces() + m.Command = m.parseCommand() + m.consumeSpaces() + m.Parameters = m.parseParameters() +} + +func (m *Message) parseTags() []Tag { + tags := &Tags{} + for { + next := m.findNext("=", ";") + if next == -1 { + break + } else if m.Raw[m.parseIndex+next] == '=' { + key := m.Raw[m.parseIndex : m.parseIndex+next] + m.parseIndex += next + 1 + eot := m.findNext(";", " ") + if eot == -1 { // ran out of message, probably + tags.Insert(Tag{Key: key, Value: unescapeTag(m.Raw[m.parseIndex:])}) + m.parseIndex = len(m.Raw) + break + } + tags.Insert(Tag{Key: key, Value: unescapeTag(m.Raw[m.parseIndex : m.parseIndex+eot])}) + m.parseIndex += len(m.Raw[m.parseIndex:m.parseIndex+eot]) + 1 + } else if m.Raw[m.parseIndex+next] == ';' { + key := m.Raw[m.parseIndex : m.parseIndex+next] + m.parseIndex += next + 1 + tags.Insert(Tag{Key: key, Value: ""}) + } + } + return tags.GetSlice() +} + +func (m *Message) parseSource() string { + start := m.parseIndex + endofparse := strings.Index(m.Raw[m.parseIndex:], " ") + if endofparse == -1 { + m.parseIndex = len(m.Raw) // out of message! which is weird. + } else { + m.parseIndex += endofparse + } + return m.Raw[start:m.parseIndex] +} + +func (m *Message) parseCommand() Command { + start := m.parseIndex + endofparse := strings.Index(m.Raw[m.parseIndex:], " ") + if endofparse == -1 { + m.parseIndex = len(m.Raw) // a command with no parameters + } else { + m.parseIndex += endofparse + } + return Command(m.Raw[start:m.parseIndex]) +} + +func (m *Message) parseParameters() []string { + params := []string{} + for m.parseIndex <= len(m.Raw) { + m.consumeSpaces() + if m.consume(':') { // "run until end of line" + params = append(params, m.Raw[m.parseIndex:]) + break + } + endofparse := strings.Index(m.Raw[m.parseIndex:], " ") + if endofparse == -1 { // no further params + break + } + params = append(params, m.Raw[m.parseIndex:m.parseIndex+endofparse]) + m.parseIndex += endofparse + } + return params +} + +func (m *Message) consumeSpaces() { + if len(m.Raw) <= m.parseIndex { + return + } + for len(m.Raw) > m.parseIndex && m.Raw[m.parseIndex] == ' ' { + m.parseIndex++ + } +} + +func (m *Message) consume(r byte) bool { + if len(m.Raw) <= m.parseIndex { + return false + } + if m.Raw[m.parseIndex] == r { + m.parseIndex++ + return true + } + return false +} + +func (m *Message) findNext(bs ...string) int { + r := -1 + for _, b := range bs { + i := strings.Index(m.Raw[m.parseIndex:], b) + if r == -1 || (i != -1 && i < r) { + r = i + } + } + return r +} + +func unescapeTag(s string) string { + sb := strings.Builder{} + escaping := false + i := 0 + for i < len(s) { + if escaping { + switch s[i] { + case ':': + sb.WriteByte(';') + case '\\': + sb.WriteByte('\\') + case 's': + sb.WriteByte(' ') + case 'r': + sb.WriteByte('\r') + case 'n': + sb.WriteByte('\n') + default: + sb.WriteByte(s[i]) + } + i++ + escaping = false + } else if s[i] == '\\' { + if !escaping { + escaping = true + i++ + continue + } + } else { + sb.WriteByte(s[i]) + i++ + } + } + return sb.String() +} + +func escapeTag(s string) string { + sb := strings.Builder{} + for i := range s { + switch s[i] { + case ';': + sb.Write([]byte{'\\', ':'}) + case ' ': + sb.Write([]byte{'\\', 's'}) + case '\\': + sb.Write([]byte{'\\', '\\'}) + case '\n': + sb.Write([]byte{'\\', 'n'}) + case '\r': + sb.Write([]byte{'\\', 'r'}) + default: + sb.WriteByte(s[i]) + } + } + return sb.String() +} + +func (m *Message) Canonicalize() []byte { + sb := bytes.NewBuffer([]byte{}) + if m.Tags != nil && len(m.Tags) > 0 { + sb.WriteByte('@') + for i := range m.Tags { + sb.WriteString(m.Tags[i].Key) + if m.Tags[i].Value != "" { + sb.WriteByte('=') + sb.WriteString(escapeTag(m.Tags[i].Value)) + } + if i+1 != len(m.Tags) { + sb.WriteByte(';') + } + } + sb.WriteByte(' ') + } + + if m.Source != "" { + sb.WriteByte(':') + sb.WriteString(m.Source) + sb.WriteByte(' ') + } + + sb.WriteString(string(m.Command)) + + if m.Parameters != nil && len(m.Parameters) > 0 { + sb.WriteByte(' ') + for i := range m.Parameters { + if i+1 == len(m.Parameters) && (strings.Contains(m.Parameters[i], " ") || m.Parameters[i] == "" || m.Parameters[i][0] == ':') { + sb.WriteByte(':') + } + sb.WriteString(m.Parameters[i]) + if i+1 != len(m.Parameters) { + sb.WriteByte(' ') + } + } + } + + return sb.Bytes() +} diff --git a/message/message_test.go b/message/message_test.go new file mode 100644 index 0000000..625e087 --- /dev/null +++ b/message/message_test.go @@ -0,0 +1,358 @@ +package message + +import "testing" + +func TestParsing(t *testing.T) { + testcases := []struct { + input string + output *Message + }{ + // these inputs are based on + // https://github.com/ircdocs/parser-tests/blob/master/tests/msg-split.yaml + // and https://modern.ircdocs.horse/#messages + { + input: `:irc.example.com CAP LS * :multi-prefix extended-join sasl`, + output: &Message{ + Source: "irc.example.com", + Command: CAP, + Parameters: []string{"LS", "*", "multi-prefix extended-join sasl"}, + }}, + { + input: `@id=234AB :dan!d@localhost PRIVMSG #chan :Hey what's up!`, + output: &Message{ + Tags: []Tag{{"id", "234AB"}}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }}, + { + input: `@a=b;c=32;k;rt=ql7 :dan!d@localhost PRIVMSG #chan :Hey what's up!`, + output: &Message{ + Tags: []Tag{{"a", "b"}, {"c", "32"}, {"k", ""}, {"rt", "ql7"}}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }}, + { + input: `@a=b\\and\nk;c=72\s45;d=gh\:764 CAP`, + output: &Message{ + Tags: []Tag{{"a", "b\\and\nk"}, {"c", "72 45"}, {"d", `gh;764`}}, + Command: CAP, + }}, + { + input: `@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :dan!d@localhost PRIVMSG #chan :Hey what's up!`, + output: &Message{ + Tags: []Tag{{"tag1", "value1"}, {"tag2", ""}, {"vendor1/tag3", "value2"}, {"vendor2/tag4", ""}}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }}, + { + input: ":src AWAY", + output: &Message{ + Source: "src", + Command: AWAY, + }}, + { + input: ":src AWAY ", + output: &Message{ + Source: "src", + Command: AWAY, + }}, + { + input: `CAP REQ :sasl`, + output: &Message{ + Command: CAP, + Parameters: []string{"REQ", "sasl"}, + }}, + { + input: `CAP REQ :`, + output: &Message{ + Command: CAP, + Parameters: []string{"REQ", ""}, + }}, + { + input: `CAP REQ ::asdf`, + output: &Message{ + Command: CAP, + Parameters: []string{"REQ", ":asdf"}, + }}, + { + input: `CAP REQ : asdf qwer`, + output: &Message{ + Command: CAP, + Parameters: []string{"REQ", " asdf qwer"}, + }}, + { + input: ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz", + output: &Message{ + Source: "coolguy!ag@net\x035w\x03ork.admin", + Command: PRIVMSG, + Parameters: []string{"foo", "bar baz"}, + }}, + { + input: `:coolguy PRIVMSG bar :lol :) `, + output: &Message{ + Source: "coolguy", + Command: PRIVMSG, + Parameters: []string{"bar", "lol :) "}, + }}, + { + input: `:gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters`, + output: &Message{ + Source: "gravel.mozilla.org", + Command: ERR_ERRONEUSNICKNAME, + Parameters: []string{"#momo", "Erroneous Nickname: Illegal characters"}, + }}, + { + input: `:gravel.mozilla.org MODE #tckk +n `, + output: &Message{ + Source: "gravel.mozilla.org", + Command: MODE, + Parameters: []string{"#tckk", "+n"}, + }}, + { + input: ":services.esper.net MODE #foo-bar +o foobar ", + output: &Message{ + Source: "services.esper.net", + Command: MODE, + Parameters: []string{"#foo-bar", "+o", "foobar"}, + }}, + { + input: `@tag1=value\\ntest COMMAND`, + output: &Message{ + Tags: []Tag{{"tag1", `value\ntest`}}, + Command: Command("COMMAND"), + }}, + { + input: `@tag1=value\1 COMMAND`, + output: &Message{ + Tags: []Tag{{"tag1", `value1`}}, + Command: Command("COMMAND"), + }}, + { + input: `@tag1=value\ COMMAND`, + output: &Message{ + Tags: []Tag{{"tag1", `value`}}, + Command: Command("COMMAND"), + }}, + { + input: `@tag1=va\r\nlue COMMAND`, + output: &Message{ + Tags: []Tag{{"tag1", "va\r\nlue"}}, + Command: Command("COMMAND"), + }}, + { + input: `@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND`, + output: &Message{ + Tags: []Tag{{"tag1", "5"}, {"tag2", "3"}, {"tag3", "4"}}, + Command: Command("COMMAND"), + }}, + { + input: `@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND`, + output: &Message{ + Tags: []Tag{{"tag1", "5"}, {"tag2", "3"}, {"tag3", "4"}, {"vendor/tag2", "8"}}, + Command: Command("COMMAND"), + }}, + { + input: `:SomeOp MODE #channel :+i`, + output: &Message{ + Source: "SomeOp", + Command: MODE, + Parameters: []string{"#channel", "+i"}, + }}, + { + input: `:SomeOp MODE #channel +oo SomeUser :AnotherUser`, + output: &Message{ + Source: "SomeOp", + Command: MODE, + Parameters: []string{"#channel", "+oo", "SomeUser", "AnotherUser"}, + }}, + { + input: `:uttergarbage`, + output: &Message{ + Source: "uttergarbage", + }, + }, + { + input: `@only=tags`, + output: &Message{ + Tags: []Tag{{"only", "tags"}}, + }, + }, + { + input: `@only=`, + output: &Message{ + Tags: []Tag{{"only", ""}}, + }, + }, + } + for _, tc := range testcases { + m := &Message{Raw: tc.input} + m.ParseMessage() + if len(tc.output.Tags) != len(m.Tags) { + t.Logf("tags: actual: %v, expected: %v", m.Tags, tc.output.Tags) + t.Fail() + } + for i := range tc.output.Tags { + if tc.output.Tags[i] != m.Tags[i] { + t.Logf("tags: actual: %+v, expected: %+v", m.Tags[i], tc.output.Tags[i]) + t.Fail() + } + } + if tc.output.Source != m.Source { + t.Logf("source: actual: %v, expected: %v", m.Source, tc.output.Source) + t.Fail() + } + if tc.output.Command != m.Command { + t.Logf("command: actual: %v, expected: %v", m.Command, tc.output.Command) + t.Fail() + } + if len(tc.output.Parameters) != len(m.Parameters) { + t.Logf("parameters: actual: %v (%d), expected: %v (%d)", m.Parameters, len(m.Parameters), tc.output.Parameters, len(tc.output.Parameters)) + t.Fail() + } + for i := range tc.output.Parameters { + if tc.output.Parameters[i] != m.Parameters[i] { + t.Logf("parameters: actual: %v, expected: %v", m.Parameters[i], tc.output.Parameters[i]) + t.Fail() + } + } + } +} + +func TestCanonicalization(t *testing.T) { + testcases := []struct { + input *Message + output string + }{ + // these outputs are based on + // https://github.com/ircdocs/parser-tests/blob/master/tests/msg-split.yaml + // and https://modern.ircdocs.horse/#messages + { + input: &Message{ + Source: "irc.example.com", + Command: CAP, + Parameters: []string{"LS", "*", "multi-prefix extended-join sasl"}, + }, + output: `:irc.example.com CAP LS * :multi-prefix extended-join sasl`, + }, + { + input: &Message{ + Tags: []Tag{{"id", "234AB"}}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }, + output: `@id=234AB :dan!d@localhost PRIVMSG #chan :Hey what's up!`, + }, + { + input: &Message{ + Tags: []Tag{{"a", "b"}, {"c", "32"}, {"k", ""}, {"rt", "ql7"}}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }, + output: `@a=b;c=32;k;rt=ql7 :dan!d@localhost PRIVMSG #chan :Hey what's up!`, + }, + { + input: &Message{ + Tags: []Tag{{"a", "b\\and\nk"}, {"c", "72 45"}, {"d", `gh;764`}}, + Command: CAP, + }, + output: `@a=b\\and\nk;c=72\s45;d=gh\:764 CAP`, + }, + { + input: &Message{ + Tags: []Tag{{"tag1", "value1"}, {"tag2", ""}, {"vendor1/tag3", "value2"}, {"vendor2/tag4", ""}}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }, + output: `@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 :dan!d@localhost PRIVMSG #chan :Hey what's up!`, + }, + { + input: &Message{ + Source: "src", + Command: AWAY, + }, + output: ":src AWAY", + }, + { + input: &Message{ + Command: CAP, + Parameters: []string{"REQ", "sasl"}, + }, + output: `CAP REQ sasl`, + }, + { + input: &Message{ + Command: CAP, + Parameters: []string{"REQ", ""}, + }, + output: `CAP REQ :`, + }, + { + input: &Message{ + Command: CAP, + Parameters: []string{"REQ", ":asdf"}, + }, + output: `CAP REQ ::asdf`, + }, + { + input: &Message{ + Command: CAP, + Parameters: []string{"REQ", " asdf qwer"}, + }, + output: `CAP REQ : asdf qwer`, + }, + { + input: &Message{ + Source: "coolguy", + Command: PRIVMSG, + Parameters: []string{"bar", "lol :) "}, + }, + output: `:coolguy PRIVMSG bar :lol :) `, + }, + { + input: &Message{ + Source: "coolguy!ag@net\x035w\x03ork.admin", + Command: PRIVMSG, + Parameters: []string{"foo", "bar baz"}, + }, + output: ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz", + }, + { + input: &Message{ + Tags: []Tag{{"tag1", `value\ntest`}}, + Command: Command("COMMAND"), + }, + output: `@tag1=value\\ntest COMMAND`, + }, + { + input: &Message{ + Tags: []Tag{{"tag1", "va\r\nlue"}}, + Command: Command("COMMAND"), + }, + output: `@tag1=va\r\nlue COMMAND`, + }, + } + for _, tc := range testcases { + output := string(tc.input.Canonicalize()) + if output != tc.output { + t.Logf("received '%s', expected '%s'", output, tc.output) + t.Fail() + } + } +} + +func TestRoundTrip(t *testing.T) { + in := `@a=b;c=3\n2;k;rt=ql7 :dan!d@localhost PRIVMSG #chan :Hey what's up! ` + m := &Message{Raw: in} + m.ParseMessage() + out := m.Canonicalize() + if string(out) != in { + t.Logf("expected '%s', received '%s'", in, string(out)) + t.Fail() + } +}