diff --git a/chat/command.go b/chat/command.go index 61b7073..dea601b 100644 --- a/chat/command.go +++ b/chat/command.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/shazow/ssh-chat/chat/message" + "github.com/shazow/ssh-chat/common" ) // The error returned when an invalid command is issued. @@ -240,4 +241,62 @@ func InitCommands(c *Commands) { return nil }, }) + + c.Add(Command{ + Prefix: "/ignore", + PrefixHelp: "[USER]", + Help: "Ignore messages from USER, list ignored users without parameters.", + Handler: func(room *Room, msg message.CommandMsg) error { + id := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/ignore")) + if id == "" { + var names []string + msg.From().Ignored.Each(func(i common.Identified) { + names = append(names, i.Id()) + }) + + var systemMsg string + if len(names) == 0 { + systemMsg = "0 users ignored." + } else { + systemMsg = fmt.Sprintf("%d ignored: %s", len(names), strings.Join(names, ", ")) + } + + room.Send(message.NewSystemMsg(systemMsg, msg.From())) + return nil + } + + target, ok := room.MemberById(id) + if !ok { + return fmt.Errorf("user %s not found.", id) + } + + err := msg.From().Ignore(target) + if err != nil { + return err + } + + room.Send(message.NewSystemMsg(fmt.Sprintf("%s is now being ignored.", target.Name()), msg.From())) + return nil + }, + }) + + c.Add(Command{ + Prefix: "/unignore", + PrefixHelp: "[USER]", + Help: "Stop ignoring USER.", + Handler: func(room *Room, msg message.CommandMsg) error { + id := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/unignore")) + if id == "" { + return errors.New("missing user id") + } + + err := msg.From().Unignore(id) + if err != nil { + return err + } + + room.Send(message.NewSystemMsg(fmt.Sprintf("%s is not ignored anymore.", id), msg.From())) + return nil + }, + }) } diff --git a/chat/message/user.go b/chat/message/user.go index d4d1fcb..bcda156 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -8,6 +8,8 @@ import ( "regexp" "sync" "time" + + "github.com/shazow/ssh-chat/common" ) const messageBuffer = 5 @@ -24,6 +26,7 @@ type User struct { joined time.Time msg chan Message done chan struct{} + Ignored *common.IdSet replyTo *User // Set when user gets a /msg, for replying. screen io.WriteCloser @@ -37,6 +40,7 @@ func NewUser(identity Identifier) *User { joined: time.Now(), msg: make(chan Message, messageBuffer), done: make(chan struct{}), + Ignored: common.NewIdSet(), } u.SetColorIdx(rand.Int()) @@ -114,6 +118,17 @@ func (u *User) ConsumeOne() Message { return <-u.msg } +// Check if there are pending messages, used for testing +func (u *User) HasMessages() bool { + select { + case msg := <-u.msg: + u.msg <- msg + return true + default: + return false + } +} + // SetHighlight sets the highlighting regular expression to match string. func (u *User) SetHighlight(s string) error { re, err := regexp.Compile(fmt.Sprintf(reHighlight, s)) @@ -161,6 +176,36 @@ func (u *User) Send(m Message) error { return nil } +func (u *User) Ignore(identified common.Identified) error { + if identified == nil { + return errors.New("user is nil.") + } + + if identified.Id() == u.Id() { + return errors.New("cannot ignore self.") + } + + if u.Ignored.In(identified) { + return errors.New("user already ignored.") + } + + u.Ignored.Add(identified) + return nil +} + +func (u *User) Unignore(id string) error { + if id == "" { + return errors.New("user is nil.") + } + + identified, err := u.Ignored.Get(id) + if err != nil { + return err + } + + return u.Ignored.Remove(identified) +} + // Container for per-user configurations. type UserConfig struct { Highlight *regexp.Regexp diff --git a/chat/room.go b/chat/room.go index 7e73da6..8e3ebba 100644 --- a/chat/room.go +++ b/chat/room.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/shazow/ssh-chat/chat/message" + "github.com/shazow/ssh-chat/common" ) const historyLen = 20 @@ -34,8 +35,8 @@ type Room struct { closed bool closeOnce sync.Once - Members *idSet - Ops *idSet + Members *common.IdSet + Ops *common.IdSet } // NewRoom creates a new room. @@ -47,8 +48,8 @@ func NewRoom() *Room { history: message.NewHistory(historyLen), commands: *defaultCommands, - Members: newIdSet(), - Ops: newIdSet(), + Members: common.NewIdSet(), + Ops: common.NewIdSet(), } } @@ -61,7 +62,7 @@ func (r *Room) SetCommands(commands Commands) { func (r *Room) Close() { r.closeOnce.Do(func() { r.closed = true - r.Members.Each(func(m identified) { + r.Members.Each(func(m common.Identified) { m.(*Member).Close() }) r.Members.Clear() @@ -95,8 +96,14 @@ func (r *Room) HandleMsg(m message.Message) { } r.history.Add(m) - r.Members.Each(func(u identified) { + r.Members.Each(func(u common.Identified) { user := u.(*Member).User + + if fromMsg != nil && user.Ignored.In(fromMsg.From()) { + // Skip because ignored + return + } + if skip && skipUser == user { // Skip return diff --git a/chat/room_test.go b/chat/room_test.go index 29a9294..078b80b 100644 --- a/chat/room_test.go +++ b/chat/room_test.go @@ -1,8 +1,11 @@ package chat import ( + "errors" + "fmt" "reflect" "testing" + "time" "github.com/shazow/ssh-chat/chat/message" ) @@ -40,6 +43,134 @@ func TestRoomServe(t *testing.T) { } } +type ScreenedUser struct { + user *message.User + screen *MockScreen +} + +func TestIgnore(t *testing.T) { + var buffer []byte + + ch := NewRoom() + go ch.Serve() + defer ch.Close() + + // Create 3 users, join the room and clear their screen buffers + users := make([]ScreenedUser, 3) + for i := 0; i < 3; i++ { + screen := &MockScreen{} + user := message.NewUserScreen(message.SimpleId(fmt.Sprintf("user%d", i)), screen) + users[i] = ScreenedUser{ + user: user, + screen: screen, + } + + _, err := ch.Join(user) + if err != nil { + t.Fatal(err) + } + } + + for _, u := range users { + for i := 0; i < 3; i++ { + u.user.HandleMsg(u.user.ConsumeOne()) + u.screen.Read(&buffer) + } + } + + // Use some handy variable names for distinguish between roles + ignorer := users[0] + ignored := users[1] + other := users[2] + + // test ignoring unexisting user + if err := sendCommand("/ignore test", ignorer, ch, &buffer); err != nil { + t.Fatal(err) + } + expectOutput(t, buffer, "-> Err: user test not found."+message.Newline) + + // test ignoring existing user + if err := sendCommand("/ignore "+ignored.user.Name(), ignorer, ch, &buffer); err != nil { + t.Fatal(err) + } + expectOutput(t, buffer, "-> "+ignored.user.Name()+" is now being ignored."+message.Newline) + + // ignoring the same user twice returns an error message and doesn't add the user twice + if err := sendCommand("/ignore "+ignored.user.Name(), ignorer, ch, &buffer); err != nil { + t.Fatal(err) + } + expectOutput(t, buffer, "-> Err: user already ignored."+message.Newline) + if ignoredList := ignorer.user.Ignored.ListPrefix(""); len(ignoredList) != 1 { + t.Fatalf("should have %d ignored users, has %d", 1, len(ignoredList)) + } + + // when a message is sent from the ignored user, it is delivered to non-ignoring users + ch.Send(message.NewPublicMsg("hello", ignored.user)) + other.user.HandleMsg(other.user.ConsumeOne()) + other.screen.Read(&buffer) + expectOutput(t, buffer, ignored.user.Name()+": hello"+message.Newline) + + // ensure ignorer doesn't have received any message + if ignorer.user.HasMessages() { + t.Fatal("should not have messages") + } + + // `/ignore` returns a list of ignored users + if err := sendCommand("/ignore", ignorer, ch, &buffer); err != nil { + t.Fatal(err) + } + expectOutput(t, buffer, "-> 1 ignored: "+ignored.user.Name()+message.Newline) + + // `/unignore [USER]` removes the user from ignored ones + if err := sendCommand("/unignore "+ignored.user.Name(), users[0], ch, &buffer); err != nil { + t.Fatal(err) + } + expectOutput(t, buffer, "-> "+ignored.user.Name()+" is not ignored anymore."+message.Newline) + + if err := sendCommand("/ignore", users[0], ch, &buffer); err != nil { + t.Fatal(err) + } + expectOutput(t, buffer, "-> 0 users ignored."+message.Newline) + + if ignoredList := ignorer.user.Ignored.ListPrefix(""); len(ignoredList) != 0 { + t.Fatalf("should have %d ignored users, has %d", 0, len(ignoredList)) + } + + // after unignoring a user, its messages can be received again + ch.Send(message.NewPublicMsg("hello again!", ignored.user)) + + // give some time for the channel to get the message + time.Sleep(100) + + // ensure ignorer has received the message + if !ignorer.user.HasMessages() { + t.Fatal("should have messages") + } + ignorer.user.HandleMsg(ignorer.user.ConsumeOne()) + ignorer.screen.Read(&buffer) + expectOutput(t, buffer, ignored.user.Name()+": hello again!"+message.Newline) +} + +func expectOutput(t *testing.T, buffer []byte, expected string) { + bytes := []byte(expected) + if !reflect.DeepEqual(buffer, bytes) { + t.Errorf("Got: %q; Expected: %q", buffer, expected) + } +} + +func sendCommand(cmd string, mock ScreenedUser, room *Room, buffer *[]byte) error { + msg, ok := message.NewPublicMsg(cmd, mock.user).ParseCommand() + if !ok { + return errors.New("cannot parse command message") + } + + room.Send(msg) + mock.user.HandleMsg(mock.user.ConsumeOne()) + mock.screen.Read(buffer) + + return nil +} + func TestRoomJoin(t *testing.T) { var expected, actual []byte diff --git a/chat/set_test.go b/chat/set_test.go index 6db2e9b..cf8135c 100644 --- a/chat/set_test.go +++ b/chat/set_test.go @@ -4,11 +4,12 @@ import ( "testing" "github.com/shazow/ssh-chat/chat/message" + "github.com/shazow/ssh-chat/common" ) func TestSet(t *testing.T) { var err error - s := newIdSet() + s := common.NewIdSet() u := message.NewUser(message.SimpleId("foo")) if s.In(u) { @@ -31,7 +32,7 @@ func TestSet(t *testing.T) { } err = s.Add(u2) - if err != ErrIdTaken { + if err != common.ErrIdTaken { t.Error(err) } diff --git a/chat/set.go b/common/set.go similarity index 67% rename from chat/set.go rename to common/set.go index 438fe97..7d81041 100644 --- a/chat/set.go +++ b/common/set.go @@ -1,4 +1,4 @@ -package chat +package common import ( "errors" @@ -10,45 +10,45 @@ import ( var ErrIdTaken = errors.New("id already taken") // The error returned when a requested item does not exist in the set. -var ErridentifiedMissing = errors.New("item does not exist") +var ErrIdentifiedMissing = errors.New("item does not exist") // Interface for an item storeable in the set -type identified interface { +type Identified interface { Id() string } // Set with string lookup. // TODO: Add trie for efficient prefix lookup? -type idSet struct { +type IdSet struct { sync.RWMutex - lookup map[string]identified + lookup map[string]Identified } // newIdSet creates a new set. -func newIdSet() *idSet { - return &idSet{ - lookup: map[string]identified{}, +func NewIdSet() *IdSet { + return &IdSet{ + lookup: map[string]Identified{}, } } // Clear removes all items and returns the number removed. -func (s *idSet) Clear() int { +func (s *IdSet) Clear() int { s.Lock() n := len(s.lookup) - s.lookup = map[string]identified{} + s.lookup = map[string]Identified{} s.Unlock() return n } // Len returns the size of the set right now. -func (s *idSet) Len() int { +func (s *IdSet) Len() int { s.RLock() defer s.RUnlock() return len(s.lookup) } // In checks if an item exists in this set. -func (s *idSet) In(item identified) bool { +func (s *IdSet) In(item Identified) bool { s.RLock() _, ok := s.lookup[item.Id()] s.RUnlock() @@ -56,20 +56,20 @@ func (s *idSet) In(item identified) bool { } // Get returns an item with the given Id. -func (s *idSet) Get(id string) (identified, error) { +func (s *IdSet) Get(id string) (Identified, error) { s.RLock() item, ok := s.lookup[id] s.RUnlock() if !ok { - return nil, ErridentifiedMissing + return nil, ErrIdentifiedMissing } return item, nil } // Add item to this set if it does not exist already. -func (s *idSet) Add(item identified) error { +func (s *IdSet) Add(item Identified) error { s.Lock() defer s.Unlock() @@ -83,21 +83,21 @@ func (s *idSet) Add(item identified) error { } // Remove item from this set. -func (s *idSet) Remove(item identified) error { +func (s *IdSet) Remove(item Identified) error { s.Lock() defer s.Unlock() id := item.Id() _, found := s.lookup[id] if !found { - return ErridentifiedMissing + return ErrIdentifiedMissing } delete(s.lookup, id) return nil } -// Replace item from old id with new identified. -// Used for moving the same identified to a new Id, such as a rename. -func (s *idSet) Replace(oldId string, item identified) error { +// Replace item from old id with new Identified. +// Used for moving the same Identified to a new Id, such as a rename. +func (s *IdSet) Replace(oldId string, item Identified) error { s.Lock() defer s.Unlock() @@ -110,11 +110,11 @@ func (s *idSet) Replace(oldId string, item identified) error { // Remove oldId _, found = s.lookup[oldId] if !found { - return ErridentifiedMissing + return ErrIdentifiedMissing } delete(s.lookup, oldId) - // Add new identified + // Add new Identified s.lookup[item.Id()] = item return nil @@ -122,7 +122,7 @@ func (s *idSet) Replace(oldId string, item identified) error { // Each loops over every item while holding a read lock and applies fn to each // element. -func (s *idSet) Each(fn func(item identified)) { +func (s *IdSet) Each(fn func(item Identified)) { s.RLock() for _, item := range s.lookup { fn(item) @@ -131,8 +131,8 @@ func (s *idSet) Each(fn func(item identified)) { } // ListPrefix returns a list of items with a prefix, case insensitive. -func (s *idSet) ListPrefix(prefix string) []identified { - r := []identified{} +func (s *IdSet) ListPrefix(prefix string) []Identified { + r := []Identified{} prefix = strings.ToLower(prefix) s.RLock()