Collapse and log join, part and quit, closes #27, log nick and topic changes, move state into irc package

This commit is contained in:
Ken-Håvard Lieng 2020-06-03 03:04:38 +02:00
parent edd4d6eadb
commit ead3b37cf9
37 changed files with 1980 additions and 969 deletions

View file

@ -5,6 +5,9 @@ import (
)
func (c *Client) HasCapability(name string, values ...string) bool {
c.lock.Lock()
defer c.lock.Unlock()
if capValues, ok := c.enabledCapabilities[name]; ok {
if len(values) == 0 || capValues == nil {
return true

View file

@ -35,8 +35,10 @@ type Client struct {
Messages chan *Message
ConnectionChanged chan ConnectionState
Features *Features
nick string
channels []string
state *state
nick string
channels []string
wantedCapabilities []string
requestedCapabilities map[string][]string
@ -80,18 +82,15 @@ func NewClient(config *Config) *Client {
wantedCapabilities = append(wantedCapabilities, "sasl")
}
return &Client{
client := &Client{
Config: config,
nick: config.Nick,
Features: NewFeatures(),
Messages: make(chan *Message, 32),
ConnectionChanged: make(chan ConnectionState, 4),
Features: NewFeatures(),
nick: config.Nick,
wantedCapabilities: wantedCapabilities,
requestedCapabilities: map[string][]string{},
enabledCapabilities: map[string][]string{},
ConnectionChanged: make(chan ConnectionState, 4),
out: make(chan string, 32),
quit: make(chan struct{}),
reconnect: make(chan struct{}),
dialer: &net.Dialer{Timeout: 10 * time.Second},
recvBuf: make([]byte, 0, 4096),
backoff: &backoff.Backoff{
@ -99,7 +98,13 @@ func NewClient(config *Config) *Client {
Max: 30 * time.Second,
Jitter: true,
},
out: make(chan string, 32),
quit: make(chan struct{}),
reconnect: make(chan struct{}),
}
client.state = newState(client)
return client
}
func (c *Client) GetNick() string {
@ -143,6 +148,18 @@ func (c *Client) Host() string {
return c.Config.Host
}
func (c *Client) MOTD() []string {
return c.state.getMOTD()
}
func (c *Client) ChannelUsers(channel string) []string {
return c.state.getUsers(channel)
}
func (c *Client) ChannelTopic(channel string) string {
return c.state.getTopic(channel)
}
func (c *Client) Nick(nick string) {
c.Write("NICK " + nick)
}

View file

@ -21,7 +21,7 @@ func (c *Client) Connect() {
}
func (c *Client) Reconnect() {
close(c.reconnect)
c.tryConnect()
}
func (c *Client) Write(data string) {
@ -63,6 +63,7 @@ func (c *Client) run() {
c.sendRecv.Wait()
c.reconnect = make(chan struct{})
c.state.reset()
time.Sleep(c.backoff.Duration())
c.tryConnect()
@ -178,7 +179,7 @@ func (c *Client) recv() {
default:
c.connChange(false, nil)
c.Reconnect()
close(c.reconnect)
return
}
}
@ -195,54 +196,7 @@ func (c *Client) recv() {
return
}
switch msg.Command {
case PING:
go c.write("PONG :" + msg.LastParam())
case JOIN:
if c.Is(msg.Sender) {
c.addChannel(msg.Params[0])
}
case NICK:
if c.Is(msg.Sender) {
c.setNick(msg.LastParam())
}
case PRIVMSG:
if ctcp := msg.ToCTCP(); ctcp != nil {
c.handleCTCP(ctcp, msg)
}
case CAP:
c.handleCAP(msg)
case RPL_WELCOME:
c.setNick(msg.Params[0])
c.setRegistered(true)
c.flushChannels()
c.backoff.Reset()
c.sendRecv.Add(1)
go c.send()
case RPL_ISUPPORT:
c.Features.Parse(msg.Params)
case ERR_NICKNAMEINUSE, ERR_NICKCOLLISION, ERR_UNAVAILRESOURCE:
if c.Config.HandleNickInUse != nil {
go c.writeNick(c.Config.HandleNickInUse(msg.Params[1]))
}
case ERROR:
c.Messages <- msg
c.connChange(false, nil)
time.Sleep(5 * time.Second)
close(c.quit)
return
}
c.handleSASL(msg)
c.handleMessage(msg)
c.Messages <- msg
}

View file

@ -147,7 +147,7 @@ func TestRecv(t *testing.T) {
func TestRecvTriggersReconnect(t *testing.T) {
c := NewClient(&Config{})
c.conn = &mockConn{}
c.scan = bufio.NewScanner(bytes.NewBufferString("001 bob\r\n"))
c.scan = bufio.NewScanner(bytes.NewBufferString(""))
done := make(chan struct{})
ok := false
go func() {

158
pkg/irc/internal.go Normal file
View file

@ -0,0 +1,158 @@
package irc
import (
"strings"
"time"
)
func (c *Client) handleMessage(msg *Message) {
switch msg.Command {
case CAP:
c.handleCAP(msg)
case PING:
go c.write("PONG :" + msg.LastParam())
case JOIN:
if len(msg.Params) > 0 {
channel := msg.Params[0]
if c.Is(msg.Sender) {
c.addChannel(channel)
}
c.state.addUser(msg.Sender, channel)
}
case PART:
if len(msg.Params) > 0 {
channel := msg.Params[0]
if c.Is(msg.Sender) {
c.state.removeChannel(channel)
} else {
c.state.removeUser(msg.Sender, channel)
}
}
case QUIT:
msg.meta = c.state.removeUserAll(msg.Sender)
case NICK:
if c.Is(msg.Sender) {
c.setNick(msg.LastParam())
}
msg.meta = c.state.renameUser(msg.Sender, msg.LastParam())
case PRIVMSG:
if ctcp := msg.ToCTCP(); ctcp != nil {
c.handleCTCP(ctcp, msg)
}
case MODE:
if len(msg.Params) > 1 {
target := msg.Params[0]
if len(msg.Params) > 2 && isChannel(target) {
mode := ParseMode(msg.Params[1])
mode.Server = c.Host()
mode.Channel = target
mode.User = msg.Params[2]
c.state.setMode(target, msg.Params[2], mode.Add, mode.Remove)
msg.meta = mode
}
}
case TOPIC, RPL_TOPIC:
chIndex := 0
if msg.Command == RPL_TOPIC {
chIndex = 1
}
if len(msg.Params) > chIndex {
c.state.setTopic(msg.LastParam(), msg.Params[chIndex])
}
case RPL_NOTOPIC:
if len(msg.Params) > 1 {
channel := msg.Params[1]
c.state.setTopic("", channel)
}
case RPL_WELCOME:
if len(msg.Params) > 0 {
c.setNick(msg.Params[0])
}
c.setRegistered(true)
c.flushChannels()
c.backoff.Reset()
c.sendRecv.Add(1)
go c.send()
case RPL_ISUPPORT:
c.Features.Parse(msg.Params)
case ERR_NICKNAMEINUSE, ERR_NICKCOLLISION, ERR_UNAVAILRESOURCE:
if c.Config.HandleNickInUse != nil && len(msg.Params) > 1 {
go c.writeNick(c.Config.HandleNickInUse(msg.Params[1]))
}
case RPL_NAMREPLY:
channel := msg.Params[2]
users := strings.Split(strings.TrimSuffix(msg.LastParam(), " "), " ")
userBuffer := c.state.userBuffers[channel]
c.state.userBuffers[channel] = append(userBuffer, users...)
case RPL_ENDOFNAMES:
channel := msg.Params[1]
users := c.state.userBuffers[channel]
c.state.setUsers(users, channel)
delete(c.state.userBuffers, channel)
msg.meta = users
case ERROR:
c.Messages <- msg
c.connChange(false, nil)
time.Sleep(5 * time.Second)
close(c.quit)
return
}
c.handleSASL(msg)
}
type Mode struct {
Server string
Channel string
User string
Add string
Remove string
}
func ParseMode(mode string) *Mode {
m := Mode{}
add := false
for _, c := range mode {
if c == '+' {
add = true
} else if c == '-' {
add = false
} else if add {
m.Add += string(c)
} else {
m.Remove += string(c)
}
}
return &m
}
func isChannel(s string) bool {
return strings.IndexAny(s, "&#+!") == 0
}

37
pkg/irc/internal_test.go Normal file
View file

@ -0,0 +1,37 @@
package irc
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestHandlePing(t *testing.T) {
c, out := testClientSend()
c.handleMessage(&Message{
Command: "PING",
Params: []string{"voi voi"},
})
assert.Equal(t, "PONG :voi voi\r\n", <-out)
}
func TestHandleNamreply(t *testing.T) {
c, _ := testClientSend()
c.handleMessage(&Message{
Command: RPL_NAMREPLY,
Params: []string{"", "", "#chan", "a b c"},
})
c.handleMessage(&Message{
Command: RPL_NAMREPLY,
Params: []string{"", "", "#chan", "d"},
})
endMsg := &Message{
Command: RPL_ENDOFNAMES,
Params: []string{"", "#chan"},
}
c.handleMessage(endMsg)
assert.Equal(t, []string{"a", "b", "c", "d"}, endMsg.meta)
}

View file

@ -11,6 +11,8 @@ type Message struct {
Host string
Command string
Params []string
meta interface{}
}
func (m *Message) LastParam() string {

33
pkg/irc/meta.go Normal file
View file

@ -0,0 +1,33 @@
package irc
// GetNickChannels returns the channels the client has in common with
// the user that changed nick
func GetNickChannels(msg *Message) []string {
return stringListMeta(msg)
}
// GetQuitChannels returns the channels the client has in common with
// the user that quit
func GetQuitChannels(msg *Message) []string {
return stringListMeta(msg)
}
func GetMode(msg *Message) *Mode {
if mode, ok := msg.meta.(*Mode); ok {
return mode
}
return nil
}
// GetNamreplyUsers returns all RPL_NAMREPLY users
// when passed a RPL_ENDOFNAMES message
func GetNamreplyUsers(msg *Message) []string {
return stringListMeta(msg)
}
func stringListMeta(msg *Message) []string {
if list, ok := msg.meta.([]string); ok {
return list
}
return nil
}

236
pkg/irc/state.go Normal file
View file

@ -0,0 +1,236 @@
package irc
import (
"strings"
"sync"
)
type state struct {
client *Client
users map[string][]*User
topic map[string]string
userBuffers map[string][]string
motd []string
lock sync.Mutex
}
const userModePrefixes = "~&@%+"
const userModeChars = "qaohv"
type User struct {
nick string
modes string
prefix string
}
func NewUser(nick string) *User {
user := &User{nick: nick}
if i := strings.IndexAny(nick, userModePrefixes); i == 0 {
i = strings.Index(userModePrefixes, string(nick[0]))
user.modes = string(userModeChars[i])
user.nick = nick[1:]
user.updatePrefix()
}
return user
}
func (u *User) String() string {
return u.prefix + u.nick
}
func (u *User) AddModes(modes string) {
for _, mode := range modes {
if strings.Contains(u.modes, string(mode)) {
continue
}
u.modes += string(mode)
}
u.updatePrefix()
}
func (u *User) RemoveModes(modes string) {
for _, mode := range modes {
u.modes = strings.Replace(u.modes, string(mode), "", 1)
}
u.updatePrefix()
}
func (u *User) updatePrefix() {
for i, mode := range userModeChars {
if strings.Contains(u.modes, string(mode)) {
u.prefix = string(userModePrefixes[i])
return
}
}
u.prefix = ""
}
func newState(client *Client) *state {
return &state{
client: client,
users: make(map[string][]*User),
topic: make(map[string]string),
userBuffers: make(map[string][]string),
}
}
func (s *state) reset() {
s.lock.Lock()
s.users = make(map[string][]*User)
s.topic = make(map[string]string)
s.userBuffers = make(map[string][]string)
s.motd = []string{}
s.lock.Unlock()
}
func (s *state) removeChannel(channel string) {
s.lock.Lock()
delete(s.users, channel)
delete(s.topic, channel)
s.lock.Unlock()
}
func (s *state) getUsers(channel string) []string {
s.lock.Lock()
users := make([]string, len(s.users[channel]))
for i, user := range s.users[channel] {
users[i] = user.String()
}
s.lock.Unlock()
return users
}
func (s *state) setUsers(users []string, channel string) {
s.lock.Lock()
s.users[channel] = make([]*User, len(users))
for i, nick := range users {
s.users[channel][i] = NewUser(nick)
}
s.lock.Unlock()
}
func (s *state) addUser(user, channel string) {
s.lock.Lock()
if users, ok := s.users[channel]; ok {
for _, u := range users {
if u.nick == user {
s.lock.Unlock()
return
}
}
s.users[channel] = append(users, NewUser(user))
} else {
s.users[channel] = []*User{NewUser(user)}
}
s.lock.Unlock()
}
func (s *state) removeUser(user, channel string) {
s.lock.Lock()
s.internalRemoveUser(user, channel)
s.lock.Unlock()
}
func (s *state) removeUserAll(user string) []string {
channels := []string{}
s.lock.Lock()
for channel := range s.users {
if s.internalRemoveUser(user, channel) {
channels = append(channels, channel)
}
}
s.lock.Unlock()
return channels
}
func (s *state) renameUser(oldNick, newNick string) []string {
s.lock.Lock()
channels := s.renameAll(oldNick, newNick)
s.lock.Unlock()
return channels
}
func (s *state) setMode(channel, user, add, remove string) {
s.lock.Lock()
for _, u := range s.users[channel] {
if u.nick == user {
u.AddModes(add)
u.RemoveModes(remove)
s.lock.Unlock()
return
}
}
s.lock.Unlock()
}
func (s *state) getTopic(channel string) string {
s.lock.Lock()
topic := s.topic[channel]
s.lock.Unlock()
return topic
}
func (s *state) setTopic(topic, channel string) {
s.lock.Lock()
s.topic[channel] = topic
s.lock.Unlock()
}
func (s *state) getMOTD() []string {
s.lock.Lock()
motd := s.motd
s.lock.Unlock()
return motd
}
func (s *state) rename(channel, oldNick, newNick string) bool {
for _, user := range s.users[channel] {
if user.nick == oldNick {
user.nick = newNick
return true
}
}
return false
}
func (s *state) renameAll(oldNick, newNick string) []string {
channels := []string{}
for channel := range s.users {
if s.rename(channel, oldNick, newNick) {
channels = append(channels, channel)
}
}
return channels
}
func (s *state) internalRemoveUser(user, channel string) bool {
for i, u := range s.users[channel] {
if u.nick == user {
users := s.users[channel]
s.users[channel] = append(users[:i], users[i+1:]...)
return true
}
}
return false
}

89
pkg/irc/state_test.go Normal file
View file

@ -0,0 +1,89 @@
package irc
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStateGetSetUsers(t *testing.T) {
state := newState(NewClient(&Config{}))
users := []string{"a", "b"}
state.setUsers(users, "#chan")
assert.Equal(t, users, state.getUsers("#chan"))
state.setUsers(users, "#chan")
assert.Equal(t, users, state.getUsers("#chan"))
}
func TestStateAddRemoveUser(t *testing.T) {
state := newState(NewClient(&Config{}))
state.addUser("user", "#chan")
state.addUser("user", "#chan")
assert.Len(t, state.getUsers("#chan"), 1)
state.addUser("user2", "#chan")
assert.Equal(t, []string{"user", "user2"}, state.getUsers("#chan"))
state.removeUser("user", "#chan")
assert.Equal(t, []string{"user2"}, state.getUsers("#chan"))
}
func TestStateRemoveUserAll(t *testing.T) {
state := newState(NewClient(&Config{}))
state.addUser("user", "#chan1")
state.addUser("user", "#chan2")
state.removeUserAll("user")
assert.Empty(t, state.getUsers("#chan1"))
assert.Empty(t, state.getUsers("#chan2"))
}
func TestStateRenameUser(t *testing.T) {
state := newState(NewClient(&Config{}))
state.addUser("user", "#chan1")
state.addUser("user", "#chan2")
state.renameUser("user", "new")
assert.Equal(t, []string{"new"}, state.getUsers("#chan1"))
assert.Equal(t, []string{"new"}, state.getUsers("#chan2"))
state.addUser("@gotop", "#chan3")
state.renameUser("gotop", "stillgotit")
assert.Equal(t, []string{"@stillgotit"}, state.getUsers("#chan3"))
}
func TestStateMode(t *testing.T) {
state := newState(NewClient(&Config{}))
state.addUser("+user", "#chan")
state.setMode("#chan", "user", "o", "v")
assert.Equal(t, []string{"@user"}, state.getUsers("#chan"))
state.setMode("#chan", "user", "v", "")
assert.Equal(t, []string{"@user"}, state.getUsers("#chan"))
state.setMode("#chan", "user", "", "o")
assert.Equal(t, []string{"+user"}, state.getUsers("#chan"))
state.setMode("#chan", "user", "q", "")
assert.Equal(t, []string{"~user"}, state.getUsers("#chan"))
}
func TestStateTopic(t *testing.T) {
state := newState(NewClient(&Config{}))
assert.Equal(t, "", state.getTopic("#chan"))
state.setTopic("the topic", "#chan")
assert.Equal(t, "the topic", state.getTopic("#chan"))
}
func TestStateChannelUserMode(t *testing.T) {
user := NewUser("&test")
assert.Equal(t, "test", user.nick)
assert.Equal(t, "a", string(user.modes[0]))
assert.Equal(t, "&test", user.String())
user.RemoveModes("a")
assert.Equal(t, "test", user.String())
user.AddModes("o")
assert.Equal(t, "@test", user.String())
user.AddModes("q")
assert.Equal(t, "~test", user.String())
user.AddModes("v")
assert.Equal(t, "~test", user.String())
user.RemoveModes("qo")
assert.Equal(t, "+test", user.String())
user.RemoveModes("v")
assert.Equal(t, "test", user.String())
}