diff --git a/channel.go b/channel.go index 181e57f..ac45c26 100644 --- a/channel.go +++ b/channel.go @@ -1,10 +1,10 @@ package mock import ( - "errors" "math/rand" "strconv" "strings" + "sync" "sync/atomic" "time" @@ -12,18 +12,35 @@ import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-mock/segments" "github.com/diamondburned/cchat/text" + "github.com/pkg/errors" ) -func init() {} +// FetchBacklog is the number of messages to fake-fetch. +const FetchBacklog = 35 + +// max number to add to before the next author, with rand.Intn(limit) + incr. +const sameAuthorLimit = 12 type Channel struct { id uint32 name string username text.Rich - done chan struct{} - send chan cchat.SendableMessage // ideally this should be another type - lastID uint32 + send chan cchat.SendableMessage // ideally this should be another type + edit chan Message // id + + messageMutex sync.Mutex + messageIDs map[string]int + messages []Message + + // used for unique ID generation of messages + incrID uint32 + // used for generating the same author multiple times before shuffling, goes + // up to about 12 or so. check sameAuthorLimit. + incrAuthor uint8 + + // + busyWg sync.WaitGroup } var ( @@ -32,54 +49,46 @@ var ( _ cchat.ServerMessageSender = (*Channel)(nil) _ cchat.ServerMessageSendCompleter = (*Channel)(nil) _ cchat.ServerNickname = (*Channel)(nil) + _ cchat.ServerMessageEditor = (*Channel)(nil) + _ cchat.ServerMessageActioner = (*Channel)(nil) ) func (ch *Channel) ID() string { return strconv.Itoa(int(ch.id)) } -func (ch *Channel) Name(labeler cchat.LabelContainer) error { +func (ch *Channel) Name(labeler cchat.LabelContainer) (func(), error) { labeler.SetLabel(text.Rich{Content: ch.name}) - return nil + return func() {}, nil } -func (ch *Channel) Nickname(labeler cchat.LabelContainer) error { +func (ch *Channel) Nickname(labeler cchat.LabelContainer) (func(), error) { labeler.SetLabel(ch.username) - return nil + return func() {}, nil } -func (ch *Channel) JoinServer(container cchat.MessagesContainer) error { - // Emulate IO. - emulateAustralianInternet() +func (ch *Channel) JoinServer(container cchat.MessagesContainer) (func(), error) { + // Is this a fresh channel? If yes, generate messages with some IO latency. + if ch.messageIDs == nil || len(ch.messages) == 0 { + // Emulate IO. + emulateAustralianInternet() - var lastAuthor text.Rich - var lastCounter int + // Initialize. + ch.messageIDs = map[string]int{} + ch.messages = make([]Message, 0, FetchBacklog) - var nextID = func() uint32 { - id := ch.lastID - ch.lastID++ - return id - } - var randomMsg = func() Message { - // Try and reuse the author multiple times. - if lastCounter++; lastCounter < randClamp(2, 5) { - return randomMessageWithAuthor(nextID(), lastAuthor) + // Allocate 2 channels that we won't clean up, because we're lazy. + ch.send = make(chan cchat.SendableMessage) + ch.edit = make(chan Message) + + // Generate the backlog. + for i := 0; i < FetchBacklog; i++ { + ch.addMessage(randomMessage(ch.nextID()), container) } - - msg := randomMessage(nextID()) - lastAuthor = msg.author - lastCounter = 0 - - return msg } - // Write the backlog. - for i := 0; i < 30; i++ { - container.CreateMessage(randomMsg()) - } - - ch.done = make(chan struct{}) - ch.send = make(chan cchat.SendableMessage) + // Initialize channels for use. + doneCh := make(chan struct{}) go func() { ticker := time.NewTicker(4 * time.Second) @@ -94,26 +103,141 @@ func (ch *Channel) JoinServer(container cchat.MessagesContainer) error { for { select { case msg := <-ch.send: - container.CreateMessage(echoMessage(msg, nextID(), ch.username)) + ch.addMessage(echoMessage(msg, ch.nextID(), ch.username), container) + + case msg := <-ch.edit: + container.UpdateMessage(msg) + case <-ticker.C: - container.CreateMessage(randomMsg()) + ch.addMessage(ch.randomMsg(), container) + case <-editTick.C: - container.UpdateMessage(newRandomMessage(ch.lastID, lastAuthor)) + var old = ch.randomOldMsg() + ch.updateMessage(newRandomMessage(old.id, old.author), container) + case <-deleteTick.C: - container.DeleteMessage(newEmptyMessage(ch.lastID, lastAuthor)) - case <-ch.done: + var old = ch.randomOldMsg() + ch.deleteMessage(MessageHeader{old.id, time.Now()}, container) + + case <-doneCh: return } } }() + return func() { doneCh <- struct{}{} }, nil +} + +func (ch *Channel) RawMessageContent(id string) (string, error) { + ch.messageMutex.Lock() + defer ch.messageMutex.Unlock() + + ix, ok := ch.messageIDs[id] + if !ok { + return "", errors.New("Message not found") + } + + return ch.messages[ix].content, nil +} + +func (ch *Channel) EditMessage(id, content string) error { + emulateAustralianInternet() + + ch.messageMutex.Lock() + defer ch.messageMutex.Unlock() + + ix, ok := ch.messageIDs[id] + if !ok { + return errors.New("ID not found") + } + + msg := ch.messages[ix] + msg.content = content + + ch.messages[ix] = msg + ch.edit <- msg + return nil } -func (ch *Channel) LeaveServer() error { - ch.done <- struct{}{} - ch.send = nil - return nil +func (ch *Channel) addMessage(msg Message, container cchat.MessagesContainer) { + ch.messageMutex.Lock() + defer ch.messageMutex.Unlock() + + // Clean up the backlog. + if len(ch.messages) > FetchBacklog*2 { + + } + ch.messages = append(ch.messages, msg) + container.CreateMessage(msg) +} + +func (ch *Channel) updateMessage(msg Message, container cchat.MessagesContainer) { + ch.messageMutex.Lock() + defer ch.messageMutex.Unlock() + + ix, ok := ch.messageIDs[msg.ID()] + if !ok { + // Unknown message. + return + } + + ch.messages[ix] = msg + container.UpdateMessage(msg) +} + +func (ch *Channel) deleteMessage(msg MessageHeader, container cchat.MessagesContainer) { + ch.messageMutex.Lock() + defer ch.messageMutex.Unlock() + + ix, ok := ch.messageIDs[msg.ID()] + if !ok { + return + } + + delete(ch.messageIDs, msg.ID()) + ch.messages = append(ch.messages[:ix], ch.messages[ix+1:]...) + container.DeleteMessage(msg) +} + +// randomMsgID returns a random recent message ID. +func (ch *Channel) randomOldMsg() Message { + ch.messageMutex.Lock() + defer ch.messageMutex.Unlock() + + // Pick a random number, clamped to 10 and len channel. + n := rand.Intn(len(ch.messages)) % 10 + return ch.messages[n] +} + +// randomMsg uses top of the state algorithms to return fair and balanced +// messages suitable for rigorous testing. +func (ch *Channel) randomMsg() (msg Message) { + ch.messageMutex.Lock() + defer ch.messageMutex.Unlock() + + // If we don't have any messages, then skip. + if len(ch.messages) == 0 { + return randomMessage(ch.nextID()) + } + + // Add a random number into incrAuthor and determine if that should be + // enough to generate a new author. + ch.incrAuthor += uint8(rand.Intn(5)) // 2~4 appearances + + // Should we generate a new author for the new message? + if ch.incrAuthor > sameAuthorLimit { + msg = randomMessage(ch.nextID()) + } else { + last := ch.messages[len(ch.messages)-1] + msg = randomMessageWithAuthor(ch.nextID(), last.author) + } + + return +} + +func (ch *Channel) nextID() (id uint32) { + return atomic.AddUint32(&ch.incrID, 1) } func (ch *Channel) SendMessage(msg cchat.SendableMessage) error { @@ -131,6 +255,47 @@ func (ch *Channel) SendMessage(msg cchat.SendableMessage) error { return nil } +const ( + DeleteAction = "Delete" + NoopAction = "No-op" + BestTrapAction = "Print best trap" +) + +func (ch *Channel) MessageActions() []string { + return []string{ + DeleteAction, + NoopAction, + BestTrapAction, + } +} + +// DoMessageAction will be blocked by IO. As goes for every other method that +// takes a container: the frontend should call this in a goroutine. +func (ch *Channel) DoMessageAction(c cchat.MessagesContainer, action, messageID string) error { + switch action { + case DeleteAction: + i, err := strconv.Atoi(messageID) + if err != nil { + return errors.Wrap(err, "Invalid ID") + } + + // Emulate IO. + emulateAustralianInternet() + ch.deleteMessage(MessageHeader{uint32(i), time.Now()}, c) + + case NoopAction: + // do nothing. + + case BestTrapAction: + return ch.EditMessage(messageID, "Astolfo.") + + default: + return errors.New("Unknown action.") + } + + return nil +} + func (ch *Channel) CompleteMessage(words []string, i int) []string { switch { case strings.HasPrefix("complete", words[i]): diff --git a/go.mod b/go.mod index b381d67..9401682 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,6 @@ go 1.14 require ( github.com/Pallinder/go-randomdata v1.2.0 - github.com/diamondburned/cchat v0.0.15 + github.com/diamondburned/cchat v0.0.19 + github.com/pkg/errors v0.9.1 ) diff --git a/go.sum b/go.sum index 09fff14..7933d67 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,15 @@ github.com/diamondburned/cchat v0.0.14 h1:QpYRndVRBgg0DZHNrjbf+FNZ7dJlAsP7PlR+JA github.com/diamondburned/cchat v0.0.14/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.15 h1:1o4OX8zw/CdSv3Idaylz7vjHVOZKEi/xkg8BpEvtsHY= github.com/diamondburned/cchat v0.0.15/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.0.16 h1:N+KxOhFJ+rpqs7mBYr40UWUyFeDA4js2W13KL8nuOOs= +github.com/diamondburned/cchat v0.0.16/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.0.17 h1:n3x5p7hc4G+6osmjY1tsjbCd1+9YZ+vX/A6fcIm0HtE= +github.com/diamondburned/cchat v0.0.17/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.0.18 h1:1nTPcYEumpLCangEV/oblNkZrZG9dQ432Ov1qvlzSNw= +github.com/diamondburned/cchat v0.0.18/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.0.19 h1:XPZDqOR8P1tzTWVkYzRb6yZ1H6yU8tSUReGjklIqarw= +github.com/diamondburned/cchat v0.0.19/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/message.go b/message.go index a8ec1d7..5029b18 100644 --- a/message.go +++ b/message.go @@ -13,9 +13,23 @@ import ( const avatarURL = "https://gist.github.com/diamondburned/945744c2b5ce0aa0581c9267a4e5cf24/raw/598069da673093aaca4cd4aa0ede1a0e324e9a3a/astolfo_selfie.png" +type MessageHeader struct { + id uint32 + time time.Time +} + +var _ cchat.MessageHeader = (*Message)(nil) + +func (m MessageHeader) ID() string { + return strconv.Itoa(int(m.id)) +} + +func (m MessageHeader) Time() time.Time { + return m.time +} + type Message struct { - id uint32 - time time.Time + MessageHeader author text.Rich content string nonce string @@ -31,26 +45,24 @@ var ( func newEmptyMessage(id uint32, author text.Rich) Message { return Message{ - id: id, - author: author, + MessageHeader: MessageHeader{id: id}, + author: author, } } func newRandomMessage(id uint32, author text.Rich) Message { return Message{ - id: id, - time: time.Now(), - author: author, - content: randomdata.Paragraph(), + MessageHeader: MessageHeader{id: id, time: time.Now()}, + author: author, + content: randomdata.Paragraph(), } } func echoMessage(sendable cchat.SendableMessage, id uint32, author text.Rich) Message { var echo = Message{ - id: id, - time: time.Now(), - author: author, - content: sendable.Content(), + MessageHeader: MessageHeader{id: id, time: time.Now()}, + author: author, + content: sendable.Content(), } if noncer, ok := sendable.(cchat.MessageNonce); ok { echo.nonce = noncer.Nonce() @@ -68,24 +80,13 @@ func randomMessage(id uint32) Message { } func randomMessageWithAuthor(id uint32, author text.Rich) Message { - var now = time.Now() - return Message{ - id: id, - time: now, - author: author, - content: randomdata.Paragraph(), + MessageHeader: MessageHeader{id: id, time: time.Now()}, + author: author, + content: randomdata.Paragraph(), } } -func (m Message) ID() string { - return strconv.Itoa(int(m.id)) -} - -func (m Message) Time() time.Time { - return m.time -} - func (m Message) Author() cchat.MessageAuthor { return Author{name: m.author} } diff --git a/server.go b/server.go index ab31903..fe8304d 100644 --- a/server.go +++ b/server.go @@ -26,9 +26,9 @@ func (sv *Server) ID() string { return strconv.Itoa(int(sv.id)) } -func (sv *Server) Name(labeler cchat.LabelContainer) error { +func (sv *Server) Name(labeler cchat.LabelContainer) (func(), error) { labeler.SetLabel(text.Rich{Content: sv.name}) - return nil + return func() {}, nil } func (sv *Server) Servers(container cchat.ServersContainer) error { diff --git a/service.go b/service.go index d1e17f3..f3d042b 100644 --- a/service.go +++ b/service.go @@ -139,9 +139,9 @@ func (s *Session) ID() string { return s.username } -func (s *Session) Name(labeler cchat.LabelContainer) error { +func (s *Session) Name(labeler cchat.LabelContainer) (func(), error) { labeler.SetLabel(text.Rich{Content: s.username}) - return nil + return func() {}, nil } func (s *Session) Servers(container cchat.ServersContainer) error { @@ -149,9 +149,9 @@ func (s *Session) Servers(container cchat.ServersContainer) error { return nil } -func (s *Session) Icon(iconer cchat.IconContainer) error { +func (s *Session) Icon(iconer cchat.IconContainer) (func(), error) { iconer.SetIcon(avatarURL) - return nil + return func() {}, nil } func (s *Session) Save() (map[string]string, error) {