diff --git a/src/github.com/belak/irc/.gitignore b/src/github.com/belak/irc/.gitignore new file mode 100644 index 0000000..31318c9 --- /dev/null +++ b/src/github.com/belak/irc/.gitignore @@ -0,0 +1,2 @@ +*.cover +*.test diff --git a/src/github.com/belak/irc/.travis.yml b/src/github.com/belak/irc/.travis.yml new file mode 100644 index 0000000..736f045 --- /dev/null +++ b/src/github.com/belak/irc/.travis.yml @@ -0,0 +1,16 @@ +language: go + +before_install: + - go get github.com/axw/gocov/gocov + - go get github.com/mattn/goveralls + - go get golang.org/x/tools/cmd/cover + - go get github.com/golang/lint/golint + +script: + - go vet -x ./... + - $HOME/gopath/bin/golint ./... + - go test -v ./... + - go test -covermode=count -coverprofile=profile.cov + +after_script: + - $HOME/gopath/bin/goveralls -coverprofile=profile.cov -service=travis-ci diff --git a/src/github.com/belak/irc/LICENSE b/src/github.com/belak/irc/LICENSE new file mode 100644 index 0000000..d523087 --- /dev/null +++ b/src/github.com/belak/irc/LICENSE @@ -0,0 +1,12 @@ +Copyright (c) 2014, Kaleb Elwert and Nathan Scowcroft +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/github.com/belak/irc/README.md b/src/github.com/belak/irc/README.md new file mode 100644 index 0000000..0189df2 --- /dev/null +++ b/src/github.com/belak/irc/README.md @@ -0,0 +1,48 @@ +# irc + +[![Build Status](https://travis-ci.org/belak/irc.svg?branch=master)](https://travis-ci.org/belak/irc) +[![Coverage Status](https://coveralls.io/repos/belak/irc/badge.svg?branch=master&service=github)](https://coveralls.io/github/belak/irc?branch=master) + +irc is a simple, low-ish level golang irc library which is meant to +only read and write messages from a given stream. There are a number +of other libraries which provide a more full featured client if that's +what you're looking for. This library is more of a building block for +other things to build on. + +## Example + +```go +package main + +import ( + "log" + "net" + + "github.com/belak/irc" +) + +func main() { + conn, err := net.Dial("tcp", "chat.freenode.net:6667") + if err != nil { + log.Fatalln(err) + } + + // Create the client + client := irc.NewClient(conn, "i_have_a_nick", "user", "name", "pass") + + for { + m, err := client.ReadMessage() + if err != nil { + log.Fatalln(err) + } + + if m.Command == "001" { + // 001 is a welcome event, so we join channels there + c.Write("JOIN #bot-test-chan") + } else if m.Command == "PRIVMSG" { + // Create a handler on all messages. + c.MentionReply(e, e.Trailing()) + } + } +} +``` diff --git a/src/github.com/belak/irc/conn.go b/src/github.com/belak/irc/conn.go new file mode 100644 index 0000000..50dacc8 --- /dev/null +++ b/src/github.com/belak/irc/conn.go @@ -0,0 +1,80 @@ +package irc + +import ( + "bufio" + "fmt" + "io" + "strings" +) + +// Conn represents a simple IRC client. +type Conn struct { + // DebugCallback is a callback for every line of input and + // output. It is meant for debugging and is not guaranteed to + // be stable. + DebugCallback func(line string) + + // Internal things + conn io.ReadWriteCloser + in *bufio.Reader +} + +// NewConn creates a new Conn +func NewConn(rwc io.ReadWriteCloser) *Conn { + // Create the client + c := &Conn{ + func(line string) {}, + rwc, + bufio.NewReader(rwc), + } + + return c +} + +// Write is a simple function which will write the given line to the +// underlying connection. +func (c *Conn) Write(line string) { + c.DebugCallback("--> " + line) + c.conn.Write([]byte(line)) + c.conn.Write([]byte("\r\n")) +} + +// Writef is a wrapper around the connection's Write method and +// fmt.Sprintf. Simply use it to send a message as you would normally +// use fmt.Printf. +func (c *Conn) Writef(format string, args ...interface{}) { + c.Write(fmt.Sprintf(format, args...)) +} + +// WriteMessage writes the given message to the stream +func (c *Conn) WriteMessage(m *Message) { + c.Write(m.String()) +} + +// ReadMessage returns the next message from the stream or an error. +func (c *Conn) ReadMessage() (*Message, error) { + line, err := c.in.ReadString('\n') + if err != nil { + return nil, err + } + + c.DebugCallback("<-- " + strings.TrimRight(line, "\r\n")) + + // Parse the message from our line + m := ParseMessage(line) + + // Now that we have the message parsed, do some preprocessing on it + lastArg := m.Trailing() + + // Clean up CTCP stuff so everyone + // doesn't have to parse it manually + if m.Command == "PRIVMSG" && len(lastArg) > 0 && lastArg[0] == '\x01' { + m.Command = "CTCP" + + if i := strings.LastIndex(lastArg, "\x01"); i > -1 { + m.Params[len(m.Params)-1] = lastArg[1:i] + } + } + + return m, nil +} diff --git a/src/github.com/belak/irc/conn_test.go b/src/github.com/belak/irc/conn_test.go new file mode 100644 index 0000000..215fb02 --- /dev/null +++ b/src/github.com/belak/irc/conn_test.go @@ -0,0 +1,113 @@ +package irc + +import ( + "bytes" + "io" + "reflect" + "strings" + "testing" +) + +type testReadWriteCloser struct { + client *bytes.Buffer + server *bytes.Buffer +} + +func newTestReadWriteCloser() *testReadWriteCloser { + return &testReadWriteCloser{ + client: &bytes.Buffer{}, + server: &bytes.Buffer{}, + } +} + +func (t *testReadWriteCloser) Read(p []byte) (int, error) { + return t.server.Read(p) +} + +func (t *testReadWriteCloser) Write(p []byte) (int, error) { + return t.client.Write(p) +} + +// Ensure we can close the thing +func (t *testReadWriteCloser) Close() error { + return nil +} + +func testReadMessage(t *testing.T, c *Conn) *Message { + m, err := c.ReadMessage() + if err != nil { + t.Error(err) + } + return m +} + +func testLines(t *testing.T, rwc *testReadWriteCloser, expected []string) { + lines := strings.Split(rwc.client.String(), "\r\n") + var line, clientLine string + for len(expected) > 0 { + line, expected = expected[0], expected[1:] + clientLine, lines = lines[0], lines[1:] + + if line != clientLine { + t.Errorf("Expected %s != Got %s", line, clientLine) + } + } + + for _, line := range lines { + if strings.TrimSpace(line) != "" { + t.Errorf("Extra non-empty lines: %s", line) + } + } + + // Reset the contents + rwc.client.Reset() + rwc.server.Reset() +} + +func TestClient(t *testing.T) { + rwc := newTestReadWriteCloser() + c := NewConn(rwc) + + // Test writing a message + m := &Message{Prefix: &Prefix{}, Command: "PING", Params: []string{"Hello World"}} + c.WriteMessage(m) + testLines(t, rwc, []string{ + "PING :Hello World", + }) + + // Test with Writef + c.Writef("PING :%s", "Hello World") + testLines(t, rwc, []string{ + "PING :Hello World", + }) + + m = ParseMessage("PONG :Hello World") + rwc.server.WriteString(m.String() + "\r\n") + m2 := testReadMessage(t, c) + + if !reflect.DeepEqual(m, m2) { + t.Errorf("Message returned by client did not match input") + } + + // Test welcome message + rwc.server.WriteString("001 test_nick\r\n") + m = testReadMessage(t, c) + + // Ensure CTCP messages are parsed + rwc.server.WriteString(":world PRIVMSG :\x01VERSION\x01\r\n") + m = testReadMessage(t, c) + if m.Command != "CTCP" { + t.Error("Message was not parsed as CTCP") + } + if m.Trailing() != "VERSION" { + t.Error("Wrong CTCP command") + } + + // This is an odd one... if there wasn't any output, it'll hit + // EOF, so we expect an error here so we can test an error + // condition. + _, err := c.ReadMessage() + if err != io.EOF { + t.Error("Didn't get expected EOF error") + } +} diff --git a/src/github.com/belak/irc/parser.go b/src/github.com/belak/irc/parser.go new file mode 100644 index 0000000..cf1763e --- /dev/null +++ b/src/github.com/belak/irc/parser.go @@ -0,0 +1,209 @@ +package irc + +import ( + "bytes" + "strings" +) + +// Prefix represents the prefix of a message, generally the user who sent it +type Prefix struct { + // Name will contain the nick of who sent the message, the + // server who sent the message, or a blank string + Name string + + // User will either contain the user who sent the message or a blank string + User string + + // Host will either contain the host of who sent the message or a blank string + Host string +} + +// Message represents a line parsed from the server +type Message struct { + // Each message can have a Prefix + *Prefix + + // Command is which command is being called. + Command string + + // Params are all the arguments for the command. + Params []string +} + +// ParsePrefix takes an identity string and parses it into an +// identity struct. It will always return an Prefix struct and never +// nil. +func ParsePrefix(line string) *Prefix { + // Start by creating an Prefix with nothing but the host + id := &Prefix{ + Name: line, + } + + uh := strings.SplitN(id.Name, "@", 2) + if len(uh) == 2 { + id.Name, id.Host = uh[0], uh[1] + } + + nu := strings.SplitN(id.Name, "!", 2) + if len(nu) == 2 { + id.Name, id.User = nu[0], nu[1] + } + + return id +} + +// Copy will create a new copy of an Prefix +func (p *Prefix) Copy() *Prefix { + newPrefix := &Prefix{} + + *newPrefix = *p + + return newPrefix +} + +// String ensures this is stringable +func (p *Prefix) String() string { + buf := &bytes.Buffer{} + buf.WriteString(p.Name) + + if p.User != "" { + buf.WriteString("!") + buf.WriteString(p.User) + } + + if p.Host != "" { + buf.WriteString("@") + buf.WriteString(p.Host) + } + + return buf.String() +} + +// ParseMessage takes a message string (usually a whole line) and +// parses it into a Message struct. This will return nil in the case +// of invalid messages. +func ParseMessage(line string) *Message { + // Trim the line and make sure we have data + line = strings.TrimSpace(line) + if len(line) == 0 { + return nil + } + + c := &Message{Prefix: &Prefix{}} + + if line[0] == ':' { + split := strings.SplitN(line, " ", 2) + if len(split) < 2 { + return nil + } + + // Parse the identity, if there was one + c.Prefix = ParsePrefix(string(split[0][1:])) + line = split[1] + } + + // Split out the trailing then the rest of the args. Because + // we expect there to be at least one result as an arg (the + // command) we don't need to special case the trailing arg and + // can just attempt a split on " :" + split := strings.SplitN(line, " :", 2) + c.Params = strings.FieldsFunc(split[0], func(r rune) bool { + return r == ' ' + }) + + // If there are no args, we need to bail because we need at + // least the command. + if len(c.Params) == 0 { + return nil + } + + // If we had a trailing arg, append it to the other args + if len(split) == 2 { + c.Params = append(c.Params, split[1]) + } + + // Because of how it's parsed, the Command will show up as the + // first arg. + c.Command = c.Params[0] + c.Params = c.Params[1:] + + return c +} + +// Trailing returns the last argument in the Message or an empty string +// if there are no args +func (m *Message) Trailing() string { + if len(m.Params) < 1 { + return "" + } + + return m.Params[len(m.Params)-1] +} + +// FromChannel is mostly for PRIVMSG messages (and similar derived messages) +// It will check if the message came from a channel or a person. +func (m *Message) FromChannel() bool { + if len(m.Params) < 1 || len(m.Params[0]) < 1 { + return false + } + + switch m.Params[0][0] { + case '#', '&': + return true + default: + return false + } +} + +// Copy will create a new copy of an message +func (m *Message) Copy() *Message { + // Create a new message + newMessage := &Message{} + + // Copy stuff from the old message + *newMessage = *m + + // Copy the Prefix + newMessage.Prefix = m.Prefix.Copy() + + // Copy the Params slice + newMessage.Params = append(make([]string, 0, len(m.Params)), m.Params...) + + return newMessage +} + +// String ensures this is stringable +func (m *Message) String() string { + buf := &bytes.Buffer{} + + // Add the prefix if we have one + if m.Prefix.Name != "" { + buf.WriteByte(':') + buf.WriteString(m.Prefix.String()) + buf.WriteByte(' ') + } + + // Add the command since we know we'll always have one + buf.WriteString(m.Command) + + if len(m.Params) > 0 { + args := m.Params[:len(m.Params)-1] + trailing := m.Params[len(m.Params)-1] + + if len(args) > 0 { + buf.WriteByte(' ') + buf.WriteString(strings.Join(args, " ")) + } + + // If trailing contains a space or starts with a : we + // need to actually specify that it's trailing. + if strings.ContainsRune(trailing, ' ') || trailing[0] == ':' { + buf.WriteString(" :") + } else { + buf.WriteString(" ") + } + buf.WriteString(trailing) + } + + return buf.String() +} diff --git a/src/github.com/belak/irc/parser_test.go b/src/github.com/belak/irc/parser_test.go new file mode 100644 index 0000000..0e8146c --- /dev/null +++ b/src/github.com/belak/irc/parser_test.go @@ -0,0 +1,263 @@ +package irc + +import ( + "reflect" + "testing" +) + +var messageTests = []struct { + // Message parsing + Prefix, Cmd string + Params []string + + // Prefix parsing + Name, User, Host string + + // Total output + Expect string + IsNil bool + + // FromChannel + FromChan bool +}{ + { + IsNil: true, + }, + { + Expect: ":asd :", + IsNil: true, + }, + { + Expect: ":A", + IsNil: true, + }, + { + Prefix: "server.kevlar.net", + Cmd: "PING", + + Name: "server.kevlar.net", + + Expect: ":server.kevlar.net PING\n", + }, + { + Prefix: "server.kevlar.net", + Cmd: "NOTICE", + Params: []string{"user", "*** This is a test"}, + + Name: "server.kevlar.net", + + Expect: ":server.kevlar.net NOTICE user :*** This is a test\n", + }, + { + Prefix: "belakA!belakB@a.host.com", + Cmd: "PRIVMSG", + Params: []string{"#somewhere", "*** This is a test"}, + + Name: "belakA", + User: "belakB", + Host: "a.host.com", + + Expect: ":belakA!belakB@a.host.com PRIVMSG #somewhere :*** This is a test\n", + FromChan: true, + }, + { + Prefix: "freenode", + Cmd: "005", + Params: []string{"starkbot", "CHANLIMIT=#:120", "MORE", "are supported by this server"}, + + Name: "freenode", + + Expect: ":freenode 005 starkbot CHANLIMIT=#:120 MORE :are supported by this server\n", + }, + { + Prefix: "belakA!belakB@a.host.com", + Cmd: "PRIVMSG", + Params: []string{"&somewhere", "*** This is a test"}, + + Name: "belakA", + User: "belakB", + Host: "a.host.com", + + Expect: ":belakA!belakB@a.host.com PRIVMSG &somewhere :*** This is a test\n", + FromChan: true, + }, + { + Prefix: "belakA!belakB@a.host.com", + Cmd: "PRIVMSG", + Params: []string{"belak", "*** This is a test"}, + + Name: "belakA", + User: "belakB", + Host: "a.host.com", + + Expect: ":belakA!belakB@a.host.com PRIVMSG belak :*** This is a test\n", + }, + { + Prefix: "A", + Cmd: "B", + Params: []string{"C"}, + + Name: "A", + + Expect: ":A B C\n", + }, + { + Prefix: "A@B", + Cmd: "C", + Params: []string{"D"}, + + Name: "A", + Host: "B", + + Expect: ":A@B C D\n", + }, + { + Cmd: "B", + Params: []string{"C"}, + Expect: "B C\n", + }, + { + Prefix: "A", + Cmd: "B", + Params: []string{"C", "D"}, + + Name: "A", + + Expect: ":A B C D\n", + }, +} + +func TestParseMessage(t *testing.T) { + for i, test := range messageTests { + m := ParseMessage(test.Expect) + if m == nil && !test.IsNil { + t.Errorf("%d. Got nil for valid message", i) + } else if m != nil && test.IsNil { + t.Errorf("%d. Didn't get nil for invalid message", i) + } + + if m == nil { + continue + } + + if test.Cmd != m.Command { + t.Errorf("%d. command = %q, want %q", i, m.Command, test.Cmd) + } + if len(test.Params) != len(m.Params) { + t.Errorf("%d. args = %v, want %v", i, m.Params, test.Params) + } else { + for j := 0; j < len(test.Params) && j < len(m.Params); j++ { + if test.Params[j] != m.Params[j] { + t.Errorf("%d. arg[%d] = %q, want %q", i, j, m.Params[j], test.Params[j]) + } + } + } + } +} + +func BenchmarkParseMessage(b *testing.B) { + for i := 0; i < b.N; i++ { + ParseMessage(messageTests[i%len(messageTests)].Prefix) + } +} + +func TestParsePrefix(t *testing.T) { + for i, test := range messageTests { + // TODO: Not sure if we should be skipping empty strings or handling them. + if test.Prefix == "" { + continue + } + + pi := ParsePrefix(test.Prefix) + if pi == nil { + t.Errorf("%d. Got nil for valid identity", i) + continue + } + if test.Name != pi.Name { + t.Errorf("%d. name = %q, want %q", i, pi.Name, test.Name) + } + if test.User != pi.User { + t.Errorf("%d. user = %q, want %q", i, pi.User, test.User) + } + if test.Host != pi.Host { + t.Errorf("%d. host = %q, want %q", i, pi.Host, test.Host) + } + } +} + +func BenchmarkParsePrefix(b *testing.B) { + for i := 0; i < b.N; i++ { + ParsePrefix(messageTests[i%len(messageTests)].Expect) + } +} + +func TestMessageTrailing(t *testing.T) { + for i, test := range messageTests { + if test.IsNil { + continue + } + + m := ParseMessage(test.Expect) + tr := m.Trailing() + if len(test.Params) < 1 { + if tr != "" { + t.Errorf("%d. trailing = %q, want %q", i, tr, "") + } + } else if tr != test.Params[len(test.Params)-1] { + t.Errorf("%d. trailing = %q, want %q", i, tr, test.Params[len(test.Params)-1]) + } + } +} + +func TestMessageFromChan(t *testing.T) { + for i, test := range messageTests { + if test.IsNil { + continue + } + + m := ParseMessage(test.Expect) + if m.FromChannel() != test.FromChan { + t.Errorf("%d. fromchannel = %v, want %v", i, m.FromChannel(), test.FromChan) + } + } +} + +func TestMessageCopy(t *testing.T) { + for i, test := range messageTests { + if test.IsNil { + continue + } + + m := ParseMessage(test.Expect) + c := m.Copy() + + if !reflect.DeepEqual(m, c) { + t.Errorf("%d. copy = %q, want %q", i, m, c) + } + + if c.Prefix != nil { + c.Prefix.Name += "junk" + if reflect.DeepEqual(m, c) { + t.Errorf("%d. copyidentity matched when it shouldn't", i) + } + } + + c.Params = append(c.Params, "junk") + if reflect.DeepEqual(m, c) { + t.Errorf("%d. copyargs matched when it shouldn't", i) + } + } +} + +func TestMessageString(t *testing.T) { + for i, test := range messageTests { + if test.IsNil { + continue + } + + m := ParseMessage(test.Expect) + if m.String()+"\n" != test.Expect { + t.Errorf("%d. %s did not match %s", i, m.String(), test.Expect) + } + } +}