From 4c8d73b9323e6b4fad09ebb7bd48cb34f2b5edc2 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 20 Dec 2014 16:45:10 -0800 Subject: [PATCH] Framework for server-agnostic chat. --- chat/channel.go | 44 ++++++++++++++ chat/history.go | 58 ++++++++++++++++++ chat/history_test.go | 65 ++++++++++++++++++++ chat/message.go | 55 +++++++++++++++++ chat/set.go | 106 ++++++++++++++++++++++++++++++++ chat/set_test.go | 38 ++++++++++++ chat/theme.go | 141 +++++++++++++++++++++++++++++++++++++++++++ chat/user.go | 67 ++++++++++++++++++++ 8 files changed, 574 insertions(+) create mode 100644 chat/channel.go create mode 100644 chat/history.go create mode 100644 chat/history_test.go create mode 100644 chat/message.go create mode 100644 chat/set.go create mode 100644 chat/set_test.go create mode 100644 chat/theme.go create mode 100644 chat/user.go diff --git a/chat/channel.go b/chat/channel.go new file mode 100644 index 0000000..b478c82 --- /dev/null +++ b/chat/channel.go @@ -0,0 +1,44 @@ +package chat + +const historyLen = 20 + +// Channel definition, also a Set of User Items +type Channel struct { + id string + topic string + history *History + users *Set + out chan<- Message +} + +func NewChannel(id string, out chan<- Message) *Channel { + ch := Channel{ + id: id, + out: out, + history: NewHistory(historyLen), + users: NewSet(), + } + return &ch +} + +func (ch *Channel) Send(m Message) { + ch.out <- m +} + +func (ch *Channel) Join(u *User) error { + err := ch.users.Add(u) + return err +} + +func (ch *Channel) Leave(u *User) error { + err := ch.users.Remove(u) + return err +} + +func (ch *Channel) Topic() string { + return ch.topic +} + +func (ch *Channel) SetTopic(s string) { + ch.topic = s +} diff --git a/chat/history.go b/chat/history.go new file mode 100644 index 0000000..b0f78a6 --- /dev/null +++ b/chat/history.go @@ -0,0 +1,58 @@ +package chat + +import "sync" + +// History contains the history entries +type History struct { + entries []interface{} + head int + size int + sync.RWMutex +} + +// NewHistory constructs a new history of the given size +func NewHistory(size int) *History { + return &History{ + entries: make([]interface{}, size), + } +} + +// Add adds the given entry to the entries in the history +func (h *History) Add(entry interface{}) { + h.Lock() + defer h.Unlock() + + max := cap(h.entries) + h.head = (h.head + 1) % max + h.entries[h.head] = entry + if h.size < max { + h.size++ + } +} + +// Len returns the number of entries in the history +func (h *History) Len() int { + return h.size +} + +// Get recent entries +func (h *History) Get(num int) []interface{} { + h.RLock() + defer h.RUnlock() + + max := cap(h.entries) + if num > h.size { + num = h.size + } + + r := make([]interface{}, num) + for i := 0; i < num; i++ { + idx := (h.head - i) % max + if idx < 0 { + idx += max + } + r[num-i-1] = h.entries[idx] + } + + return r +} diff --git a/chat/history_test.go b/chat/history_test.go new file mode 100644 index 0000000..fb166c4 --- /dev/null +++ b/chat/history_test.go @@ -0,0 +1,65 @@ +package chat + +import "testing" + +func equal(a []interface{}, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := 0; i < len(a); i++ { + if a[0] != b[0] { + return false + } + } + + return true +} + +func TestHistory(t *testing.T) { + var r []interface{} + var expected []string + var size int + + h := NewHistory(5) + + r = h.Get(10) + expected = []string{} + if !equal(r, expected) { + t.Errorf("Got: %v, Expected: %v", r, expected) + } + + h.Add("1") + + if size = h.Len(); size != 1 { + t.Errorf("Wrong size: %v", size) + } + + r = h.Get(1) + expected = []string{"1"} + if !equal(r, expected) { + t.Errorf("Got: %v, Expected: %v", r, expected) + } + + h.Add("2") + h.Add("3") + h.Add("4") + h.Add("5") + h.Add("6") + + if size = h.Len(); size != 5 { + t.Errorf("Wrong size: %v", size) + } + + r = h.Get(2) + expected = []string{"5", "6"} + if !equal(r, expected) { + t.Errorf("Got: %v, Expected: %v", r, expected) + } + + r = h.Get(10) + expected = []string{"2", "3", "4", "5", "6"} + if !equal(r, expected) { + t.Errorf("Got: %v, Expected: %v", r, expected) + } +} diff --git a/chat/message.go b/chat/message.go new file mode 100644 index 0000000..6050e83 --- /dev/null +++ b/chat/message.go @@ -0,0 +1,55 @@ +package chat + +import ( + "fmt" + "time" +) + +// Container for messages sent to chat +type Message struct { + Body string + from *User // Not set for Sys messages + to *User // Only set for PMs + timestamp time.Time + themeCache *map[*Theme]string +} + +func NewMessage(from *User, body string) *Message { + m := Message{ + Body: body, + from: from, + timestamp: time.Now(), + } + return &m +} + +// Set message recipient +func (m *Message) To(u *User) *Message { + m.to = u + return m +} + +// Set message sender +func (m *Message) From(u *User) *Message { + m.from = u + return m +} + +// Render message based on the given theme +func (m *Message) Render(*Theme) string { + // TODO: Render based on theme. + // TODO: Cache based on theme + var msg string + if m.to != nil { + msg = fmt.Sprintf("[PM from %s] %s", m.to, m.Body) + } else if m.from != nil { + msg = fmt.Sprintf("%s: %s", m.from, m.Body) + } else { + msg = fmt.Sprintf(" * %s", m.Body) + } + return msg +} + +func (m *Message) String() string { + return m.Render(nil) +} diff --git a/chat/set.go b/chat/set.go new file mode 100644 index 0000000..d4442d7 --- /dev/null +++ b/chat/set.go @@ -0,0 +1,106 @@ +package chat + +import ( + "errors" + "strings" + "sync" +) + +var ErrIdTaken error = errors.New("id already taken") +var ErrItemMissing error = errors.New("item does not exist") + +// Unique identifier for an item +type Id string + +// A prefix for a unique identifier +type IdPrefix Id + +// An interface for items to store-able in the set +type Item interface { + Id() Id +} + +// Set with string lookup +// TODO: Add trie for efficient prefix lookup? +type Set struct { + lookup map[Id]Item + sync.RWMutex +} + +// Create a new set +func NewSet() *Set { + return &Set{ + lookup: map[Id]Item{}, + } +} + +// Size of the set right now +func (s *Set) Len() int { + return len(s.lookup) +} + +// Check if user belongs in this set +func (s *Set) In(item Item) bool { + s.RLock() + _, ok := s.lookup[item.Id()] + s.RUnlock() + return ok +} + +// Get user by name +func (s *Set) Get(id Id) (Item, error) { + s.RLock() + item, ok := s.lookup[id] + s.RUnlock() + + if !ok { + return nil, ErrItemMissing + } + + return item, nil +} + +// Add user to set if user does not exist already +func (s *Set) Add(item Item) error { + s.Lock() + defer s.Unlock() + + _, found := s.lookup[item.Id()] + if found { + return ErrIdTaken + } + + s.lookup[item.Id()] = item + return nil +} + +// Remove user from set +func (s *Set) Remove(item Item) error { + s.Lock() + defer s.Unlock() + id := item.Id() + _, found := s.lookup[id] + if found { + return ErrItemMissing + } + delete(s.lookup, id) + return nil +} + +// List users by prefix, case insensitive +func (s *Set) ListPrefix(prefix string) []Item { + r := []Item{} + prefix = strings.ToLower(prefix) + + s.RLock() + defer s.RUnlock() + + for id, item := range s.lookup { + if !strings.HasPrefix(string(id), prefix) { + continue + } + r = append(r, item) + } + + return r +} diff --git a/chat/set_test.go b/chat/set_test.go new file mode 100644 index 0000000..60038c1 --- /dev/null +++ b/chat/set_test.go @@ -0,0 +1,38 @@ +package chat + +import "testing" + +func TestSet(t *testing.T) { + var err error + s := NewSet() + u := NewUser("foo") + + if s.In(u) { + t.Errorf("Set should be empty.") + } + + err = s.Add(u) + if err != nil { + t.Error(err) + } + + if !s.In(u) { + t.Errorf("Set should contain user.") + } + + u2 := NewUser("bar") + err = s.Add(u2) + if err != nil { + t.Error(err) + } + + err = s.Add(u2) + if err != ErrIdTaken { + t.Error(err) + } + + size := s.Len() + if size != 2 { + t.Errorf("Set wrong size: %d (expected %d)", size, 2) + } +} diff --git a/chat/theme.go b/chat/theme.go new file mode 100644 index 0000000..d187256 --- /dev/null +++ b/chat/theme.go @@ -0,0 +1,141 @@ +package chat + +import "fmt" + +const ( + // Reset resets the color + Reset = "\033[0m" + + // Bold makes the following text bold + Bold = "\033[1m" + + // Dim dims the following text + Dim = "\033[2m" + + // Italic makes the following text italic + Italic = "\033[3m" + + // Underline underlines the following text + Underline = "\033[4m" + + // Blink blinks the following text + Blink = "\033[5m" + + // Invert inverts the following text + Invert = "\033[7m" +) + +// Interface for Colors +type Color interface { + String() string + Format(string) string +} + +// 256 color type, for terminals who support it +type Color256 int8 + +// String version of this color +func (c Color256) String() string { + return fmt.Sprintf("38;05;%d", c) +} + +// Return formatted string with this color +func (c Color256) Format(s string) string { + return "\033[" + c.String() + "m" + s + Reset +} + +// No color, used for mono theme +type Color0 struct{} + +// No-op for Color0 +func (c Color0) String() string { + return "" +} + +// No-op for Color0 +func (c Color0) Format(s string) string { + return s +} + +// Container for a collection of colors +type Palette struct { + colors []Color + size int +} + +// Get a color by index, overflows are looped around. +func (p Palette) Get(i int) Color { + return p.colors[i%p.size] +} + +// Collection of settings for chat +type Theme struct { + id string + sys Color + pm Color + names *Palette +} + +// Colorize name string given some index +func (t Theme) ColorName(s string, i int) string { + if t.names == nil { + return s + } + + return t.names.Get(i).Format(s) +} + +// Colorize the PM string +func (t Theme) ColorPM(s string) string { + if t.pm == nil { + return s + } + + return t.pm.Format(s) +} + +// Colorize the Sys message +func (t Theme) ColorSys(s string) string { + if t.sys == nil { + return s + } + + return t.sys.Format(s) +} + +// List of initialzied themes +var Themes []Theme + +// Default theme to use +var DefaultTheme *Theme + +func readableColors256() *Palette { + p := Palette{ + colors: make([]Color, 246), + size: 246, + } + for i := 0; i < 256; i++ { + if (16 <= i && i <= 18) || (232 <= i && i <= 237) { + // Remove the ones near black, this is kinda sadpanda. + continue + } + p.colors = append(p.colors, Color256(i)) + } + return &p +} + +func init() { + palette := readableColors256() + + Themes = []Theme{ + Theme{ + id: "colors", + names: palette, + }, + Theme{ + id: "mono", + }, + } + + DefaultTheme = &Themes[0] +} diff --git a/chat/user.go b/chat/user.go new file mode 100644 index 0000000..97acb8b --- /dev/null +++ b/chat/user.go @@ -0,0 +1,67 @@ +package chat + +import ( + "math/rand" + "time" +) + +// User definition, implemented set Item interface +type User struct { + name string + op bool + colorIdx int + joined time.Time + Config UserConfig +} + +func NewUser(name string) *User { + u := User{Config: *DefaultUserConfig} + u.SetName(name) + return &u +} + +// Return unique identifier for user +func (u *User) Id() Id { + return Id(u.name) +} + +// Return user's name +func (u *User) Name() string { + return u.name +} + +// Return set user's name +func (u *User) SetName(name string) { + u.name = name + u.colorIdx = rand.Int() +} + +// Return whether user is an admin +func (u *User) Op() bool { + return u.op +} + +// Set whether user is an admin +func (u *User) SetOp(op bool) { + u.op = op +} + +// Container for per-user configurations. +type UserConfig struct { + Highlight bool + Bell bool + Theme *Theme +} + +// Default user configuration to use +var DefaultUserConfig *UserConfig + +func init() { + DefaultUserConfig = &UserConfig{ + Highlight: true, + Bell: false, + Theme: DefaultTheme, + } + + // TODO: Seed random? +}