import belak/irc 8f71d74fe2eebaee1a626e85360a05fc395fc80e

This commit is contained in:
Christine Dodrill 2015-10-09 22:02:22 -07:00
parent 6bb3671a34
commit f8a34c927f
8 changed files with 743 additions and 0 deletions

2
src/github.com/belak/irc/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.cover
*.test

View File

@ -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

View File

@ -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.

View File

@ -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())
}
}
}
```

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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()
}

View File

@ -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)
}
}
}