commands: /ignore, /unignore

#154
* Add `/ignore`/`/unignore` commands
* Move set to common package, use set for ignores
* `chat/set.go` is now `common/set.go`
* use `*IdSet` as type for ignored list for users
* remove `IsIgnoring` and `IgnoredNames` user functions, so to use directly `IdSet` methods
* refactor code accordingly
This commit is contained in:
Federico Ruggi 2016-08-01 17:19:12 +02:00 committed by Andrey Petrov
parent 2abe368022
commit 58e1cb60bd
6 changed files with 276 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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