From 068ba8bfb0b48c7ecca87331faa2186108bf4a1e Mon Sep 17 00:00:00 2001 From: David Ashby Date: Thu, 7 Oct 2021 23:22:06 -0400 Subject: [PATCH] initial message-parsing work --- const.go | 177 ++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + message.go | 137 +++++++++++++++++++++++++++++++++++++ message_test.go | 110 ++++++++++++++++++++++++++++++ 4 files changed, 427 insertions(+) create mode 100644 const.go create mode 100644 go.mod create mode 100644 message.go create mode 100644 message_test.go diff --git a/const.go b/const.go new file mode 100644 index 0000000..77b1c71 --- /dev/null +++ b/const.go @@ -0,0 +1,177 @@ +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/go.mod b/go.mod new file mode 100644 index 0000000..e42f7dc --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.yetaga.in/alazyreader/irc + +go 1.17 diff --git a/message.go b/message.go new file mode 100644 index 0000000..bc167b3 --- /dev/null +++ b/message.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "strings" +) + +type Message struct { + Tags map[string]string + Source string + Command command + Parameters []string + Raw string + + parseIndex int +} + +func (m *Message) ParseMessage() error { + 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() + return nil +} + +func (m *Message) parseTags() (map[string]string, error) { + tags := make(map[string]string) + 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(";", " ") + tags[key] = m.Raw[m.parseIndex : m.parseIndex+eot] + m.parseIndex += len(tags[key]) + 1 + } else if m.Raw[m.parseIndex+next] == ';' { + key := m.Raw[m.parseIndex : m.parseIndex+next] + m.parseIndex += next + tags[key] = "" + m.parseIndex++ + } else { + break + } + } + return tags, nil +} + +func (m *Message) parseSource() (string, error) { + start := m.parseIndex + endofparse := strings.Index(m.Raw[m.parseIndex:], " ") + if endofparse == -1 { + return "", fmt.Errorf("end of string encountered while parsing tags") + } + m.parseIndex += endofparse + return m.Raw[start:m.parseIndex], nil +} + +func (m *Message) parseCommand() (command, error) { + start := m.parseIndex + endofparse := strings.Index(m.Raw[m.parseIndex:], " ") + if endofparse == -1 { + return "", fmt.Errorf("end of string encountered while parsing command") + } + m.parseIndex += endofparse + return command(m.Raw[start:m.parseIndex]), nil +} + +func (m *Message) parseParameters() ([]string, error) { + 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 after this one + p := strings.TrimSuffix(m.Raw[m.parseIndex:], " ") + if len(p) > 0 { // reject empty trailing params + params = append(params, p) + } + break + } + params = append(params, m.Raw[m.parseIndex:m.parseIndex+endofparse]) + m.parseIndex += endofparse + } + return params, nil +} + +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 { + if len(bs) == 0 { + return 0 + } + 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 (m *Message) ToBytes() []byte { + return nil +} diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..553d3f3 --- /dev/null +++ b/message_test.go @@ -0,0 +1,110 @@ +package main + +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: map[string]string{"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: map[string]string{"a": "b", "c": "32", "k": "", "rt": "ql7"}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }}, + { + input: `@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :dan!d@localhost PRIVMSG #chan :Hey what's up!`, + output: &Message{ + Tags: map[string]string{"tag1": "value1", "tag2": "", "vendor1/tag3": "value2", "vendor2/tag4": ""}, + Source: "dan!d@localhost", + Command: PRIVMSG, + Parameters: []string{"#chan", "Hey what's up!"}, + }}, + { + 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 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"}, + }}, + } + 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() + } + 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() + } + } +}