diff --git a/builtin.go b/builtin.go index 2994ed8..549fba0 100644 --- a/builtin.go +++ b/builtin.go @@ -7,6 +7,7 @@ package girc import ( "strconv" "strings" + "sync/atomic" "time" "github.com/araddon/dateparse" @@ -123,7 +124,10 @@ func nickCollisionHandler(c *Client, e Event) { return } - c.Cmd.Nick(c.Config.HandleNickCollide(c.GetNick())) + newNick := c.Config.HandleNickCollide(c.GetNick()) + if newNick != "" { + c.Cmd.Nick(newNick) + } } // handlePING helps respond to ping requests from the server. @@ -488,23 +492,25 @@ func handleISUPPORT(c *Client, e Event) { return } - c.state.Lock() // Skip the first parameter, as it's our nickname, and the last, as it's the doc. - for i := 1; i < len(e.Params)-1; i++ { - j := strings.IndexByte(e.Params[i], '=') + for i := range e.Params { + split := strings.Split(e.Params[i], "=") - if j < 1 || (j+1) == len(e.Params[i]) { - opt := c.state.serverOptions[e.Params[i]] - opt.Store("") + if len(split) != 2 { + c.state.serverOptions[e.Params[i]] = &atomic.Value{} + c.state.serverOptions[e.Params[i]].Store("") continue } - name := e.Params[i][0:j] - val := e.Params[i][j+1:] - opt := c.state.serverOptions[name] - opt.Store(val) + if len(split[0]) < 1 || len(split[1]) < 1 { + c.state.serverOptions[e.Params[i]] = &atomic.Value{} + c.state.serverOptions[e.Params[i]].Store("") + continue + } + + c.state.serverOptions[split[0]] = &atomic.Value{} + c.state.serverOptions[split[0]].Store(split[1]) } - c.state.Unlock() c.state.notify(c, UPDATE_GENERAL) } diff --git a/client.go b/client.go index 829fc33..d927fb6 100644 --- a/client.go +++ b/client.go @@ -19,7 +19,6 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "time" ) @@ -199,6 +198,9 @@ type Config struct { // an invalid nickname. For example, if "test" is already in use, or is // blocked by the network/a service, the client will try and use "test_", // then it will attempt "test__", "test___", and so on. + // + // If HandleNickCollide returns an empty string, the client will not + // attempt to fix nickname collisions, and you must handle this yourself. HandleNickCollide func(oldNick string) (newNick string) } @@ -690,10 +692,9 @@ func (c *Client) IsInChannel(channel string) (in bool) { // func (c *Client) GetServerOption(key string) (result string, ok bool) { c.panicIfNotTracking() - var opt atomic.Value - if opt, ok = c.state.serverOptions[key]; ok { - result = opt.Load().(string) + if _, ok := c.state.serverOptions[key]; ok { + return c.state.serverOptions[key].Load().(string), ok } return result, ok @@ -725,8 +726,14 @@ func (c *Client) GetAllServerOption() (map[string]string, error) { func (c *Client) NetworkName() (name string) { c.panicIfNotTracking() - name, _ = c.GetServerOption("NETWORK") - if len(name) < 1 { + var ok bool + + name, ok = c.GetServerOption("NETWORK") + if !ok { + return c.IRCd.Network + } + + if len(name) < 0 && len(c.IRCd.Network) > 1 { name = c.IRCd.Network } diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..1a939d8 --- /dev/null +++ b/client_test.go @@ -0,0 +1,200 @@ +// Copyright (c) Liam Stanley . All rights reserved. Use +// of this source code is governed by the MIT license that can be found in +// the LICENSE file. + +package girc + +import ( + "strings" + "testing" + "time" +) + +func TestDisableTracking(t *testing.T) { + client := New(Config{ + Server: "dummy.int", + Port: 6667, + Nick: "test", + User: "test", + Name: "Testing123", + }) + + if len(client.Handlers.internal) < 1 { + t.Fatal("Client.Handlers empty, though just initialized") + } + + client.DisableTracking() + if _, ok := client.Handlers.internal[CAP]; ok { + t.Fatal("Client.Handlers contains capability tracking handlers, though disabled") + } + + client.state.Lock() + defer client.state.Unlock() + + if client.state.channels != nil { + t.Fatal("Client.DisableTracking() called but channel state still exists") + } +} + +func TestConfigValid(t *testing.T) { + conf := Config{ + Server: "irc.example.com", Port: 6667, + Nick: "test", User: "test", Name: "Realname", + } + + var err error + if err = conf.isValid(); err != nil { + t.Fatalf("valid config failed Config.isValid() with: %s", err) + } + + conf.Server = "" + if err = conf.isValid(); err == nil { + t.Fatalf("invalid server passed validation check: %s", err) + } + conf.Server = "irc.example.com" + + conf.Port = 100000 + if err = conf.isValid(); err == nil { + t.Fatalf("invalid port passed validation check: %s", err) + } + conf.Port = 0 // Assumes "default". + if err = conf.isValid(); err != nil { + t.Fatalf("valid default failed validation check: %s", err) + } + if conf.Port != 6667 { + t.Fatal("irc port was not defaulted to 6667") + } + + conf.Nick = "invalid nick" + if err = conf.isValid(); err == nil { + t.Fatalf("invalid nick passed validation check: %s", err) + } + conf.User = "test" + + conf.User = "invalid user" + if err = conf.isValid(); err == nil { + t.Fatalf("invalid user passed validation check: %s", err) + } + conf.User = "test" +} + +func TestClientLifetime(t *testing.T) { + client := New(Config{ + Server: "dummy.int", + Port: 6667, + Nick: "test", + User: "test", + Name: "Testing123", + }) + + tm := client.Lifetime() + + if tm < 0 || tm > 2*time.Second { + t.Fatalf("Client.Lifetime() = %q, out of bounds", tm) + } +} + +func TestClientUptime(t *testing.T) { + c, conn, server := genMockConn() + defer conn.Close() + defer server.Close() + go mockReadBuffer(conn) + + done := make(chan struct{}, 1) + c.Handlers.Add(INITIALIZED, func(c *Client, e Event) { close(done) }) + + go c.MockConnect(server) + defer c.Close() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Client.Uptime() timed out") + } + + uptime, err := c.Uptime() + if err != nil { + t.Fatalf("Client.Uptime() = %s, wanted time", err) + } + + since := time.Since(uptime) + connsince, err := c.ConnSince() + if err != nil { + t.Fatalf("Client.ConnSince() = %s, wanted time", err) + } + + if since < 0 || since > 4*time.Second || *connsince < 0 || *connsince > 4*time.Second { + t.Fatalf("Client.Uptime() = %q (%q, connsince: %q), out of bounds", uptime, since, connsince) + } + + // Verify the time we got from Client.Uptime() and Client.ConnSince() are + // within reach of eachother. + + if *connsince-since > 2*time.Second { + t.Fatalf("Client.Uptime() (diff) = %q, Client.ConnSince() = %q, differ too much", since, connsince) + } + + if !c.IsConnected() { + t.Fatal("Client.IsConnected() = false, though mock should be true") + } +} + +func TestClientGet(t *testing.T) { + c, conn, server := genMockConn() + defer conn.Close() + defer server.Close() + go mockReadBuffer(conn) + + done := make(chan struct{}, 1) + c.Handlers.Add(INITIALIZED, func(c *Client, e Event) { close(done) }) + + go c.MockConnect(server) + defer c.Close() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out during connect") + } + + if nick := c.GetNick(); nick != c.Config.Nick { + t.Fatalf("Client.GetNick() = %q though should be %q", nick, c.Config.Nick) + } + + if user := c.GetIdent(); user != c.Config.User { + t.Fatalf("Client.GetIdent() = %q though should be %q", user, c.Config.User) + } + + if !strings.Contains(c.String(), "connected:true") { + t.Fatalf("Client.String() == %q, doesn't contain 'connected:true'", c.String()) + } +} + +func TestClientClose(t *testing.T) { + c, conn, server := genMockConn() + defer server.Close() + defer conn.Close() + go mockReadBuffer(conn) + + errchan := make(chan error, 1) + done := make(chan struct{}, 1) + + c.Handlers.AddBg(CLOSED, func(c *Client, e Event) { close(done) }) + c.Handlers.AddBg(INITIALIZED, func(c *Client, e Event) { c.Close() }) + + go func() { errchan <- c.MockConnect(server) }() + + defer c.Close() + + select { + case err := <-errchan: + if err == nil { + break + } + + t.Fatalf("connect returned with error when close was invoked: %s", err) + case <-time.After(5 * time.Second): + t.Fatal("Client.Close() timed out") + case <-done: + } +} diff --git a/conn.go b/conn.go index 5d092a7..2afb6d7 100644 --- a/conn.go +++ b/conn.go @@ -145,6 +145,17 @@ type ErrParseEvent struct { func (e ErrParseEvent) Error() string { return "unable to parse event: " + e.Line } +func (c *ircConn) encode(event *Event) error { + if _, err := c.io.Write(event.Bytes()); err != nil { + return err + } + if _, err := c.io.Write(endline); err != nil { + return err + } + + return c.io.Flush() +} + func (c *ircConn) decode() (event *Event, err error) { line, err := c.io.ReadString(delim) if err != nil { diff --git a/conn_test.go b/conn_test.go new file mode 100644 index 0000000..7232cef --- /dev/null +++ b/conn_test.go @@ -0,0 +1,120 @@ +// Copyright (c) Liam Stanley . All rights reserved. Use +// of this source code is governed by the MIT license that can be found in +// the LICENSE file. + +package girc + +import ( + "bufio" + "bytes" + "net" + "sync/atomic" + "testing" + "time" +) + +func mockBuffers() (in *bytes.Buffer, out *bytes.Buffer, irc *ircConn) { + in = &bytes.Buffer{} + out = &bytes.Buffer{} + irc = &ircConn{ + io: bufio.NewReadWriter(bufio.NewReader(in), bufio.NewWriter(out)), + connected: atomic.Value{}, + } + + return in, out, irc +} + +func TestDecode(t *testing.T) { + in, _, c := mockBuffers() + + e := mockEvent() + + in.Write(e.Bytes()) + in.Write(endline) + + event, err := c.decode() + if err != nil { + t.Fatalf("received error during decode: %s", err) + } + + if event.String() != e.String() { + t.Fatalf("event returned from decode not the same as mock event. want %#v, got %#v", e, event) + } + + // Test a failure. + in.WriteString("::abcd\r\n") + event, err = c.decode() + if err == nil { + t.Fatalf("should have failed to parse decoded event. got: %#v", event) + } + + return +} + +func TestEncode(t *testing.T) { + _, out, c := mockBuffers() + + e := mockEvent() + + err := c.encode(e) + if err != nil { + t.Fatalf("received error during encode: %s", err) + } + + line, err := out.ReadString(delim) + if err != nil { + t.Fatalf("received error during check for encoded event: %s", err) + } + + want := e.String() + "\r\n" + + if want != line { + t.Fatalf("encoded line wanted: %q, got: %q", want, line) + } + + return +} + +func TestRate(t *testing.T) { + _, _, c := mockBuffers() + c.lastWrite.Store(time.Now()) + if delay := c.rate(100); delay > time.Second { + t.Fatal("first instance of rate is > second") + } + + for i := 0; i < 500; i++ { + c.rate(200) + } + + if delay := c.rate(200); delay > (3 * time.Second) { + t.Fatal("rate delay too high") + } + + return +} + +func genMockConn() (client *Client, clientConn net.Conn, serverConn net.Conn) { + client = New(Config{ + Server: "dummy.int", + Port: 6667, + Nick: "test", + User: "test", + Name: "Testing123", + }) + + conn1, conn2 := net.Pipe() + + return client, conn1, conn2 +} + +func mockReadBuffer(conn net.Conn) { + // Accept all outgoing writes from the client. + b := bufio.NewReader(conn) + for { + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + _, err := b.ReadString(byte('\n')) + if err != nil { + return + } + } +} diff --git a/state.go b/state.go index 5e87fc9..89a2617 100644 --- a/state.go +++ b/state.go @@ -32,7 +32,7 @@ type state struct { // serverOptions are the standard capabilities and configurations // supported by the server at connection time. This also includes // RPL_ISUPPORT entries. - serverOptions map[string]atomic.Value + serverOptions map[string]*atomic.Value // motd is the servers message of the day. motd string @@ -52,7 +52,7 @@ func (s *state) reset(initial bool) { s.host.Store("") s.channels = make(map[string]*Channel) s.users = make(map[string]*User) - s.serverOptions = make(map[string]atomic.Value) + s.serverOptions = make(map[string]*atomic.Value) s.enabledCap = make(map[string]map[string]string) s.tmpCap = make(map[string]map[string]string) s.motd = "" diff --git a/state_test.go b/state_test.go new file mode 100644 index 0000000..7021663 --- /dev/null +++ b/state_test.go @@ -0,0 +1,261 @@ +// Copyright (c) Liam Stanley . All rights reserved. Use +// of this source code is governed by the MIT license that can be found in +// the LICENSE file. + +package girc + +import ( + "reflect" + "testing" + "time" +) + +func debounce(delay time.Duration, done chan bool, f func()) { + var init bool + for { + select { + case <-done: + init = true + case <-time.After(delay): + if init { + f() + return + } + } + } +} + +const mockConnStartState = `:dummy.int NOTICE * :*** Looking up your hostname... +:dummy.int NOTICE * :*** Checking Ident +:dummy.int NOTICE * :*** Found your hostname +:dummy.int NOTICE * :*** No Ident response +:dummy.int 001 fhjones :Welcome to the DUMMY Internet Relay Chat Network fhjones +:dummy.int 005 fhjones NETWORK=DummyIRC NICKLEN=20 :are supported by this server +:dummy.int 375 fhjones :- dummy.int Message of the Day - +:dummy.int 372 fhjones :example motd +:dummy.int 376 fhjones :End of /MOTD command. +:fhjones!~user@local.int JOIN #channel * :realname +:dummy.int 332 fhjones #channel :example topic +:dummy.int 353 fhjones = #channel :fhjones!~user@local.int @nick2!nick2@other.int +:dummy.int 366 fhjones #channel :End of /NAMES list. +:dummy.int 354 fhjones 1 #channel ~user local.int fhjones 0 :realname +:dummy.int 354 fhjones 1 #channel nick2 other.int nick2 nick2 :realname2 +:dummy.int 315 fhjones #channel :End of /WHO list. +:fhjones!~user@local.int JOIN #channel2 * :realname +:dummy.int 332 fhjones #channel2 :example topic +:dummy.int 353 fhjones = #channel2 :fhjones!~user@local.int @nick2!nick2@other.int +:dummy.int 366 fhjones #channel2 :End of /NAMES list. +:dummy.int 354 fhjones 1 #channel2 ~user local.int fhjones 0 :realname +:dummy.int 354 fhjones 1 #channel2 nick2 other.int nick2 nick2 :realname2 +:dummy.int 315 fhjones #channel2 :End of /WHO list. +` + +const mockConnEndState = `:nick2!nick2@other.int QUIT :example reason +:fhjones!~user@local.int PART #channel2 :example reason +:fhjones!~user@local.int NICK notjones +` + +func TestState(t *testing.T) { + c, conn, server := genMockConn() + defer c.Close() + go mockReadBuffer(conn) + + go func() { + err := c.MockConnect(server) + if err != nil { + panic(err) + } + }() + + bounceStart := make(chan bool, 1) + finishStart := make(chan bool, 1) + go debounce(250*time.Millisecond, bounceStart, func() { + if motd := c.ServerMOTD(); motd != "example motd" { + t.Fatalf("Client.ServerMOTD() returned invalid MOTD: %q", motd) + } + + if network := c.NetworkName(); network != "DummyIRC" { + t.Fatalf("Client.NetworkName() returned invalid network name: %q", network) + } + + if caseExample, ok := c.GetServerOption("NICKLEN"); !ok || caseExample != "20" { + t.Fatalf("Client.GetServerOptions returned invalid ISUPPORT variable") + } + + users := c.UserList() + channels := c.ChannelList() + + if !reflect.DeepEqual(users, []string{"fhjones", "nick2"}) { + // This could fail too, if sorting isn't occurring. + t.Fatalf("got state users %#v, wanted: %#v", users, []string{"fhjones", "nick2"}) + } + + if !reflect.DeepEqual(channels, []string{"#channel", "#channel2"}) { + // This could fail too, if sorting isn't occurring. + t.Fatalf("got state channels %#v, wanted: %#v", channels, []string{"#channel", "#channel2"}) + } + + fullChannels := c.Channels() + for i := 0; i < len(fullChannels); i++ { + if fullChannels[i].Name != channels[i] { + t.Fatalf("fullChannels name doesn't map to same name in ChannelsList: %q :: %#v", fullChannels[i].Name, channels) + } + } + + fullUsers := c.Users() + for i := 0; i < len(fullUsers); i++ { + if fullUsers[i].Nick != users[i] { + t.Fatalf("fullUsers nick doesn't map to same nick in UsersList: %q :: %#v", fullUsers[i].Nick, users) + } + } + + ch := c.LookupChannel("#channel") + if ch == nil { + t.Fatal("Client.LookupChannel returned nil on existing channel") + } + + adm := ch.Admins(c) + admList := []string{} + for i := 0; i < len(adm); i++ { + admList = append(admList, adm[i].Nick) + } + trusted := ch.Trusted(c) + trustedList := []string{} + for i := 0; i < len(trusted); i++ { + trustedList = append(trustedList, trusted[i].Nick) + } + + if !reflect.DeepEqual(admList, []string{"nick2"}) { + t.Fatalf("got Channel.Admins() == %#v, wanted %#v", admList, []string{"nick2"}) + } + + if !reflect.DeepEqual(trustedList, []string{"nick2"}) { + t.Fatalf("got Channel.Trusted() == %#v, wanted %#v", trustedList, []string{"nick2"}) + } + + if topic := ch.Topic; topic != "example topic" { + t.Fatalf("Channel.Topic == %q, want \"example topic\"", topic) + } + + if in := ch.UserIn("fhjones"); !in { + t.Fatalf("Channel.UserIn == %t, want %t", in, true) + } + + if users := ch.Users(c); len(users) != 2 { + t.Fatalf("Channel.Users == %#v, wanted length of 2", users) + } + + if h := c.GetHost(); h != "local.int" { + t.Fatalf("Client.GetHost() == %q, want local.int", h) + } + + if nick := c.GetNick(); nick != "fhjones" { + t.Fatalf("Client.GetNick() == %q, want nick", nick) + } + + if ident := c.GetIdent(); ident != "~user" { + t.Fatalf("Client.GetIdent() == %q, want ~user", ident) + } + + user := c.LookupUser("fhjones") + if user == nil { + t.Fatal("Client.LookupUser() returned nil on existing user") + } + + if !reflect.DeepEqual(user.ChannelList, []string{"#channel", "#channel2"}) { + t.Fatalf("User.ChannelList == %#v, wanted %#v", user.ChannelList, []string{"#channel", "#channel2"}) + } + + if count := len(user.Channels(c)); count != 2 { + t.Fatalf("len(User.Channels) == %d, want 2", count) + } + + if user.Nick != "fhjones" { + t.Fatalf("User.Nick == %q, wanted \"nick\"", user.Nick) + } + + if user.Extras.Name != "realname" { + t.Fatalf("User.Extras.Name == %q, wanted \"realname\"", user.Extras.Name) + } + + if user.Host != "local.int" { + t.Fatalf("User.Host == %q, wanted \"local.int\"", user.Host) + } + + if user.Ident != "~user" { + t.Fatalf("User.Ident == %q, wanted \"~user\"", user.Ident) + } + + if !user.InChannel("#channel2") { + t.Fatal("User.InChannel() returned false for existing channel") + } + + finishStart <- true + }) + + cuid := c.Handlers.AddBg(UPDATE_STATE, func(c *Client, e Event) { + bounceStart <- true + }) + + conn.SetDeadline(time.Now().Add(5 * time.Second)) + _, err := conn.Write([]byte(mockConnStartState)) + if err != nil { + panic(err) + } + + select { + case <-finishStart: + case <-time.After(5 * time.Second): + t.Fatal("timed out while waiting for state update start") + } + c.Handlers.Remove(cuid) + + bounceEnd := make(chan bool, 1) + finishEnd := make(chan bool, 1) + go debounce(250*time.Millisecond, bounceEnd, func() { + if !reflect.DeepEqual(c.ChannelList(), []string{"#channel"}) { + t.Fatalf("Client.ChannelList() == %#v, wanted %#v", c.ChannelList(), []string{"#channel"}) + } + + if !reflect.DeepEqual(c.UserList(), []string{"notjones"}) { + t.Fatalf("Client.UserList() == %#v, wanted %#v", c.UserList(), []string{"notjones"}) + } + + user := c.LookupUser("notjones") + if user == nil { + t.Fatal("Client.LookupUser() returned nil for existing user") + } + + if !reflect.DeepEqual(user.ChannelList, []string{"#channel"}) { + t.Fatalf("user.ChannelList == %q, wanted %q", user.ChannelList, []string{"#channel"}) + } + + channel := c.LookupChannel("#channel") + if channel == nil { + t.Fatal("Client.LookupChannel() returned nil for existing channel") + } + + if !reflect.DeepEqual(channel.UserList, []string{"notjones"}) { + t.Fatalf("channel.UserList == %q, wanted %q", channel.UserList, []string{"notjones"}) + } + + finishEnd <- true + }) + + cuid = c.Handlers.AddBg(UPDATE_STATE, func(c *Client, e Event) { + bounceEnd <- true + }) + + conn.SetDeadline(time.Now().Add(5 * time.Second)) + _, err = conn.Write([]byte(mockConnEndState)) + if err != nil { + panic(err) + } + + select { + case <-finishEnd: + case <-time.After(5 * time.Second): + t.Fatal("timed out while waiting for state update end") + } + c.Handlers.Remove(cuid) +}