Framework for server-agnostic chat.

This commit is contained in:
Andrey Petrov 2014-12-20 16:45:10 -08:00
parent 4e9bd419b0
commit 4c8d73b932
8 changed files with 574 additions and 0 deletions

44
chat/channel.go Normal file
View File

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

58
chat/history.go Normal file
View File

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

65
chat/history_test.go Normal file
View File

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

55
chat/message.go Normal file
View File

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

106
chat/set.go Normal file
View File

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

38
chat/set_test.go Normal file
View File

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

141
chat/theme.go Normal file
View File

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

67
chat/user.go Normal file
View File

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