From f6a94cc3a391c5795ecc8809c8cc6e32d7a1e7bb Mon Sep 17 00:00:00 2001 From: Jake Bailey Date: Mon, 11 Jan 2016 16:20:06 -0600 Subject: [PATCH 1/7] add tags parsing and CAP command, with parsing tests --- client/commands.go | 11 ++++++++ client/line.go | 34 +++++++++++++++++++++++-- client/line_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/client/commands.go b/client/commands.go index 1850883..e76fe53 100644 --- a/client/commands.go +++ b/client/commands.go @@ -11,6 +11,7 @@ const ( DISCONNECTED = "DISCONNECTED" ACTION = "ACTION" AWAY = "AWAY" + CAP = "CAP" CTCP = "CTCP" CTCPREPLY = "CTCPREPLY" INVITE = "INVITE" @@ -289,3 +290,13 @@ func (conn *Conn) Ping(message string) { conn.Raw(PING + " :" + message) } // Pong sends a PONG command to the server. // PONG :message func (conn *Conn) Pong(message string) { conn.Raw(PONG + " :" + message) } + +// Cap sends a CAP command to the server. +// CAP capability +func (conn *Conn) Cap(subcommmand string, messages ...string) { + if len(messages) == 0 { + conn.Raw(CAP + " " + subcommmand) + } else { + conn.Raw(CAP + " " + subcommmand + " :" + strings.Join(messages, " ")) + } +} diff --git a/client/line.go b/client/line.go index d0204fd..64e090c 100644 --- a/client/line.go +++ b/client/line.go @@ -1,12 +1,15 @@ package client import ( + "runtime" "strings" "time" - "runtime" + "github.com/fluffle/goirc/logging" ) +var tagsReplacer = strings.NewReplacer("\\:", ";", "\\s", " ", "\\r", "\r", "\\n", "\n") + // We parse an incoming line into this struct. Line.Cmd is used as the trigger // name for incoming event handlers and is the IRC verb, the first sequence // of non-whitespace characters after ":nick!user@host", e.g. PRIVMSG. @@ -14,6 +17,7 @@ import ( // Src == "nick!user@host" // Cmd == e.g. PRIVMSG, 332 type Line struct { + Tags map[string]string Nick, Ident, Host, Src string Cmd, Raw string Args []string @@ -25,6 +29,12 @@ func (l *Line) Copy() *Line { nl := *l nl.Args = make([]string, len(l.Args)) copy(nl.Args, l.Args) + if l.Tags != nil { + nl.Tags = make(map[string]string) + for k, v := range l.Tags { + nl.Tags[k] = v + } + } return &nl } @@ -97,6 +107,27 @@ func (line *Line) Public() bool { // set to CTCP or CTCPREPLY, and the CTCP command prepended to line.Args. func ParseLine(s string) *Line { line := &Line{Raw: s} + + if s[0] == '@' { + var rawTags string + line.Tags = make(map[string]string) + if idx := strings.Index(s, " "); idx != -1 { + rawTags, s = s[1:idx], s[idx+1:] + } else { + return nil + } + + // all tags are escaped, so splitting on ; is right by design + for _, tag := range strings.Split(rawTags, ";") { + pair := strings.Split(tagsReplacer.Replace(tag), "=") + if len(pair) > 1 { + line.Tags[pair[0]] = pair[1] + } else { + line.Tags[tag] = "" + } + } + } + if s[0] == ':' { // remove a source and parse it if idx := strings.Index(s, " "); idx != -1 { @@ -159,7 +190,6 @@ func ParseLine(s string) *Line { return line } - func (line *Line) argslen(minlen int) bool { pc, _, _, _ := runtime.Caller(1) fn := runtime.FuncForPC(pc) diff --git a/client/line_test.go b/client/line_test.go index 114b1fd..ba2c090 100644 --- a/client/line_test.go +++ b/client/line_test.go @@ -1,12 +1,14 @@ package client import ( + "reflect" "testing" "time" ) func TestLineCopy(t *testing.T) { l1 := &Line{ + Tags: map[string]string{"foo": "bar", "fizz": "buzz"}, Nick: "nick", Ident: "ident", Host: "host", @@ -20,7 +22,8 @@ func TestLineCopy(t *testing.T) { l2 := l1.Copy() // Ugly. Couldn't be bothered to bust out reflect and actually think. - if l2.Nick != "nick" || l2.Ident != "ident" || l2.Host != "host" || + if l2.Tags == nil || l2.Tags["foo"] != "bar" || l2.Tags["fizz"] != "buzz" || + l2.Nick != "nick" || l2.Ident != "ident" || l2.Host != "host" || l2.Src != "src" || l2.Cmd != "cmd" || l2.Raw != "raw" || l2.Args[0] != "arg" || l2.Args[1] != "text" || l2.Time != l1.Time { t.Errorf("Line not copied correctly") @@ -28,6 +31,7 @@ func TestLineCopy(t *testing.T) { } // Now, modify l2 and verify l1 not changed + l2.Tags["foo"] = "baz" l2.Nick = l2.Nick[1:] l2.Ident = "foo" l2.Host = "" @@ -35,7 +39,8 @@ func TestLineCopy(t *testing.T) { l2.Args[1] = "bar" l2.Time = time.Now() - if l1.Nick != "nick" || l1.Ident != "ident" || l1.Host != "host" || + if l2.Tags == nil || l2.Tags["foo"] != "baz" || l2.Tags["fizz"] != "buzz" || + l1.Nick != "nick" || l1.Ident != "ident" || l1.Host != "host" || l1.Src != "src" || l1.Cmd != "cmd" || l1.Raw != "raw" || l1.Args[0] != "arg" || l1.Args[1] != "text" || l1.Time == l2.Time { t.Errorf("Original modified when copy changed") @@ -88,3 +93,56 @@ func TestLineTarget(t *testing.T) { } } } + +func TestLineTags(t *testing.T) { + tests := []struct { + in string + out *Line + }{ + { // Make sure non-tagged lines work + ":nick!ident@host.com PRIVMSG me :Hello", + &Line{ + Nick: "nick", + Ident: "ident", + Host: "host.com", + Src: "nick!ident@host.com", + Cmd: PRIVMSG, + Raw: ":nick!ident@host.com PRIVMSG me :Hello", + Args: []string{"me", "Hello"}, + }, + }, + { // Tags example from the spec + "@aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello", + &Line{ + Tags: map[string]string{"aaa": "bbb", "ccc": "", "example.com/ddd": "eee"}, + Nick: "nick", + Ident: "ident", + Host: "host.com", + Src: "nick!ident@host.com", + Cmd: PRIVMSG, + Raw: "@aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello", + Args: []string{"me", "Hello"}, + }, + }, + { // Test escaped characters + "@\\:=\\:;\\s=\\s;\\r=\\r;\\n=\\n :nick!ident@host.com PRIVMSG me :Hello", + &Line{ + Tags: map[string]string{";": ";", " ": " ", "\r": "\r", "\n": "\n"}, + Nick: "nick", + Ident: "ident", + Host: "host.com", + Src: "nick!ident@host.com", + Cmd: PRIVMSG, + Raw: "@\\:=\\:;\\s=\\s;\\r=\\r;\\n=\\n :nick!ident@host.com PRIVMSG me :Hello", + Args: []string{"me", "Hello"}, + }, + }, + } + + for i, test := range tests { + got := ParseLine(test.in) + if !reflect.DeepEqual(got, test.out) { + t.Errorf("test %d:\nexpected %#v\ngot %#v", i, test.out, got) + } + } +} From 46ce56c58050a65d676c4e17b5ca348ea1409168 Mon Sep 17 00:00:00 2001 From: Jake Bailey Date: Mon, 11 Jan 2016 16:24:01 -0600 Subject: [PATCH 2/7] don't add spaces and instead make message optional, let the user decide --- client/commands.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/commands.go b/client/commands.go index e76fe53..e49faca 100644 --- a/client/commands.go +++ b/client/commands.go @@ -293,10 +293,10 @@ func (conn *Conn) Pong(message string) { conn.Raw(PONG + " :" + message) } // Cap sends a CAP command to the server. // CAP capability -func (conn *Conn) Cap(subcommmand string, messages ...string) { - if len(messages) == 0 { +func (conn *Conn) Cap(subcommmand string, message ...string) { + if len(message) == 0 { conn.Raw(CAP + " " + subcommmand) } else { - conn.Raw(CAP + " " + subcommmand + " :" + strings.Join(messages, " ")) + conn.Raw(CAP + " " + subcommmand + " :" + message[0]) } } From e670ca970c545deaf7f76106cb8fca88e27b7041 Mon Sep 17 00:00:00 2001 From: Jake Bailey Date: Mon, 11 Jan 2016 16:25:17 -0600 Subject: [PATCH 3/7] fix comment --- client/commands.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/commands.go b/client/commands.go index e49faca..0e935a5 100644 --- a/client/commands.go +++ b/client/commands.go @@ -292,7 +292,8 @@ func (conn *Conn) Ping(message string) { conn.Raw(PING + " :" + message) } func (conn *Conn) Pong(message string) { conn.Raw(PONG + " :" + message) } // Cap sends a CAP command to the server. -// CAP capability +// CAP subcommand +// CAP subcommand :message func (conn *Conn) Cap(subcommmand string, message ...string) { if len(message) == 0 { conn.Raw(CAP + " " + subcommmand) From 32ae1211bb8662cabdeb277edae5d3706a7a18a3 Mon Sep 17 00:00:00 2001 From: Jake Bailey Date: Mon, 11 Jan 2016 16:43:19 -0600 Subject: [PATCH 4/7] check bad tag inputs --- client/line.go | 10 ++++++++-- client/line_test.go | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/client/line.go b/client/line.go index 64e090c..1f523d0 100644 --- a/client/line.go +++ b/client/line.go @@ -119,11 +119,17 @@ func ParseLine(s string) *Line { // all tags are escaped, so splitting on ; is right by design for _, tag := range strings.Split(rawTags, ";") { + if tag == "" { + return nil + } + pair := strings.Split(tagsReplacer.Replace(tag), "=") - if len(pair) > 1 { + if len(pair) < 2 { + line.Tags[tag] = "" + } else if len(pair) == 2 { line.Tags[pair[0]] = pair[1] } else { - line.Tags[tag] = "" + return nil } } } diff --git a/client/line_test.go b/client/line_test.go index ba2c090..b041639 100644 --- a/client/line_test.go +++ b/client/line_test.go @@ -137,6 +137,8 @@ func TestLineTags(t *testing.T) { Args: []string{"me", "Hello"}, }, }, + {"@a=a; :nick!ident@host.com PRIVMSG me :Hello", nil}, // Bad inputs + {"@a=a=a :nick!ident@host.com PRIVMSG me :Hello", nil}, } for i, test := range tests { From 73f523f252373cb17a7350424aefa7e15e4c463a Mon Sep 17 00:00:00 2001 From: Jake Bailey Date: Mon, 11 Jan 2016 16:50:32 -0600 Subject: [PATCH 5/7] use a list of capabilities instead, as the spec defines space seperated caps --- client/commands.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/commands.go b/client/commands.go index 0e935a5..85cca5f 100644 --- a/client/commands.go +++ b/client/commands.go @@ -294,10 +294,10 @@ func (conn *Conn) Pong(message string) { conn.Raw(PONG + " :" + message) } // Cap sends a CAP command to the server. // CAP subcommand // CAP subcommand :message -func (conn *Conn) Cap(subcommmand string, message ...string) { - if len(message) == 0 { +func (conn *Conn) Cap(subcommmand string, capabilities ...string) { + if len(capabilities) == 0 { conn.Raw(CAP + " " + subcommmand) } else { - conn.Raw(CAP + " " + subcommmand + " :" + message[0]) + conn.Raw(CAP + " " + subcommmand + " :" + strings.Join(capabilities, " ")) } } From 20ef362b1d95f2a83314d21f386935c72a19562c Mon Sep 17 00:00:00 2001 From: Jake Bailey Date: Tue, 12 Jan 2016 20:00:40 -0600 Subject: [PATCH 6/7] skip empty tags, allow = in tag values, better clarify ; split, add IRCv3 tag parsing comment to ParseLine() --- client/line.go | 13 +++++++------ client/line_test.go | 28 ++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/client/line.go b/client/line.go index 1f523d0..89459ef 100644 --- a/client/line.go +++ b/client/line.go @@ -105,6 +105,9 @@ func (line *Line) Public() bool { // CTCP command separated from any subsequent text. Then, CTCP ACTIONs are // rewritten such that Line.Cmd == ACTION. Other CTCP messages have Cmd // set to CTCP or CTCPREPLY, and the CTCP command prepended to line.Args. +// +// ParseLine also parses IRCv3 tags, if received. If a line does not have +// the tags section, Line.Tags will be nil. func ParseLine(s string) *Line { line := &Line{Raw: s} @@ -117,19 +120,17 @@ func ParseLine(s string) *Line { return nil } - // all tags are escaped, so splitting on ; is right by design + // ; is represented as \: in a tag, so it's safe to split on ; for _, tag := range strings.Split(rawTags, ";") { if tag == "" { - return nil + continue } - pair := strings.Split(tagsReplacer.Replace(tag), "=") + pair := strings.SplitN(tagsReplacer.Replace(tag), "=", 2) if len(pair) < 2 { line.Tags[tag] = "" - } else if len(pair) == 2 { - line.Tags[pair[0]] = pair[1] } else { - return nil + line.Tags[pair[0]] = pair[1] } } } diff --git a/client/line_test.go b/client/line_test.go index b041639..82132d1 100644 --- a/client/line_test.go +++ b/client/line_test.go @@ -137,8 +137,32 @@ func TestLineTags(t *testing.T) { Args: []string{"me", "Hello"}, }, }, - {"@a=a; :nick!ident@host.com PRIVMSG me :Hello", nil}, // Bad inputs - {"@a=a=a :nick!ident@host.com PRIVMSG me :Hello", nil}, + { // Skip empty tag + "@a=a; :nick!ident@host.com PRIVMSG me :Hello", + &Line{ + Tags: map[string]string{"a": "a"}, + Nick: "nick", + Ident: "ident", + Host: "host.com", + Src: "nick!ident@host.com", + Cmd: PRIVMSG, + Raw: "@a=a; :nick!ident@host.com PRIVMSG me :Hello", + Args: []string{"me", "Hello"}, + }, + }, + { // = in tag + "@a=a=a; :nick!ident@host.com PRIVMSG me :Hello", + &Line{ + Tags: map[string]string{"a": "a=a"}, + Nick: "nick", + Ident: "ident", + Host: "host.com", + Src: "nick!ident@host.com", + Cmd: PRIVMSG, + Raw: "@a=a=a; :nick!ident@host.com PRIVMSG me :Hello", + Args: []string{"me", "Hello"}, + }, + }, } for i, test := range tests { From 4c6842acba07a0a9baf442cd4e89074a492374c0 Mon Sep 17 00:00:00 2001 From: Jake Bailey Date: Tue, 12 Jan 2016 20:02:46 -0600 Subject: [PATCH 7/7] link to spec --- client/line.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/line.go b/client/line.go index 89459ef..ad428e2 100644 --- a/client/line.go +++ b/client/line.go @@ -107,7 +107,11 @@ func (line *Line) Public() bool { // set to CTCP or CTCPREPLY, and the CTCP command prepended to line.Args. // // ParseLine also parses IRCv3 tags, if received. If a line does not have -// the tags section, Line.Tags will be nil. +// the tags section, Line.Tags will be nil. Tags are optional, and will +// only be included after the correct CAP command. +// +// http://ircv3.net/specs/core/capability-negotiation-3.1.html +// http://ircv3.net/specs/core/message-tags-3.2.html func ParseLine(s string) *Line { line := &Line{Raw: s}