diff --git a/chat/command.go b/chat/command.go index 61b7073..ede108e 100644 --- a/chat/command.go +++ b/chat/command.go @@ -240,4 +240,64 @@ 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 == "" { + ignored := msg.From().IgnoredNames() + + var systemMsg string + if len(ignored) == 0 { + systemMsg = "0 users ignored." + } else { + systemMsg = fmt.Sprintf("%d ignored: %s", len(ignored), strings.Join(ignored, ", ")) + } + + room.Send(message.NewSystemMsg(systemMsg, msg.From())) + return nil + } + + target, ok := room.MemberById(id) + if !ok { + return fmt.Errorf("user %s not found.", id) + } + + // Don't ignore yourself + if target.Id() == msg.From().Id() { + return errors.New("cannot ignore self.") + } + + err := msg.From().Ignore(target.Id()) + 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..d66c665 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -24,10 +24,13 @@ type User struct { joined time.Time msg chan Message done chan struct{} + ignored []string replyTo *User // Set when user gets a /msg, for replying. screen io.WriteCloser closeOnce sync.Once + + mu sync.RWMutex } func NewUser(identity Identifier) *User { @@ -114,6 +117,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 +175,65 @@ func (u *User) Send(m Message) error { return nil } +func (u *User) Ignore(id string) error { + if id == "" { + return errors.New("user is nil.") + } + + u.mu.Lock() + defer u.mu.Unlock() + + for _, userId := range u.ignored { + if userId == id { + return errors.New("user already ignored.") + } + } + + u.ignored = append(u.ignored, id) + return nil +} + +func (u *User) Unignore(id string) error { + if id == "" { + return errors.New("user is nil.") + } + + u.mu.Lock() + defer u.mu.Unlock() + + for i, userId := range u.ignored { + if userId == id { + u.ignored = append(u.ignored[:i], u.ignored[i+1:]...) + return nil + } + } + + return errors.New("user not found or not currently ignored.") +} + +func (u *User) IgnoredNames() []string { + u.mu.RLock() + defer u.mu.RUnlock() + + names := make([]string, len(u.ignored)) + for i := range u.ignored { + names[i] = u.ignored[i] + } + return names +} + +func (u *User) IsIgnoring(id string) bool { + u.mu.RLock() + defer u.mu.RUnlock() + + for _, userId := range u.ignored { + if userId == id { + return true + } + } + return false +} + // Container for per-user configurations. type UserConfig struct { Highlight *regexp.Regexp diff --git a/chat/room.go b/chat/room.go index 7e73da6..08b27df 100644 --- a/chat/room.go +++ b/chat/room.go @@ -97,6 +97,12 @@ func (r *Room) HandleMsg(m message.Message) { r.history.Add(m) r.Members.Each(func(u identified) { user := u.(*Member).User + + if fromMsg != nil && user.IsIgnoring(fromMsg.From().Id()) { + // Skip because ignored + return + } + if skip && skipUser == user { // Skip return diff --git a/chat/room_test.go b/chat/room_test.go index 29a9294..b6c029b 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 names := ignorer.user.IgnoredNames(); len(names) != 1 { + t.Fatalf("should have %d ignored users, has %d", 1, len(names)) + } + + // 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 names := ignorer.user.IgnoredNames(); len(names) != 0 { + t.Fatalf("should have %d ignored users, has %d", 0, len(names)) + } + + // 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(50) + + // 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