1
0
Fork 0
mirror of https://github.com/diamondburned/cchat-mock.git synced 2024-12-03 18:32:50 +00:00

Migrated to cchat v2

This commit is contained in:
diamondburned 2020-10-04 13:47:41 -07:00
parent 0f83829667
commit b841407af3
28 changed files with 1499 additions and 1091 deletions

View file

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

2
go.mod
View file

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

10
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

100
internal/message/message.go Normal file
View file

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

78
internal/server/server.go Normal file
View file

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

View file

@ -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: "Paragrap