diff --git a/channel.go b/channel.go deleted file mode 100644 index 569e9fc..0000000 --- a/channel.go +++ /dev/null @@ -1,515 +0,0 @@ -package mock - -import ( - "context" - "math/rand" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/Pallinder/go-randomdata" - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-mock/segments" - "github.com/diamondburned/cchat/text" - "github.com/pkg/errors" -) - -// FetchBacklog is the number of messages to fake-fetch. -const FetchBacklog = 35 -const maxBacklog = FetchBacklog * 2 - -// max number to add to before the next author, with rand.Intn(limit) + incr. -const sameAuthorLimit = 6 - -type Channel struct { - id uint32 - name string - username text.Rich - - send chan cchat.SendableMessage // ideally this should be another type - edit chan Message // id - del chan MessageHeader - typ chan Author - - messageMutex sync.Mutex - messages map[uint32]Message - messageids []uint32 // indices - - // 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 -} - -var ( - _ cchat.Server = (*Channel)(nil) - _ cchat.ServerMessage = (*Channel)(nil) - _ cchat.ServerMessageSender = (*Channel)(nil) - _ cchat.ServerMessageSendCompleter = (*Channel)(nil) - _ cchat.ServerNickname = (*Channel)(nil) - _ cchat.ServerMessageEditor = (*Channel)(nil) - _ cchat.ServerMessageActioner = (*Channel)(nil) - _ cchat.ServerMessageTypingIndicator = (*Channel)(nil) -) - -func (ch *Channel) ID() string { - return strconv.Itoa(int(ch.id)) -} - -func (ch *Channel) Name() text.Rich { - return text.Rich{Content: ch.name} -} - -// Nickname sets the labeler to the nickname. It simulates heavy IO. This -// function stops as cancel is called in JoinServer, as Nickname is specially -// for that. -// -// The given context is cancelled. -func (ch *Channel) Nickname(ctx context.Context, labeler cchat.LabelContainer) (func(), error) { - // Simulate IO with cancellation. Ignore the error if it's a simulated time - // out, else return. - if err := simulateAustralianInternetCtx(ctx); err != nil && err != ErrTimedOut { - return nil, err - } - - labeler.SetLabel(ch.username) - return func() {}, nil -} - -func (ch *Channel) JoinServer(ctx context.Context, ct cchat.MessagesContainer) (func(), error) { - // Is this a fresh channel? If yes, generate messages with some IO latency. - if len(ch.messageids) == 0 || ch.messages == nil { - // Simulate IO and error. - if err := simulateAustralianInternetCtx(ctx); err != nil { - return nil, err - } - - // Initialize. - ch.messages = make(map[uint32]Message, FetchBacklog) - ch.messageids = make([]uint32, 0, FetchBacklog) - - // Allocate 3 channels that we won't clean up, because we're lazy. - ch.send = make(chan cchat.SendableMessage) - ch.edit = make(chan Message) - ch.del = make(chan MessageHeader) - ch.typ = make(chan Author) - - // Generate the backlog. - for i := 0; i < FetchBacklog; i++ { - ch.addMessage(ch.randomMsg(), ct) - } - } else { - // Else, flush the old backlog over. - for i := range ch.messages { - ct.CreateMessage(ch.messages[i]) - } - } - - // Initialize context for cancellation. The context passed in is used only - // for initialization, so we'll use our own context for the loop. - ctx, stop := context.WithCancel(context.Background()) - - go func() { - ticker := time.NewTicker(4 * time.Second) - defer ticker.Stop() - - editTick := time.NewTicker(10 * time.Second) - defer editTick.Stop() - - // deleteTick := time.NewTicker(15 * time.Second) - // defer deleteTick.Stop() - - for { - select { - case msg := <-ch.send: - ch.addMessage(echoMessage(msg, ch.nextID(), newAuthor(ch.username)), ct) - - case msg := <-ch.edit: - ct.UpdateMessage(msg) - - case msh := <-ch.del: - ch.deleteMessage(msh, ct) - - case <-ticker.C: - ch.addMessage(ch.randomMsg(), ct) - - case <-editTick.C: - var old = ch.randomOldMsg() - ch.updateMessage(newRandomMessage(old.id, old.author), ct) - - // case <-deleteTick.C: - // var old = ch.randomOldMsg() - // ch.deleteMessage(MessageHeader{old.id, time.Now()}, container) - - case <-ctx.Done(): - return - } - } - }() - - return stop, nil -} - -// MessageEditable returns true if the message belongs to the author. -func (ch *Channel) MessageEditable(id string) bool { - i, err := parseID(id) - if err != nil { - return false - } - - ch.messageMutex.Lock() - defer ch.messageMutex.Unlock() - - m, ok := ch.messages[i] - if ok { - // Editable if same author. - return m.author.name.Content == ch.username.Content - } - - return false -} - -func (ch *Channel) RawMessageContent(id string) (string, error) { - i, err := parseID(id) - if err != nil { - return "", err - } - - ch.messageMutex.Lock() - defer ch.messageMutex.Unlock() - - m, ok := ch.messages[i] - if ok { - return m.content, nil - } - - return "", errors.New("Message not found") -} - -func (ch *Channel) EditMessage(id, content string) error { - i, err := parseID(id) - if err != nil { - return err - } - - simulateAustralianInternet() - - ch.messageMutex.Lock() - defer ch.messageMutex.Unlock() - - m, ok := ch.messages[i] - if ok { - m.content = content - ch.messages[i] = m - ch.edit <- m - - return nil - } - - return errors.New("Message not found.") -} - -func (ch *Channel) addMessage(msg Message, container cchat.MessagesContainer) { - ch.messageMutex.Lock() - - // Clean up the backlog. - if clean := len(ch.messages) - maxBacklog; clean > 0 { - // Remove them from the map. - for _, id := range ch.messageids[:clean] { - delete(ch.messages, id) - } - - // Cut the message IDs away by shifting the slice. - ch.messageids = append(ch.messageids[:0], ch.messageids[clean:]...) - } - - ch.messages[msg.id] = msg - ch.messageids = append(ch.messageids, msg.id) - - ch.messageMutex.Unlock() - - container.CreateMessage(msg) -} - -func (ch *Channel) updateMessage(msg Message, container cchat.MessagesContainer) { - ch.messageMutex.Lock() - - _, ok := ch.messages[msg.id] - if ok { - ch.messages[msg.id] = msg - } - - ch.messageMutex.Unlock() - - if ok { - container.UpdateMessage(msg) - } -} - -func (ch *Channel) deleteMessage(msg MessageHeader, container cchat.MessagesContainer) { - ch.messageMutex.Lock() - - // Delete from the map. - delete(ch.messages, msg.id) - - // Delete from the ordered slice. - var ok bool - for i, id := range ch.messageids { - if id == msg.id { - ch.messageids = append(ch.messageids[:i], ch.messageids[i+1:]...) - ok = true - break - } - } - - ch.messageMutex.Unlock() - - if ok { - 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 index from last, clamped to 10 and len channel. - n := len(ch.messageids) - 1 - rand.Intn(len(ch.messageids))%10 - return ch.messages[ch.messageids[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(sameAuthorLimit)) // 1~6 appearances - - var lastID = ch.messageids[len(ch.messageids)-1] - var lastAu = ch.messages[lastID].author - - // If the last author is not the current user, then we can use it. - // Should we generate a new author for the new message? No if we're not over - // the limits. - if lastAu.name.Content != ch.username.Content && ch.incrAuthor < sameAuthorLimit { - msg = randomMessageWithAuthor(ch.nextID(), lastAu) - } else { - msg = randomMessage(ch.nextID()) - ch.incrAuthor = 0 // reset - } - - return -} - -func (ch *Channel) nextID() (id uint32) { - return atomic.AddUint32(&ch.incrID, 1) -} - -func (ch *Channel) SendMessage(msg cchat.SendableMessage) error { - if err := simulateAustralianInternet(); err != nil { - return errors.Wrap(err, "Failed to send message") - } - - go func() { - // Make no guarantee that a message may arrive immediately when the - // function exits. - <-time.After(time.Second) - ch.send <- msg - }() - - return nil -} - -const ( - DeleteAction = "Delete" - NoopAction = "No-op" - BestTrapAction = "Who's the best trap?" - TriggerTypingAction = "Trigger Typing" -) - -func (ch *Channel) MessageActions(id string) []string { - return []string{ - DeleteAction, - NoopAction, - BestTrapAction, - TriggerTypingAction, - } -} - -// 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(action, messageID string) error { - switch action { - case DeleteAction, TriggerTypingAction: - i, err := strconv.Atoi(messageID) - if err != nil { - return errors.Wrap(err, "Invalid ID") - } - - // Simulate IO. - simulateAustralianInternet() - - switch action { - case DeleteAction: - ch.del <- MessageHeader{uint32(i), time.Now()} - case TriggerTypingAction: - ch.typ <- ch.messages[uint32(i)].author - } - - 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) (entries []cchat.CompletionEntry) { - switch { - case strings.HasPrefix("complete", words[i]): - entries = makeCompletion( - "complete", - "complete me", - "complete you", - "complete everyone", - ) - - case lookbackCheck(words, i, "complete", "me"): - entries = makeCompletion("me") - - case lookbackCheck(words, i, "complete", "you"): - entries = makeCompletion("you") - - case lookbackCheck(words, i, "complete", "everyone"): - entries = makeCompletion("everyone") - - case lookbackCheck(words, i, "best", "trap:"): - entries = makeCompletion( - "trap: Astolfo", - "trap: Hackadoll No. 3", - "trap: Totsuka", - "trap: Felix Argyle", - ) - - default: - var found = map[string]struct{}{} - - ch.messageMutex.Lock() - defer ch.messageMutex.Unlock() - - // Look for members. - for _, id := range ch.messageids { - if msg := ch.messages[id]; strings.HasPrefix(msg.author.name.Content, words[i]) { - if _, ok := found[msg.author.name.Content]; ok { - continue - } - - found[msg.author.name.Content] = struct{}{} - - entries = append(entries, cchat.CompletionEntry{ - Raw: msg.author.name.Content, - Text: msg.author.name, - IconURL: avatarURL, - }) - } - } - } - - return -} - -func makeCompletion(word ...string) []cchat.CompletionEntry { - var entries = make([]cchat.CompletionEntry, len(word)) - for i, w := range word { - entries[i].Raw = w - entries[i].Text.Content = w - entries[i].IconURL = avatarURL - } - return entries -} - -// completion will only override `this'. -func lookbackCheck(words []string, i int, prev, this string) bool { - return strings.HasPrefix(this, words[i]) && i > 0 && words[i-1] == prev -} - -// Typing sleeps and returns possibly an error. -func (ch *Channel) Typing() error { - return simulateAustralianInternet() -} - -// TypingTimeout returns 5 seconds. -func (ch *Channel) TypingTimeout() time.Duration { - return 5 * time.Second -} - -type Typer struct { - Author - time time.Time -} - -var _ cchat.Typer = (*Typer)(nil) - -func newTyper(a Author) *Typer { return &Typer{a, time.Now()} } -func randomTyper() *Typer { return &Typer{randomAuthor(), time.Now()} } -func (t *Typer) Time() time.Time { return t.time } - -func (ch *Channel) TypingSubscribe(ti cchat.TypingIndicator) (stop func(), err error) { - var stopch = make(chan struct{}) - - go func() { - var ticker = time.NewTicker(8 * time.Second) - defer ticker.Stop() - - for { - select { - case <-stopch: - return - case <-ticker.C: - ti.AddTyper(randomTyper()) - case author := <-ch.typ: - ti.AddTyper(newTyper(author)) - } - } - }() - - return func() { close(stopch) }, nil -} - -func generateChannels(s *Session, amount int) []cchat.Server { - var channels = make([]cchat.Server, amount) - for i := range channels { - channels[i] = &Channel{ - id: atomic.AddUint32(&s.lastid, 1), - name: "#" + randomdata.Noun(), - username: text.Rich{ - Content: s.username, - // hot pink-ish colored - Segments: []text.Segment{segments.NewColored(s.username, 0xE88AF8)}, - }, - } - } - - return channels -} - -func randClamp(min, max int) int { - return rand.Intn(max-min) + min -} diff --git a/go.mod b/go.mod index eb80831..7223293 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.14 require ( github.com/Pallinder/go-randomdata v1.2.0 github.com/diamondburned/aqs v0.0.0-20200704043812-99b676ee44eb - github.com/diamondburned/cchat v0.0.46 + github.com/diamondburned/cchat v0.2.11 github.com/lucasb-eyer/go-colorful v1.0.3 github.com/pkg/errors v0.9.1 golang.org/x/text v0.3.3 // indirect diff --git a/go.sum b/go.sum index cc980ac..36bdb27 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,11 @@ github.com/Pallinder/go-randomdata v1.2.0 h1:DZ41wBchNRb/0GfsePLiSwb0PHZmT67XY00lCDlaYPg= github.com/Pallinder/go-randomdata v1.2.0/go.mod h1:yHmJgulpD2Nfrm0cR9tI/+oAgRqCQQixsA8HyRZfV9Y= +github.com/dave/jennifer v1.4.1/go.mod h1:7jEdnm+qBcxl8PC0zyp7vxcpSRnzXSt9r39tpTVGlwA= github.com/diamondburned/aqs v0.0.0-20200704043812-99b676ee44eb h1:Ja/niwykeFoSkYxdRRzM8QUAuCswfLmaiBTd2UIU+54= github.com/diamondburned/aqs v0.0.0-20200704043812-99b676ee44eb/go.mod h1:q1MbMBfZrv7xqV8n7LgMwhHs3oBbNwWJes8exs2AmDs= -github.com/diamondburned/cchat v0.0.40 h1:38gPyJnnDoNDHrXcV8Qchfv3y6jlS3Fzz/6FY0BPH6I= -github.com/diamondburned/cchat v0.0.40/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat v0.0.43 h1:HetAujSaUSdnQgAUZgprNLARjf/MSWXpCfWdvX2wOCU= -github.com/diamondburned/cchat v0.0.43/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat v0.0.46 h1:fzm2XA9uGasX0uaic1AFfUMGA53PlO+GGmkYbx49A5k= -github.com/diamondburned/cchat v0.0.46/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.2.11 h1:w4c/6t02htGtVj6yIjznecOGMlkcj0TmmLy+K48gHeM= +github.com/diamondburned/cchat v0.2.11/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg= github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= diff --git a/internal/channel/channel.go b/internal/channel/channel.go new file mode 100644 index 0000000..271b00b --- /dev/null +++ b/internal/channel/channel.go @@ -0,0 +1,70 @@ +package channel + +import ( + "strconv" + + "github.com/Pallinder/go-randomdata" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/shared" + "github.com/diamondburned/cchat-mock/segments" + "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" +) + +type Channel struct { + empty.Server + id uint32 + name string + user Username + + messenger *Messenger +} + +var _ cchat.Server = (*Channel)(nil) + +func NewChannels(state *shared.State, n int) []*Channel { + var channels = make([]*Channel, n) + for i := range channels { + channels[i] = NewChannel(state) + } + return channels +} + +func AsCChatServers(channels []*Channel) []cchat.Server { + var cchs = make([]cchat.Server, len(channels)) + for i, ch := range channels { + cchs[i] = ch + } + return cchs +} + +// NewChannel creates a new random channel. +func NewChannel(state *shared.State) *Channel { + return &Channel{ + id: state.NextID(), + name: "#" + randomdata.Noun(), + user: Username{ + Content: state.Username, + Segments: []text.Segment{ + // hot pink-ish colored + segments.NewColoredSegment(state.Username, 0xE88AF8), + }, + }, + } +} + +func (ch *Channel) ID() string { + return strconv.Itoa(int(ch.id)) +} + +func (ch *Channel) Name() text.Rich { + return text.Plain(ch.name) +} + +func (ch *Channel) AsNicknamer() cchat.Nicknamer { + return ch.user +} + +func (ch *Channel) AsMessenger() cchat.Messenger { + return ch.messenger +} diff --git a/internal/channel/messageactioner.go b/internal/channel/messageactioner.go new file mode 100644 index 0000000..1b9e02f --- /dev/null +++ b/internal/channel/messageactioner.go @@ -0,0 +1,74 @@ +package channel + +import ( + "strconv" + "time" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/diamondburned/cchat-mock/internal/message" + "github.com/pkg/errors" +) + +type MessageActioner struct { + msgr *Messenger +} + +var _ cchat.Actioner = (*MessageActioner)(nil) + +func NewMessageActioner(msgr *Messenger) MessageActioner { + return MessageActioner{ + msgr: msgr, + } +} + +const ( + DeleteAction = "Delete" + NoopAction = "No-op" + BestTrapAction = "Who's the best trap?" + TriggerTypingAction = "Trigger Typing" +) + +func (msga MessageActioner) Actions(id string) []string { + return []string{ + DeleteAction, + NoopAction, + BestTrapAction, + TriggerTypingAction, + } +} + +// DoAction will be blocked by IO. As goes for every other method that +// takes a container: the frontend should call this in a goroutine. +func (msga MessageActioner) DoAction(action, messageID string) error { + switch action { + case DeleteAction, TriggerTypingAction: + i, err := strconv.Atoi(messageID) + if err != nil { + return errors.Wrap(err, "Invalid ID") + } + + // Simulate IO. + if err := internet.SimulateAustralian(); err != nil { + return err + } + + switch action { + case DeleteAction: + msga.msgr.del <- message.NewHeader(uint32(i), time.Now()) + case TriggerTypingAction: + msga.msgr.typ.TriggerTyping(msga.msgr.messages[uint32(i)].RealAuthor()) + } + + case NoopAction: + // do nothing. + + case BestTrapAction: + return msga.msgr.EditMessage(messageID, "Astolfo.") + + default: + return errors.New("Unknown action.") + } + + return nil +} diff --git a/internal/channel/messagecompleter.go b/internal/channel/messagecompleter.go new file mode 100644 index 0000000..69353c3 --- /dev/null +++ b/internal/channel/messagecompleter.go @@ -0,0 +1,85 @@ +package channel + +import ( + "strings" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/message" +) + +type MessageCompleter struct { + msgr *Messenger +} + +func (msgc MessageCompleter) Complete(words []string, i int64) []cchat.CompletionEntry { + switch { + case strings.HasPrefix("complete", words[i]): + return makeCompletion( + "complete", + "complete me", + "complete you", + "complete everyone", + ) + + case lookbackCheck(words, i, "complete", "me"): + return makeCompletion("me") + + case lookbackCheck(words, i, "complete", "you"): + return makeCompletion("you") + + case lookbackCheck(words, i, "complete", "everyone"): + return makeCompletion("everyone") + + case lookbackCheck(words, i, "best", "femboys:"): + return makeCompletion( + "trap: Astolfo", + "trap: Hackadoll No. 3", + "trap: Totsuka", + "trap: Felix Argyle", + ) + + default: + var found = map[string]struct{}{} + + msgc.msgr.messageMutex.Lock() + defer msgc.msgr.messageMutex.Unlock() + + var entries []cchat.CompletionEntry + + // Look for members. + for _, id := range msgc.msgr.messageids { + if msg := msgc.msgr.messages[id]; strings.HasPrefix(msg.AuthorName(), words[i]) { + if _, ok := found[msg.AuthorName()]; ok { + continue + } + + found[msg.AuthorName()] = struct{}{} + + entries = append(entries, cchat.CompletionEntry{ + Raw: msg.AuthorName(), + Text: msg.Author().Name(), + IconURL: msg.Author().Avatar(), + }) + } + } + + return entries + } + + return nil +} + +func makeCompletion(word ...string) []cchat.CompletionEntry { + var entries = make([]cchat.CompletionEntry, len(word)) + for i, w := range word { + entries[i].Raw = w + entries[i].Text.Content = w + entries[i].IconURL = message.AvatarURL + } + return entries +} + +// completion will only override `this'. +func lookbackCheck(words []string, i int64, prev, this string) bool { + return strings.HasPrefix(this, words[i]) && i > 0 && words[i-1] == prev +} diff --git a/internal/channel/messagesender.go b/internal/channel/messagesender.go new file mode 100644 index 0000000..279765d --- /dev/null +++ b/internal/channel/messagesender.go @@ -0,0 +1,45 @@ +package channel + +import ( + "time" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/pkg/errors" +) + +type MessageSender struct { + msgr *Messenger + ch chan cchat.SendableMessage +} + +var _ cchat.Sender = (*MessageSender)(nil) + +func NewMessageSender(msgr *Messenger) MessageSender { + return MessageSender{ + msgr: msgr, + ch: make(chan cchat.SendableMessage), + } +} + +// CanAttach returns false. +func (msgs MessageSender) CanAttach() bool { return false } + +func (msgs MessageSender) Send(msg cchat.SendableMessage) error { + if err := internet.SimulateAustralian(); err != nil { + return errors.Wrap(err, "Failed to send message") + } + + go func() { + // Make no guarantee that a message may arrive immediately when the + // function exits. + <-time.After(time.Second) + msgs.ch <- msg + }() + + return nil +} + +func (msgs MessageSender) AsCompleter() cchat.Completer { + return &MessageCompleter{msgr: msgs.msgr} +} diff --git a/internal/channel/messenger.go b/internal/channel/messenger.go new file mode 100644 index 0000000..31bbd7d --- /dev/null +++ b/internal/channel/messenger.go @@ -0,0 +1,303 @@ +package channel + +import ( + "context" + "math/rand" + "sync" + "sync/atomic" + "time" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/diamondburned/cchat-mock/internal/message" + "github.com/diamondburned/cchat-mock/internal/typing" + "github.com/diamondburned/cchat/utils/empty" + "github.com/pkg/errors" +) + +// FetchBacklog is the number of messages to fake-fetmsgr. +const FetchBacklog = 35 +const maxBacklog = FetchBacklog * 2 + +// max number to add to before the next author, with rand.Intn(limit) + incr. +const sameAuthorLimit = 6 + +type Messenger struct { + empty.Messenger + channel *Channel + + send MessageSender + edit chan message.Message // id + del chan message.Header + typ typing.Subscriber + + messageMutex sync.Mutex + messages map[uint32]message.Message + messageids []uint32 // indices + + // 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 +} + +var _ cchat.Messenger = (*Messenger)(nil) + +func (msgr *Messenger) JoinServer(ctx context.Context, ct cchat.MessagesContainer) (func(), error) { + // Is this a fresh channel? If yes, generate messages with some IO latency. + if len(msgr.messageids) == 0 || msgr.messages == nil { + // Simulate IO and error. + if err := internet.SimulateAustralianCtx(ctx); err != nil { + return nil, err + } + + // Initialize. + msgr.messages = make(map[uint32]message.Message, FetchBacklog) + msgr.messageids = make([]uint32, 0, FetchBacklog) + + // Allocate 3 channels that we won't clean up, because we're lazy. + msgr.send = NewMessageSender(msgr) + msgr.edit = make(chan message.Message) + msgr.del = make(chan message.Header) + msgr.typ = typing.NewSubscriber(message.NewAuthor(msgr.channel.user.Rich())) + + // Generate the backlog. + for i := 0; i < FetchBacklog; i++ { + msgr.addMessage(msgr.randomMsg(), ct) + } + } else { + // Else, flush the old backlog over. + for i := range msgr.messages { + ct.CreateMessage(msgr.messages[i]) + } + } + + // Initialize context for cancellation. The context passed in is used only + // for initialization, so we'll use our own context for the loop. + ctx, stop := context.WithCancel(context.Background()) + + go func() { + ticker := time.NewTicker(4 * time.Second) + defer ticker.Stop() + + editTick := time.NewTicker(10 * time.Second) + defer editTick.Stop() + + // deleteTick := time.NewTicker(15 * time.Second) + // defer deleteTick.Stop() + + for { + select { + case msg := <-msgr.send.ch: + msgr.addMessage(message.Echo( + msg, + msgr.nextID(), + message.NewAuthor(msgr.channel.user.Rich()), + ), ct) + + case msg := <-msgr.edit: + ct.UpdateMessage(msg) + + case msh := <-msgr.del: + msgr.deleteMessage(msh, ct) + + case <-ticker.C: + msgr.addMessage(msgr.randomMsg(), ct) + + case <-editTick.C: + var old = msgr.randomOldMsg() + msgr.updateMessage(message.NewRandomFromMessage(old), ct) + + // case <-deleteTick.C: + // var old = msgr.randomOldMsg() + // msgr.deleteMessage(message.Header{old.id, time.Now()}, container) + + case <-ctx.Done(): + return + } + } + }() + + return stop, nil +} + +func (msgr *Messenger) nextID() (id uint32) { + return atomic.AddUint32(&msgr.incrID, 1) +} + +func (msgr *Messenger) AsEditor() cchat.Editor { return msgr } + +// MessageEditable returns true if the message belongs to the author. +func (msgr *Messenger) MessageEditable(id string) bool { + i, err := message.ParseID(id) + if err != nil { + return false + } + + msgr.messageMutex.Lock() + defer msgr.messageMutex.Unlock() + + m, ok := msgr.messages[i] + if ok { + // Editable if same author. + return m.Author().Name().String() == msgr.channel.user.String() + } + + return false +} + +func (msgr *Messenger) RawMessageContent(id string) (string, error) { + i, err := message.ParseID(id) + if err != nil { + return "", err + } + + msgr.messageMutex.Lock() + defer msgr.messageMutex.Unlock() + + m, ok := msgr.messages[i] + if ok { + return m.Content().String(), nil + } + + return "", errors.New("Message not found") +} + +func (msgr *Messenger) EditMessage(id, content string) error { + i, err := message.ParseID(id) + if err != nil { + return err + } + + if err := internet.SimulateAustralian(); err != nil { + return err + } + + msgr.messageMutex.Lock() + defer msgr.messageMutex.Unlock() + + m, ok := msgr.messages[i] + if ok { + m.SetContent(content) + msgr.messages[i] = m + msgr.edit <- m + + return nil + } + + return errors.New("Message not found.") +} + +func (msgr *Messenger) addMessage(msg message.Message, container cchat.MessagesContainer) { + msgr.messageMutex.Lock() + + // Clean up the backlog. + if clean := len(msgr.messages) - maxBacklog; clean > 0 { + // Remove them from the map. + for _, id := range msgr.messageids[:clean] { + delete(msgr.messages, id) + } + + // Cut the message IDs away by shifting the slice. + msgr.messageids = append(msgr.messageids[:0], msgr.messageids[clean:]...) + } + + msgr.messages[msg.RealID()] = msg + msgr.messageids = append(msgr.messageids, msg.RealID()) + + msgr.messageMutex.Unlock() + + container.CreateMessage(msg) +} + +func (msgr *Messenger) updateMessage(msg message.Message, container cchat.MessagesContainer) { + msgr.messageMutex.Lock() + + _, ok := msgr.messages[msg.RealID()] + if ok { + msgr.messages[msg.RealID()] = msg + } + + msgr.messageMutex.Unlock() + + if ok { + container.UpdateMessage(msg) + } +} + +func (msgr *Messenger) deleteMessage(msg message.Header, container cchat.MessagesContainer) { + msgr.messageMutex.Lock() + + // Delete from the map. + delete(msgr.messages, msg.RealID()) + + // Delete from the ordered slice. + var ok bool + for i, id := range msgr.messageids { + if id == msg.RealID() { + msgr.messageids = append(msgr.messageids[:i], msgr.messageids[i+1:]...) + ok = true + break + } + } + + msgr.messageMutex.Unlock() + + if ok { + container.DeleteMessage(msg) + } +} + +// randomMsgID returns a random recent message ID. +func (msgr *Messenger) randomOldMsg() message.Message { + msgr.messageMutex.Lock() + defer msgr.messageMutex.Unlock() + + // Pick a random index from last, clamped to 10 and len channel. + n := len(msgr.messageids) - 1 - rand.Intn(len(msgr.messageids))%10 + return msgr.messages[msgr.messageids[n]] +} + +// randomMsg uses top of the state algorithms to return fair and balanced +// messages suitable for rigorous testing. +func (msgr *Messenger) randomMsg() (msg message.Message) { + msgr.messageMutex.Lock() + defer msgr.messageMutex.Unlock() + + // If we don't have any messages, then skip. + if len(msgr.messages) == 0 { + return message.Random(msgr.nextID()) + } + + // Add a random number into incrAuthor and determine if that should be + // enough to generate a new author. + msgr.incrAuthor += uint8(rand.Intn(sameAuthorLimit)) // 1~6 appearances + + var lastID = msgr.messageids[len(msgr.messageids)-1] + var lastAu = msgr.messages[lastID].RealAuthor() + + // If the last author is not the current user, then we can use it. + // Should we generate a new author for the new message? No if we're not over + // the limits. + if !lastAu.Equal(msg.RealAuthor()) && msgr.incrAuthor < sameAuthorLimit { + msg = message.RandomWithAuthor(msgr.nextID(), lastAu) + } else { + msg = message.Random(msgr.nextID()) + msgr.incrAuthor = 0 // reset + } + + return +} + +func (msgr *Messenger) AsSender() cchat.Sender { + return msgr.send +} + +func (msgr *Messenger) AsActioner() cchat.Actioner { + return &MessageActioner{msgr} +} + +func (msgr *Messenger) AsTypingIndicator() cchat.TypingIndicator { + return msgr.typ +} diff --git a/internal/channel/username.go b/internal/channel/username.go new file mode 100644 index 0000000..c84e7b8 --- /dev/null +++ b/internal/channel/username.go @@ -0,0 +1,32 @@ +package channel + +import ( + "context" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/diamondburned/cchat/text" +) + +// Username is the type for a username/nickname. +type Username text.Rich + +func (u Username) String() string { + return text.Rich(u).String() +} + +func (u Username) Rich() text.Rich { + return text.Rich(u) +} + +// Nickname sets the labeler to the nickname. It simulates heavy IO. This +// function stops as cancel is called in JoinServer, as Nickname is specially +// for that. +func (u Username) Nickname(ctx context.Context, labeler cchat.LabelContainer) (func(), error) { + if err := internet.SimulateAustralianCtx(ctx); err != nil { + return nil, err + } + + labeler.SetLabel(text.Rich(u)) + return func() {}, nil +} diff --git a/internal/internet/internet.go b/internal/internet/internet.go new file mode 100644 index 0000000..2945684 --- /dev/null +++ b/internal/internet/internet.go @@ -0,0 +1,48 @@ +package internet + +import ( + "context" + "errors" + "math/rand" + "time" +) + +var ( + // channel.go @ simulateAustralianInternet + CanFail = true + // 500ms ~ 3s + MinLatency = 500 + MaxLatency = 3000 +) + +// ErrTimedOut is returned when the simulated IO decides to fail. +var ErrTimedOut = errors.New("Australian Internet unsupported.") + +// SimulateAustralian simulates network latency with errors. +func SimulateAustralian() error { + return SimulateAustralianCtx(context.Background()) +} + +// SimulateAustralianCtx simulates network latency with errors. +func SimulateAustralianCtx(ctx context.Context) (err error) { + var ms = randClamp(MinLatency, MaxLatency) + + select { + case <-time.After(time.Duration(ms) * time.Millisecond): + // noop + case <-ctx.Done(): + return ctx.Err() + } + + // because australia, drop packet 20% of the time if internetCanFail is + // true. + if CanFail && rand.Intn(100) < 20 { + return ErrTimedOut + } + + return nil +} + +func randClamp(min, max int) int { + return rand.Intn(max-min) + min +} diff --git a/internal/message/author.go b/internal/message/author.go new file mode 100644 index 0000000..d41090e --- /dev/null +++ b/internal/message/author.go @@ -0,0 +1,58 @@ +package message + +import ( + "github.com/diamondburned/aqs" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/segments" + "github.com/diamondburned/cchat/text" +) + +const AvatarURL = "" + + "https://gist.github.com/diamondburned/" + + "945744c2b5ce0aa0581c9267a4e5cf24/raw/" + + "598069da673093aaca4cd4aa0ede1a0e324e9a3a/" + + "astolfo_selfie.png" + +type Author struct { + name text.Rich + char aqs.Character +} + +var _ cchat.Author = (*Author)(nil) + +func NewAuthor(name text.Rich) Author { + return Author{name: name} +} + +func RandomAuthor() Author { + var char = aqs.RandomCharacter() + return Author{ + char: char, + name: text.Rich{ + Content: char.Name, + Segments: []text.Segment{ + segments.NewColorfulSegment(char.Name, char.NameColor()), + }, + }, + } +} + +func (a Author) ID() string { + return a.name.Content +} + +func (a Author) Name() text.Rich { + return a.name +} + +func (a Author) Avatar() string { + if a.char.ImageURL != "" { + return a.char.ImageURL + } + return AvatarURL +} + +// Equal returns true if this author is the same as the given other author. +func (a Author) Equal(other Author) bool { + return a.name.Content == other.name.Content +} diff --git a/internal/message/header.go b/internal/message/header.go new file mode 100644 index 0000000..b371fe5 --- /dev/null +++ b/internal/message/header.go @@ -0,0 +1,42 @@ +package message + +import ( + "strconv" + "time" + + "github.com/diamondburned/cchat" +) + +type Header struct { + id uint32 + time time.Time +} + +var _ cchat.MessageHeader = (*Message)(nil) + +func ParseID(id string) (uint32, error) { + i, err := strconv.Atoi(id) + if err != nil { + return 0, err + } + return uint32(i), nil +} + +func NewHeader(id uint32, t time.Time) Header { + return Header{ + id: id, + time: t, + } +} + +func (m Header) ID() string { + return strconv.Itoa(int(m.id)) +} + +func (m Header) RealID() uint32 { + return m.id +} + +func (m Header) Time() time.Time { + return m.time +} diff --git a/internal/message/message.go b/internal/message/message.go new file mode 100644 index 0000000..ad1a006 --- /dev/null +++ b/internal/message/message.go @@ -0,0 +1,100 @@ +package message + +import ( + "strings" + "time" + + "github.com/diamondburned/aqs/incr" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat/text" + + _ "github.com/diamondburned/aqs/data" +) + +type Message struct { + Header + author Author + content string + nonce string +} + +var ( + _ cchat.MessageCreate = (*Message)(nil) + _ cchat.MessageUpdate = (*Message)(nil) + _ cchat.MessageDelete = (*Message)(nil) + _ cchat.Noncer = (*Message)(nil) +) + +func NewEmpty(id uint32, author Author) Message { + return Message{ + Header: Header{id: id}, + author: author, + } +} + +func NewRandomFromMessage(old Message) Message { + return NewRandom(old.id, old.author) +} + +func NewRandom(id uint32, author Author) Message { + return Message{ + Header: Header{id: id, time: time.Now()}, + author: author, + content: incr.RandomQuote(author.char), + } +} + +func Echo(sendable cchat.SendableMessage, id uint32, author Author) Message { + var echo = Message{ + Header: Header{id: id, time: time.Now()}, + author: author, + content: sendable.Content(), + } + if noncer := sendable.AsNoncer(); noncer != nil { + echo.nonce = noncer.Nonce() + } + return echo +} + +func Random(id uint32) Message { + return RandomWithAuthor(id, RandomAuthor()) +} + +func RandomWithAuthor(id uint32, author Author) Message { + return Message{ + Header: Header{id: id, time: time.Now()}, + author: author, + content: incr.RandomQuote(author.char), + } +} + +func (m Message) Author() cchat.Author { + return m.author +} + +func (m Message) RealAuthor() Author { + return m.author +} + +// AuthorName returns the message author's username in string. +func (m Message) AuthorName() string { + return m.author.name.Content +} + +func (m Message) Content() text.Rich { + return text.Rich{Content: m.content} +} + +func (m Message) Nonce() string { + return m.nonce +} + +// Mentioned is true when the message content contains the author's name. +func (m Message) Mentioned() bool { + // hack + return strings.Contains(m.content, m.author.name.Content) +} + +func (m *Message) SetContent(content string) { + m.content = content +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..b9d6cfe --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,78 @@ +package server + +import ( + "math/rand" + "strconv" + + "github.com/Pallinder/go-randomdata" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/channel" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/diamondburned/cchat-mock/internal/shared" + "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" +) + +type Server struct { + empty.Server + state *shared.State + id uint32 + name string + children ChannelList +} + +var _ cchat.Server = (*Server)(nil) + +func NewServers(state *shared.State, n int) []*Server { + var servers = make([]*Server, n) + for i := range servers { + servers[i] = New(state) + } + return servers +} + +// AsCChatServers casts a list of *Server to a list of cchat.Server. +func AsCChatServers(servers []*Server) []cchat.Server { + var csvs = make([]cchat.Server, len(servers)) + for i, sv := range servers { + csvs[i] = sv + } + return csvs +} + +func New(state *shared.State) *Server { + return &Server{ + state: state, + id: state.NextID(), + name: randomdata.Noun(), + children: RandomChannels(state, rand.Intn(12)+5), + } +} + +func (sv *Server) ID() string { + return strconv.Itoa(int(sv.id)) +} + +func (sv *Server) Name() text.Rich { + return text.Plain(sv.name) +} + +func (sv *Server) AsLister() cchat.Lister { + return sv.children +} + +type ChannelList []cchat.Server + +func RandomChannels(state *shared.State, n int) ChannelList { + return channel.AsCChatServers(channel.NewChannels(state, n)) +} + +func (chl ChannelList) Servers(container cchat.ServersContainer) error { + // IO time. + if err := internet.SimulateAustralian(); err != nil { + return err + } + + container.SetServers([]cchat.Server(chl)) + return nil +} diff --git a/internal/service/authenticator.go b/internal/service/authenticator.go new file mode 100644 index 0000000..b422f45 --- /dev/null +++ b/internal/service/authenticator.go @@ -0,0 +1,37 @@ +package service + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/diamondburned/cchat-mock/internal/session" + "github.com/pkg/errors" +) + +type Authenticator struct{} + +var _ cchat.Authenticator = (*Authenticator)(nil) + +func (Authenticator) AuthenticateForm() []cchat.AuthenticateEntry { + return []cchat.AuthenticateEntry{ + { + Name: "Username", + }, + { + Name: "Password (ignored)", + Secret: true, + }, + { + Name: "Paragraph (ignored)", + Multiline: true, + }, + } +} + +func (Authenticator) Authenticate(form []string) (cchat.Session, error) { + // SLOW IO TIME. + if err := internet.SimulateAustralian(); err != nil { + return nil, errors.Wrap(err, "Authentication failed") + } + + return session.New(form[0], ""), nil +} diff --git a/internal/service/configurator.go b/internal/service/configurator.go new file mode 100644 index 0000000..0aa2843 --- /dev/null +++ b/internal/service/configurator.go @@ -0,0 +1,45 @@ +package service + +import ( + "encoding/json" + "strconv" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" +) + +type Configurator struct{} + +func (Configurator) Configuration() (map[string]string, error) { + return map[string]string{ + // refer to internet.go + "internet.CanFail": strconv.FormatBool(internet.CanFail), + "internet.MinLatency": strconv.Itoa(internet.MinLatency), + "internet.MaxLatency": strconv.Itoa(internet.MaxLatency), + }, nil +} + +func (Configurator) SetConfiguration(config map[string]string) error { + for _, err := range []error{ + // shit code, would not recommend. It's only an ok-ish idea here because + // unmarshalConfig() returns ErrInvalidConfigAtField. + unmarshalConfig(config, "internet.CanFail", &internet.CanFail), + unmarshalConfig(config, "internet.MinLatency", &internet.MinLatency), + unmarshalConfig(config, "internet.MaxLatency", &internet.MaxLatency), + } { + if err != nil { + return err + } + } + return nil +} + +func unmarshalConfig(config map[string]string, key string, value interface{}) error { + if err := json.Unmarshal([]byte(config[key]), value); err != nil { + return &cchat.ErrInvalidConfigAtField{ + Key: key, + Err: err, + } + } + return nil +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..b839fc0 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,46 @@ +package service + +import ( + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/diamondburned/cchat-mock/internal/session" + "github.com/diamondburned/cchat-mock/internal/shared" + "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" + "github.com/pkg/errors" +) + +type Service struct { + empty.Service +} + +var _ cchat.Service = (*Service)(nil) + +func (s Service) Name() text.Rich { + return text.Rich{Content: "Mock"} +} + +func (s Service) RestoreSession(storage map[string]string) (cchat.Session, error) { + if err := internet.SimulateAustralian(); err != nil { + return nil, errors.Wrap(err, "Restore failed") + } + + state, err := shared.RestoreState(storage) + if err != nil { + return nil, err + } + + return session.FromState(state), nil +} + +func (s Service) Authenticate() cchat.Authenticator { + return Authenticator{} +} + +func (s Service) AsConfigurator() cchat.Configurator { + return Configurator{} +} + +func (s Service) AsSessionRestorer() cchat.SessionRestorer { + return s +} diff --git a/internal/session/commander.go b/internal/session/commander.go new file mode 100644 index 0000000..e2a74ca --- /dev/null +++ b/internal/session/commander.go @@ -0,0 +1,116 @@ +package session + +import ( + "fmt" + "io" + "strconv" + "strings" + + "github.com/Pallinder/go-randomdata" + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/diamondburned/cchat/text" + "github.com/pkg/errors" +) + +type Commander struct{} + +func (c *Commander) RunCommand(cmds []string, w io.Writer) error { + switch cmd := arg(cmds, 0); cmd { + case "ls": + fmt.Fprintln(w, "Commands: ls, random") + + case "random": + // callback used to generate stuff and stream into readcloser + var generator func() string + // number of times to generate the word + var times = 1 + + switch arg(cmds, 1) { + case "paragraph": + generator = randomdata.Paragraph + case "noun": + generator = randomdata.Noun + case "silly_name": + generator = randomdata.SillyName + default: + return errors.New("Usage: random [repeat]") + } + + if n := arg(cmds, 2); n != "" { + i, err := strconv.Atoi(n) + if err != nil { + return errors.Wrap(err, "Failed to parse repeat number") + } + times = i + } + + for i := 0; i < times; i++ { + // Yes, we're simulating this even in something as trivial as a + // command prompt. + if err := internet.SimulateAustralian(); err != nil { + fmt.Fprintln(w, "Error:", err) + continue + } + + fmt.Fprintln(w, generator()) + } + + default: + return fmt.Errorf("Unknown command: %s", cmd) + } + + return nil +} + +func (s *Commander) AsCompleter() cchat.Completer { return s } + +func (s *Commander) Complete(words []string, i int64) []cchat.CompletionEntry { + switch { + case strings.HasPrefix("ls", words[i]): + return newCompEntries("ls") + + case strings.HasPrefix("random", words[i]): + return newCompEntries( + "random paragraph", + "random noun", + "random silly_name", + ) + + case lookbackCheck(words, i, "random", "paragraph"): + return newCompEntries("paragraph") + + case lookbackCheck(words, i, "random", "noun"): + return newCompEntries("noun") + + case lookbackCheck(words, i, "random", "silly_name"): + return newCompEntries("silly_name") + + default: + return nil + } +} + +// completion will only override `this'. +func lookbackCheck(words []string, i int64, prev, this string) bool { + return strings.HasPrefix(this, words[i]) && i > 0 && words[i-1] == prev +} + +func newCompEntries(raws ...string) []cchat.CompletionEntry { + var entries = make([]cchat.CompletionEntry, len(raws)) + for i, raw := range raws { + entries[i] = cchat.CompletionEntry{ + Raw: raw, + Text: text.Plain(raw), + } + } + + return entries +} + +func arg(sl []string, i int) string { + if i >= len(sl) { + return "" + } + return sl[i] +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..38f2915 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,70 @@ +package session + +import ( + "math/rand" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/diamondburned/cchat-mock/internal/message" + "github.com/diamondburned/cchat-mock/internal/server" + "github.com/diamondburned/cchat-mock/internal/shared" + "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" +) + +type Session struct { + empty.Session + State *shared.State + ServerList []cchat.Server +} + +var _ cchat.Session = (*Session)(nil) + +func New(username, sessionID string) *Session { + return FromState(shared.NewState(username, sessionID)) +} + +func FromState(state *shared.State) *Session { + return &Session{ + State: state, + ServerList: server.AsCChatServers( + server.NewServers(state, rand.Intn(35)+10), + ), + } +} + +func (s *Session) ID() string { + return s.State.SessionID +} + +func (s *Session) Name() text.Rich { + return text.Plain(s.State.Username) +} + +func (s *Session) Disconnect() error { + s.State.SessionID = "" + s.State.ResetID() + + return internet.SimulateAustralian() +} + +func (s *Session) Servers(container cchat.ServersContainer) error { + if err := internet.SimulateAustralian(); err != nil { + return err + } + + container.SetServers(s.ServerList) + return nil +} + +func (s *Session) AsIconer() cchat.Iconer { + return shared.NewStaticIcon(message.AvatarURL) +} + +func (s *Session) AsCommander() cchat.Commander { + return &Commander{} +} + +func (s *Session) AsSessionSaver() cchat.SessionSaver { + return s.State +} diff --git a/internal/shared/icon.go b/internal/shared/icon.go new file mode 100644 index 0000000..7093623 --- /dev/null +++ b/internal/shared/icon.go @@ -0,0 +1,27 @@ +package shared + +import ( + "context" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/pkg/errors" +) + +// StaticIcon is a struct that implements cchat.Iconer. It never updates. +type StaticIcon struct { + URL string +} + +func NewStaticIcon(url string) StaticIcon { + return StaticIcon{url} +} + +func (icn StaticIcon) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) { + if err := internet.SimulateAustralian(); err != nil { + return nil, errors.Wrap(err, "failed to query for icon") + } + + iconer.SetIcon(icn.URL) + return func() {}, nil +} diff --git a/internal/shared/state.go b/internal/shared/state.go new file mode 100644 index 0000000..a32142a --- /dev/null +++ b/internal/shared/state.go @@ -0,0 +1,64 @@ +package shared + +import ( + "errors" + "math/rand" + "strconv" + "sync/atomic" + "time" + + "github.com/diamondburned/cchat" +) + +func init() { rand.Seed(time.Now().UnixNano()) } + +// ErrInvalidSession is returned if SessionRestore is given a bad session. +var ErrInvalidSession = errors.New("invalid session") + +type State struct { + SessionID string + Username string + + lastID uint32 // used for generation +} + +var _ cchat.SessionSaver = (*State)(nil) + +func NewState(username, sessionID string) *State { + var state = &State{Username: username, SessionID: sessionID} + if sessionID == "" { + state.SessionID = strconv.FormatUint(rand.Uint64(), 10) + } + + return state +} + +func RestoreState(store map[string]string) (*State, error) { + sID, ok := store["sessionID"] + if !ok { + return nil, ErrInvalidSession + } + + un, ok := store["username"] + if !ok { + return nil, ErrInvalidSession + } + + return NewState(un, sID), nil +} + +func (s *State) NextID() uint32 { + return atomic.AddUint32(&s.lastID, 1) +} + +// ResetID resets the atomic ID counter. +func (s *State) ResetID() { + atomic.StoreUint32(&s.lastID, 0) +} + +func (s *State) SaveSession() map[string]string { + return map[string]string{ + "sessionID": s.SessionID, + "username": s.Username, + } +} diff --git a/internal/typing/typing.go b/internal/typing/typing.go new file mode 100644 index 0000000..3e9896b --- /dev/null +++ b/internal/typing/typing.go @@ -0,0 +1,88 @@ +package typing + +import ( + "time" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-mock/internal/internet" + "github.com/diamondburned/cchat-mock/internal/message" +) + +type Typer struct { + message.Author + time time.Time +} + +var _ cchat.Typer = (*Typer)(nil) + +func NewTyper(a message.Author) *Typer { + return &Typer{Author: a, time: time.Now()} +} + +func RandomTyper() *Typer { + return &Typer{ + Author: message.RandomAuthor(), + time: time.Now(), + } +} + +func (t *Typer) Time() time.Time { + return t.time +} + +type Subscriber struct { + self message.Author + incoming chan message.Author +} + +func NewSubscriber(self message.Author) Subscriber { + return Subscriber{ + self: self, + incoming: make(chan message.Author), + } +} + +func (ts Subscriber) TriggerTyping(author message.Author) { + ts.incoming <- author +} + +func (ts Subscriber) TypingSubscribe(ti cchat.TypingContainer) (func(), error) { + var stopch = make(chan struct{}) + + go func() { + var ticker = time.NewTicker(8 * time.Second) + defer ticker.Stop() + + for { + select { + case <-stopch: + return + case <-ticker.C: + ti.AddTyper(RandomTyper()) + case author := <-ts.incoming: + ti.AddTyper(NewTyper(author)) + } + } + }() + + return func() { close(stopch) }, nil +} + +// Typing sleeps and returns possibly an error. +func (ts Subscriber) Typing() error { + if err := internet.SimulateAustralian(); err != nil { + return err + } + ts.TypingNow() + return nil +} + +// TypingNow sends a typing event immediately. +func (ts Subscriber) TypingNow() { + ts.TriggerTyping(ts.self) +} + +// TypingTimeout returns 5 seconds. +func (ts Subscriber) TypingTimeout() time.Duration { + return 5 * time.Second +} diff --git a/internet.go b/internet.go deleted file mode 100644 index 6c2b25b..0000000 --- a/internet.go +++ /dev/null @@ -1,44 +0,0 @@ -package mock - -import ( - "context" - "math/rand" - "time" - - "github.com/pkg/errors" -) - -var ( - // channel.go @ simulateAustralianInternet - internetCanFail = true - // 500ms ~ 3s - internetMinLatency = 500 - internetMaxLatency = 3000 -) - -// ErrTimedOut is returned when the simulated IO decides to fail. -var ErrTimedOut = errors.New("Australian Internet unsupported.") - -// simulate network latency -func simulateAustralianInternet() error { - return simulateAustralianInternetCtx(context.Background()) -} - -func simulateAustralianInternetCtx(ctx context.Context) (err error) { - var ms = randClamp(internetMinLatency, internetMaxLatency) - - select { - case <-time.After(time.Duration(ms) * time.Millisecond): - // noop - case <-ctx.Done(): - return ctx.Err() - } - - // because australia, drop packet 20% of the time if internetCanFail is - // true. - if internetCanFail && rand.Intn(100) < 20 { - return ErrTimedOut - } - - return nil -} diff --git a/message.go b/message.go deleted file mode 100644 index 8f87cf0..0000000 --- a/message.go +++ /dev/null @@ -1,152 +0,0 @@ -package mock - -import ( - "strconv" - "strings" - "time" - - "github.com/diamondburned/aqs" - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-mock/segments" - "github.com/diamondburned/cchat/text" - - _ "github.com/diamondburned/aqs/data" - "github.com/diamondburned/aqs/incr" -) - -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 parseID(id string) (uint32, error) { - i, err := strconv.Atoi(id) - if err != nil { - return 0, err - } - return uint32(i), nil -} - -func (m MessageHeader) ID() string { - return strconv.Itoa(int(m.id)) -} - -func (m MessageHeader) Time() time.Time { - return m.time -} - -type Message struct { - MessageHeader - author Author - content string - nonce string -} - -var ( - _ cchat.MessageCreate = (*Message)(nil) - _ cchat.MessageUpdate = (*Message)(nil) - _ cchat.MessageDelete = (*Message)(nil) - _ cchat.MessageNonce = (*Message)(nil) - _ cchat.MessageMentioned = (*Message)(nil) -) - -func newEmptyMessage(id uint32, author Author) Message { - return Message{ - MessageHeader: MessageHeader{id: id}, - author: author, - } -} - -func newRandomMessage(id uint32, author Author) Message { - return Message{ - MessageHeader: MessageHeader{id: id, time: time.Now()}, - author: author, - content: incr.RandomQuote(author.char), - } -} - -func echoMessage(sendable cchat.SendableMessage, id uint32, author Author) Message { - var echo = Message{ - MessageHeader: MessageHeader{id: id, time: time.Now()}, - author: author, - content: sendable.Content(), - } - if noncer, ok := sendable.(cchat.MessageNonce); ok { - echo.nonce = noncer.Nonce() - } - return echo -} - -func randomMessage(id uint32) Message { - return randomMessageWithAuthor(id, randomAuthor()) -} - -func randomMessageWithAuthor(id uint32, author Author) Message { - return Message{ - MessageHeader: MessageHeader{id: id, time: time.Now()}, - author: author, - content: incr.RandomQuote(author.char), - } -} - -func (m Message) Author() cchat.MessageAuthor { - return m.author -} - -func (m Message) Content() text.Rich { - return text.Rich{Content: m.content} -} - -func (m Message) Nonce() string { - return m.nonce -} - -// Mentioned is true when the message content contains the author's name. -func (m Message) Mentioned() bool { - // hack - return strings.Contains(m.content, m.author.name.Content) -} - -type Author struct { - name text.Rich - char aqs.Character -} - -var ( - _ cchat.MessageAuthor = (*Author)(nil) - _ cchat.MessageAuthorAvatar = (*Author)(nil) -) - -func newAuthor(name text.Rich) Author { - return Author{name: name} -} - -func randomAuthor() Author { - var char = aqs.RandomCharacter() - return Author{ - char: char, - name: text.Rich{ - Content: char.Name, - Segments: []text.Segment{segments.NewColorful(char.Name, char.NameColor())}, - }, - } -} - -func (a Author) ID() string { - return a.name.Content -} - -func (a Author) Name() text.Rich { - return a.name -} - -func (a Author) Avatar() string { - if a.char.ImageURL != "" { - return a.char.ImageURL - } - return avatarURL -} diff --git a/mock.go b/mock.go new file mode 100644 index 0000000..6ac8d67 --- /dev/null +++ b/mock.go @@ -0,0 +1,11 @@ +// Package mock contains a mock cchat backend. +package mock + +import ( + "github.com/diamondburned/cchat-mock/internal/service" + "github.com/diamondburned/cchat/services" +) + +func init() { + services.RegisterService(&service.Service{}) +} diff --git a/segments/color.go b/segments/color.go index bc14182..97b7748 100644 --- a/segments/color.go +++ b/segments/color.go @@ -6,6 +6,7 @@ import ( "time" "github.com/diamondburned/cchat/text" + "github.com/diamondburned/cchat/utils/empty" "github.com/lucasb-eyer/go-colorful" ) @@ -13,63 +14,78 @@ func init() { rand.Seed(time.Now().UnixNano()) } -type Colored struct { - strlen int - color uint32 +type ColoredSegment struct { + empty.TextSegment + strlen int + colored Colored } -var ( - _ text.Colorer = (*Colored)(nil) - _ text.Segment = (*Colored)(nil) -) +var _ text.Segment = (*ColoredSegment)(nil) -func NewColored(str string, color uint32) Colored { - return Colored{len(str), color} +func NewColoredSegment(str string, color uint32) ColoredSegment { + return ColoredSegment{ + strlen: len(str), + colored: NewColored(color), + } } -func NewRandomColored(str string) Colored { - return Colored{len(str), RandomColor()} +func NewRandomColoredSegment(str string) ColoredSegment { + return ColoredSegment{ + strlen: len(str), + colored: NewRandomColored(), + } } -func NewColorful(str string, color colorful.Color) Colored { +func NewColorfulSegment(str string, color colorful.Color) ColoredSegment { + return ColoredSegment{ + strlen: len(str), + colored: NewColorful(color), + } +} + +func (seg ColoredSegment) Bounds() (start, end int) { + return 0, seg.strlen +} + +func (seg ColoredSegment) AsColorer() text.Colorer { + return seg.colored +} + +type Colored uint32 + +var _ text.Colorer = (*Colored)(nil) + +// NewColored makes a new color segment from a string and a 24-bit color. +func NewColored(color uint32) Colored { + return Colored(color | (0xFF << 24)) // set alpha bits to 0xFF +} + +// NewRandomColored returns a random color segment. +func NewRandomColored() Colored { + return Colored(RandomColor()) +} + +// NewColorful returns a color segment from the given colorful.Color. +func NewColorful(color colorful.Color) Colored { r, g, b := color.RGB255() - h := (uint32(r) << 16) + (uint32(g) << 8) + (uint32(b)) - return NewColored(str, h) -} - -func (color Colored) Bounds() (start, end int) { - return 0, color.strlen + h := (0xFF << 24) + (uint32(r) << 16) + (uint32(g) << 8) + (uint32(b)) + return NewColored(h) } func (color Colored) Color() uint32 { - return color.color + return uint32(color) } -// var Colors = []uint32{ -// 0x55cdfc, -// 0x609ffb, -// 0x6b78fa, -// 0x9375f9, -// 0xc180f8, -// 0xe88af8, -// 0xf794e7, -// 0xf79ecc, -// 0xf7a8b8, -// } - -// func randomColor() uint32 { -// return Colors[rand.Intn(len(Colors))] -// } - var Colors = []uint32{ - 0xF5ABBA, - 0x5ACFFA, // starts here - 0xF5ABBA, - 0xFFFFFF, + 0xF5ABBAFF, + 0x5ACFFAFF, // starts here + 0xF5ABBAFF, + 0xFFFFFFFF, } var colorIndex uint32 = 0 +// RandomColor returns a random 32-bit RGBA color from the known palette. func RandomColor() uint32 { i := atomic.AddUint32(&colorIndex, 1) % uint32(len(Colors)) return Colors[i] diff --git a/server.go b/server.go deleted file mode 100644 index 90b367d..0000000 --- a/server.go +++ /dev/null @@ -1,58 +0,0 @@ -package mock - -import ( - "math/rand" - "strconv" - "sync/atomic" - - "github.com/Pallinder/go-randomdata" - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat/text" -) - -type Server struct { - session *Session - id uint32 - name string - children []cchat.Server -} - -var ( - _ cchat.Server = (*Server)(nil) - _ cchat.ServerList = (*Server)(nil) -) - -func (sv *Server) ID() string { - return strconv.Itoa(int(sv.id)) -} - -func (sv *Server) Name() text.Rich { - return text.Rich{Content: sv.name} -} - -func (sv *Server) Servers(container cchat.ServersContainer) error { - // IO time. - if err := simulateAustralianInternet(); err != nil { - return err - } - - container.SetServers(sv.children) - return nil -} - -func GenerateServers(s *Session) []cchat.Server { - return generateServers(s, rand.Intn(45)+2) -} - -func generateServers(s *Session, amount int) []cchat.Server { - var servers = make([]cchat.Server, amount) - for i := range servers { - servers[i] = &Server{ - session: s, - id: atomic.AddUint32(&s.lastid, 1), - name: randomdata.Noun(), - children: generateChannels(s, rand.Intn(12)+2), - } - } - return servers -} diff --git a/service.go b/service.go deleted file mode 100644 index ada76b6..0000000 --- a/service.go +++ /dev/null @@ -1,276 +0,0 @@ -// Package mock contains a mock cchat backend. -package mock - -import ( - "context" - "encoding/json" - "fmt" - "io" - "math/rand" - "strconv" - "strings" - "time" - - "github.com/Pallinder/go-randomdata" - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat/services" - "github.com/diamondburned/cchat/text" - "github.com/pkg/errors" -) - -func init() { - services.RegisterService(&Service{}) -} - -// ErrInvalidSession is returned if SessionRestore is given a bad session. -var ErrInvalidSession = errors.New("invalid session") - -type Service struct{} - -var ( - _ cchat.Service = (*Service)(nil) - _ cchat.Configurator = (*Service)(nil) - _ cchat.SessionRestorer = (*Service)(nil) -) - -func (s Service) Name() text.Rich { - return text.Rich{Content: "Mock"} -} - -func (s Service) RestoreSession(storage map[string]string) (cchat.Session, error) { - if err := simulateAustralianInternet(); err != nil { - return nil, errors.Wrap(err, "Restore failed") - } - - username, ok := storage["username"] - if !ok { - return nil, ErrInvalidSession - } - - sessionID, ok := storage["sessionID"] - if !ok { - return nil, ErrInvalidSession - } - - return newSession(username, sessionID), nil -} - -func (s Service) Authenticate() cchat.Authenticator { - return Authenticator{} -} - -type Authenticator struct{} - -var _ cchat.Authenticator = (*Authenticator)(nil) - -func (Authenticator) AuthenticateForm() []cchat.AuthenticateEntry { - return []cchat.AuthenticateEntry{ - { - Name: "Username", - }, - { - Name: "Password (ignored)", - Secret: true, - }, - { - Name: "Paragraph (ignored)", - Multiline: true, - }, - } -} - -func (Authenticator) Authenticate(form []string) (cchat.Session, error) { - // SLOW IO TIME. - if err := simulateAustralianInternet(); err != nil { - return nil, errors.Wrap(err, "Authentication failed") - } - - return newSession(form[0], ""), nil -} - -func (s Service) Configuration() (map[string]string, error) { - return map[string]string{ - // refer to internet.go - "internet.canFail": strconv.FormatBool(internetCanFail), - "internet.minLatency": strconv.Itoa(internetMinLatency), - "internet.maxLatency": strconv.Itoa(internetMaxLatency), - }, nil -} - -func (s Service) SetConfiguration(config map[string]string) error { - for _, err := range []error{ - // shit code, would not recommend. It's only an ok-ish idea here because - // unmarshalConfig() returns ErrInvalidConfigAtField. - unmarshalConfig(config, "internet.canFail", &internetCanFail), - unmarshalConfig(config, "internet.minLatency", &internetMinLatency), - unmarshalConfig(config, "internet.maxLatency", &internetMaxLatency), - } { - if err != nil { - return err - } - } - return nil -} - -func unmarshalConfig(config map[string]string, key string, value interface{}) error { - if err := json.Unmarshal([]byte(config[key]), value); err != nil { - return &cchat.ErrInvalidConfigAtField{ - Key: key, - Err: err, - } - } - return nil -} - -type Session struct { - sesID string - username string - servers []cchat.Server - lastid uint32 // used for generation -} - -var ( - _ cchat.Icon = (*Session)(nil) - _ cchat.Session = (*Session)(nil) - _ cchat.ServerList = (*Session)(nil) - _ cchat.SessionSaver = (*Session)(nil) - _ cchat.Commander = (*Session)(nil) - _ cchat.CommandCompleter = (*Session)(nil) -) - -func newSession(username, sessionID string) *Session { - ses := &Session{username: username, sesID: sessionID} - ses.servers = GenerateServers(ses) - - if sessionID == "" { - ses.sesID = strconv.FormatUint(rand.Uint64(), 10) - } - - return ses -} - -func (s *Session) ID() string { - return s.sesID -} - -func (s *Session) Name() text.Rich { - return text.Rich{Content: s.username} -} - -func (s *Session) Disconnect() error { - // Nothing to do here, but emulate errors. - return simulateAustralianInternet() -} - -func (s *Session) Servers(container cchat.ServersContainer) error { - // Simulate slight IO. - <-time.After(time.Second) - - container.SetServers(s.servers) - return nil -} - -func (s *Session) Icon(ctx context.Context, iconer cchat.IconContainer) (func(), error) { - // Simulate IO while ignoring the context. - simulateAustralianInternet() - - iconer.SetIcon(avatarURL) - return func() {}, nil -} - -func (s *Session) Save() (map[string]string, error) { - return map[string]string{ - "sessionID": s.sesID, - "username": s.username, - }, nil -} - -func (s *Session) RunCommand(cmds []string) (io.ReadCloser, error) { - var r, w = io.Pipe() - - switch cmd := arg(cmds, 0); cmd { - case "ls": - go func() { - fmt.Fprintln(w, "Commands: ls, random") - w.Close() - }() - - case "random": - // callback used to generate stuff and stream into readcloser - var generator func() string - // number of times to generate the word - var times = 1 - - switch arg(cmds, 1) { - case "paragraph": - generator = randomdata.Paragraph - case "noun": - generator = randomdata.Noun - case "silly_name": - generator = randomdata.SillyName - default: - return nil, errors.New("Usage: random [repeat]") - } - - if n := arg(cmds, 2); n != "" { - i, err := strconv.Atoi(n) - if err != nil { - return nil, errors.Wrap(err, "Failed to parse repeat number") - } - times = i - } - - go func() { - defer w.Close() - - for i := 0; i < times; i++ { - // Yes, we're simulating this even in something as trivial as a - // command prompt. - if err := simulateAustralianInternet(); err != nil { - fmt.Fprintln(w, "Error:", err) - continue - } - - fmt.Fprintln(w, generator()) - } - }() - - default: - return nil, fmt.Errorf("Unknown command: %s", cmd) - } - - return r, nil -} - -func (s *Session) CompleteCommand(words []string, i int) []string { - switch { - case strings.HasPrefix("ls", words[i]): - return []string{"ls"} - - case strings.HasPrefix("random", words[i]): - return []string{ - "random paragraph", - "random noun", - "random silly_name", - } - - case lookbackCheck(words, i, "random", "paragraph"): - return []string{"paragraph"} - - case lookbackCheck(words, i, "random", "noun"): - return []string{"noun"} - - case lookbackCheck(words, i, "random", "silly_name"): - return []string{"silly_name"} - - default: - return nil - } -} - -func arg(sl []string, i int) string { - if i >= len(sl) { - return "" - } - return sl[i] -}