From f5ea7011274e273d6efb37c5cf942773b95e7e81 Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Tue, 26 Mar 2019 12:27:03 -0400 Subject: [PATCH 01/21] Add colon to the emote trim cutset This enables parsing emotes in the format :emote: as defined in #9. This does not add auto-complete, just brings emote parsing up to date. --- common/emotes.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/emotes.go b/common/emotes.go index b330c7b..50db5ed 100644 --- a/common/emotes.go +++ b/common/emotes.go @@ -11,7 +11,8 @@ var Emotes map[string]string func ParseEmotesArray(words []string) []string { newWords := []string{} for _, word := range words { - word = strings.Trim(word, "[]") + // make :emote: and [emote] valid for replacement. + word = strings.Trim(word, ":[]") found := false for key, val := range Emotes { From b03329772e892f5706d08e5d0a1ac5f9d7cf803e Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Tue, 26 Mar 2019 14:30:52 -0400 Subject: [PATCH 02/21] Fix fmt import error Not sure how this happened. --- main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/main.go b/main.go index aeada1a..929bae3 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "flag" + "fmt" "net/http" "os" "os/signal" From 64a6b2943ce9e517285092b0e2e7a0a7c144c410 Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Thu, 28 Mar 2019 15:19:43 -0400 Subject: [PATCH 03/21] Fix #70, name change on cancel Don't send the /nick command if a user clicks on the "nick" button and cancels out of the prompt. --- static/js/chat.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/js/chat.js b/static/js/chat.js index 8b3503b..9910cd0 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -124,7 +124,7 @@ function auth() { function nick() { let nick = prompt("Enter new name"); - if (nick != "") { + if (nick != "" && nick !== null) { sendMessage("/nick " + nick); } } @@ -270,4 +270,4 @@ function pleaseremovethis() { "tomato", "turquoise", "violet", "wheat", "white", "whitesmoke", "yellow", "yellowgreen",] -} \ No newline at end of file +} From a8ee9db3b801cdbf556d196ac8de48549e7a3ad2 Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Thu, 28 Mar 2019 15:38:00 -0400 Subject: [PATCH 04/21] Fix #63, case-sensitive highlight Make the name highlight code case-insensitive, and only match whole words. This fixes an unreported issue with emotes. If a user's name was in an emote, the emote image was broken by placing the highlight span in the image's url attribute. --- chatclient.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chatclient.go b/chatclient.go index 244daeb..d2b8145 100644 --- a/chatclient.go +++ b/chatclient.go @@ -180,7 +180,8 @@ func (cl *Client) Host() string { } func (cl *Client) setName(s string) error { - regex, err := regexp.Compile(fmt.Sprintf("(%s|@%s)", s, s)) + // Case-insensitive search. Match whole words only (`\b` is word boundary). + regex, err := regexp.Compile(fmt.Sprintf(`(?i)\b(%s|@%s)\b`, s, s)) if err != nil { return fmt.Errorf("could not compile regex: %v", err) } From d12434502e3a8ccb422bea9ee5c0254684138917 Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Thu, 28 Mar 2019 16:21:23 -0400 Subject: [PATCH 05/21] Put each CN- command constant on it's own line This will help with merges that add/remove commands. --- common/chatcommands.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/common/chatcommands.go b/common/chatcommands.go index 5257a8e..e9817a6 100644 --- a/common/chatcommands.go +++ b/common/chatcommands.go @@ -39,11 +39,30 @@ var ( var ChatCommands = []ChatCommandNames{ // User - CNMe, CNHelp, CNCount, CNColor, CNWhoAmI, CNAuth, CNUsers, CNNick, + CNMe, + CNHelp, + CNCount, + CNColor, + CNWhoAmI, + CNAuth, + CNUsers, + CNNick, + // Mod - CNSv, CNPlaying, CNUnmod, CNKick, CNBan, CNUnban, CNPurge, + CNSv, + CNPlaying, + CNUnmod, + CNKick, + CNBan, + CNUnban, + CNPurge, + // Admin - CNMod, CNReloadPlayer, CNReloadEmotes, CNModpass, CNIP, + CNMod, + CNReloadPlayer, + CNReloadEmotes, + CNModpass, + CNIP, } func GetFullChatCommand(c string) string { From 6f865b7a53ed78b1c0768156b1da78ba350d6cee Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Sun, 17 Mar 2019 23:46:46 -0400 Subject: [PATCH 06/21] Start adding support to download twitch emotes The code has been transferred over from the utility, and should work, but it hasn't been tested nor has it been linked to the command. --- common/chatcommands.go | 1 + emotes.go | 165 +++++++++++++++++++++++++++++++++++++++++ settings.go | 1 + 3 files changed, 167 insertions(+) create mode 100644 emotes.go diff --git a/common/chatcommands.go b/common/chatcommands.go index e9817a6..5b429e4 100644 --- a/common/chatcommands.go +++ b/common/chatcommands.go @@ -35,6 +35,7 @@ var ( CNReloadEmotes ChatCommandNames = []string{"reloademotes"} CNModpass ChatCommandNames = []string{"modpass"} CNIP ChatCommandNames = []string{"iplist"} + CNAddEmotes ChatCommandNames = []string{"addemotes"} ) var ChatCommands = []ChatCommandNames{ diff --git a/emotes.go b/emotes.go new file mode 100644 index 0000000..7f3df62 --- /dev/null +++ b/emotes.go @@ -0,0 +1,165 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +type twitchChannel struct { + ChannelName string `json:"channel_name"` + DisplayName string `json:"display_name"` + ChannelId string `json:"channel_id"` + BroadcasterType string `json:"broadcaster_type"` + Plans map[string]string `json:"plans"` + Emotes []struct { + Code string `json:"code"` + Set string `json:"emoticon_set"` + Id int `json:"id"` + } `json:"emotes"` + BaseSetId string `json:"base_set_id"` + GeneratedAt string `json:"generated_at"` +} + +// Used in settings +type EmoteSet struct { + Channel string // channel name + Prefix string // emote prefix + Found bool `json:"-"` +} + +const subscriberJson string = `subscribers.json` + +// Download a single channel's emote set +func (tc *twitchChannel) downloadEmotes() (*EmoteSet, error) { + es := &EmoteSet{Channel: strings.ToLower(tc.ChannelName)} + for _, emote := range tc.Emotes { + url := fmt.Sprintf(`https://static-cdn.jtvnw.net/emoticons/v1/%d/1.0`, emote.Id) + png := `static/emotes/` + emote.Code + `.png` + + if len(es.Prefix) == 0 { + // For each letter + for i := 0; i < len(emote.Code); i++ { + // Find the first capital + b := emote.Code[i] + if b >= 'A' && b <= 'Z' { + es.Prefix = emote.Code[0 : i-1] + fmt.Printf("Found prefix for channel %q: %q (%q)\n", es.Channel, es.Prefix, emote) + break + } + } + } + + resp, err := http.Get(url) + if err != nil { + return nil, err + } + + f, err := os.Create(png) + if err != nil { + return nil, err + } + + _, err = io.Copy(f, resp.Body) + if err != nil { + return nil, err + } + } + + return es, nil +} + +func GetEmotes(names []string) ([]*EmoteSet, error) { + // Do this up-front + for i := 0; i < len(names); i++ { + names[i] = strings.ToLower(names[i]) + } + + channels, err := findChannels(names) + if err != nil { + return nil, fmt.Errorf("Error reading %q: %v", subscriberJson, err) + } + + emoteSets := []*EmoteSet{} + for _, c := range channels { + es, err := c.downloadEmotes() + if err != nil { + return nil, fmt.Errorf("Error downloading emotes: %v", err) + } + emoteSets = append(emoteSets, es) + } + + for _, es := range emoteSets { + found := false + for _, name := range names { + if es.Channel == name { + found = true + break + } + } + if !found { + es.Found = false + } + } + + return emoteSets, nil +} + +func findChannels(names []string) ([]twitchChannel, error) { + file, err := os.Open(subscriberJson) + if err != nil { + return nil, err + } + defer file.Close() + + data := []twitchChannel{} + dec := json.NewDecoder(file) + + // Open bracket + _, err = dec.Token() + if err != nil { + return nil, err + } + + done := false + for dec.More() && !done { + // opening bracket of channel + _, err = dec.Token() + if err != nil { + return nil, err + } + + // Decode the channel stuff + var c twitchChannel + err = dec.Decode(&c) + if err != nil { + return nil, err + } + + // Is this a channel we are looking for? + found := false + for _, search := range names { + if strings.ToLower(c.ChannelName) == search { + found = true + break + } + } + + // Yes it is. Add it to the data + if found { + data = append(data, c) + } + + // Check for completion. Don't bother parsing the rest of + // the json file if we've already found everything that we're + // looking for. + if len(data) == len(names) { + done = true + } + } + + return data, nil +} diff --git a/settings.go b/settings.go index 278c523..7143cea 100644 --- a/settings.go +++ b/settings.go @@ -28,6 +28,7 @@ type Settings struct { AdminPassword string StreamKey string ListenAddress string + ApprovedEmotes []EmoteSet // list of channels that have been approved for emote use. Global emotes are always "approved". Bans []BanInfo LogLevel common.LogLevel LogFile string From fb2c25dc999ed896376c782d87f519def3e7c5cd Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Thu, 28 Mar 2019 16:47:58 -0400 Subject: [PATCH 07/21] Add the /addemotes command This command launches a goroutine that does the actual downloading of the emotes so the client doesn't hang until the process is finished. subscribers.json needs to be manually downloaded and added next to the main executable for now. Eventually, it will download and cache this file (for 24 hours, at a minimum). Issue #10 should not be considered finished yet. This needs to be tested a bit more with multiple channels. Ideally, some that exist and some that don't to verify that it fails gracefully (ie, adds the emotes it was able to download). --- .gitignore | 3 +++ chatcommands.go | 31 +++++++++++++++++++++++++++++++ common/chatcommands.go | 1 + emotes.go | 2 +- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d387c94..930c306 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ static/main.wasm # tags for vim tags + +# channel and emote list from twitch +subscribers.json diff --git a/chatcommands.go b/chatcommands.go index db8b867..c737466 100644 --- a/chatcommands.go +++ b/chatcommands.go @@ -318,6 +318,37 @@ var commands = &CommandControl{ return "see console for output" }, }, + + common.CNAddEmotes.String(): Command{ + HelpText: "Add emotes from a given twitch channel.", + Function: func(cl *Client, args []string) string { + // Fire this off in it's own goroutine so the client doesn't + // block waiting for the emote download to finish. + go func() { + + // Pretty sure this breaks on partial downloads (eg, one good channel and one non-existant) + _, err := GetEmotes(args) + if err != nil { + cl.SendChatData(common.NewChatMessage("", "", + err.Error(), + common.CmdlUser, common.MsgCommandResponse)) + return + } + + // reload emotes now that new ones were added + _, err = common.LoadEmotes() + if err != nil { + cl.SendChatData(common.NewChatMessage("", "", + err.Error(), + common.CmdlUser, common.MsgCommandResponse)) + return + } + + cl.belongsTo.AddModNotice(cl.name + " has added emotes from the following channels: " + strings.Join(args, ", ")) + }() + return "Emote download initiated for the following channels: " + strings.Join(args, ", ") + }, + }, }, } diff --git a/common/chatcommands.go b/common/chatcommands.go index 5b429e4..90bac91 100644 --- a/common/chatcommands.go +++ b/common/chatcommands.go @@ -64,6 +64,7 @@ var ChatCommands = []ChatCommandNames{ CNReloadEmotes, CNModpass, CNIP, + CNAddEmotes, } func GetFullChatCommand(c string) string { diff --git a/emotes.go b/emotes.go index 7f3df62..242f8c0 100644 --- a/emotes.go +++ b/emotes.go @@ -17,7 +17,7 @@ type twitchChannel struct { Plans map[string]string `json:"plans"` Emotes []struct { Code string `json:"code"` - Set string `json:"emoticon_set"` + Set int `json:"emoticon_set"` Id int `json:"id"` } `json:"emotes"` BaseSetId string `json:"base_set_id"` From 7a294ea00f7b7ff23e52c1a5dda5c1cbb7f23725 Mon Sep 17 00:00:00 2001 From: joeyak Date: Thu, 28 Mar 2019 18:40:49 -0400 Subject: [PATCH 08/21] Move color and whoami commands into the commands declaration --- chatcommands.go | 216 +++++++++++++++++++++++------------------------- 1 file changed, 105 insertions(+), 111 deletions(-) diff --git a/chatcommands.go b/chatcommands.go index c737466..48f41e6 100644 --- a/chatcommands.go +++ b/chatcommands.go @@ -45,9 +45,112 @@ var commands = &CommandControl{ }, }, - common.CNColor.String(): cmdColor, + common.CNColor.String(): Command{ + HelpText: "Change user color.", + Function: func(cl *Client, args []string) string { + if len(args) > 2 { + return "Too many arguments!" + } - common.CNWhoAmI.String(): cmdWhoAmI, + // If the caller is priviledged enough, they can change the color of another user + if len(args) == 2 { + if cl.CmdLevel == common.CmdlUser { + return "You cannot change someone else's color. PeepoSus" + } + + name, color := "", "" + + if strings.ToLower(args[0]) == strings.ToLower(args[1]) || + (common.IsValidColor(args[0]) && common.IsValidColor(args[1])) { + return "Name and color are ambiguous. Prefix the name with '@' or color with '#'" + } + + // Check for explicit name + if strings.HasPrefix(args[0], "@") { + name = strings.TrimLeft(args[0], "@") + color = args[1] + common.LogDebugln("[color:mod] Found explicit name: ", name) + } else if strings.HasPrefix(args[1], "@") { + name = strings.TrimLeft(args[1], "@") + color = args[0] + common.LogDebugln("[color:mod] Found explicit name: ", name) + + // Check for explicit color + } else if strings.HasPrefix(args[0], "#") { + name = strings.TrimPrefix(args[1], "@") // this shouldn't be needed, but just in case. + color = args[0] + common.LogDebugln("[color:mod] Found explicit color: ", color) + } else if strings.HasPrefix(args[1], "#") { + name = strings.TrimPrefix(args[0], "@") // this shouldn't be needed, but just in case. + color = args[1] + common.LogDebugln("[color:mod] Found explicit color: ", color) + + // Guess + } else if common.IsValidColor(args[0]) { + name = strings.TrimPrefix(args[1], "@") + color = args[0] + common.LogDebugln("[color:mod] Guessed name: ", name, " and color: ", color) + } else if common.IsValidColor(args[1]) { + name = strings.TrimPrefix(args[0], "@") + color = args[1] + common.LogDebugln("[color:mod] Guessed name: ", name, " and color: ", color) + } + + if name == "" { + return "Cannot determine name. Prefix name with @." + } + if color == "" { + return "Cannot determine color. Prefix name with @." + } + + if color == "" { + common.LogInfof("[color:mod] %s missing color\n", cl.name) + return "Missing color" + } + + if name == "" { + common.LogInfof("[color:mod] %s missing name\n", cl.name) + return "Missing name" + } + + if err := cl.belongsTo.ForceColorChange(name, color); err != nil { + return err.Error() + } + return fmt.Sprintf("Color changed for user %s to %s\n", name, color) + } + + // Don't allow an unprivilaged user to change their color if + // it was changed by a mod + if cl.IsColorForced { + common.LogInfof("[color] %s tried to change a forced color\n", cl.name) + return "You are not allowed to change your color." + } + + if len(args) == 0 { + cl.color = common.RandomColor() + return "Random color chosen: " + cl.color + } + + // Change the color of the user + if !common.IsValidColor(args[0]) { + return "To choose a specific color use the format /color #c029ce. Hex values expected." + } + + cl.color = args[0] + common.LogInfof("[color] %s new color: %s\n", cl.name, cl.color) + return "Color changed successfully." + }, + }, + + common.CNWhoAmI.String(): Command{ + HelpText: "Shows debug user info", + Function: func(cl *Client, args []string) string { + return fmt.Sprintf("Name: %s IsMod: %t IsAdmin: %t", + cl.name, + cl.CmdLevel >= common.CmdlMod, + cl.CmdLevel == common.CmdlAdmin) + }, + }, common.CNAuth.String(): Command{ HelpText: "Authenticate to admin", @@ -420,112 +523,3 @@ func getHelp(lvl common.CommandLevel) map[string]string { } return helptext } - -// Commands below have more than one invoking command (aliases). - -var cmdColor = Command{ - HelpText: "Change user color.", - Function: func(cl *Client, args []string) string { - if len(args) > 2 { - return "Too many arguments!" - } - - // If the caller is priviledged enough, they can change the color of another user - if len(args) == 2 { - if cl.CmdLevel == common.CmdlUser { - return "You cannot change someone else's color. PeepoSus" - } - - name, color := "", "" - - if strings.ToLower(args[0]) == strings.ToLower(args[1]) || - (common.IsValidColor(args[0]) && common.IsValidColor(args[1])) { - return "Name and color are ambiguous. Prefix the name with '@' or color with '#'" - } - - // Check for explicit name - if strings.HasPrefix(args[0], "@") { - name = strings.TrimLeft(args[0], "@") - color = args[1] - common.LogDebugln("[color:mod] Found explicit name: ", name) - } else if strings.HasPrefix(args[1], "@") { - name = strings.TrimLeft(args[1], "@") - color = args[0] - common.LogDebugln("[color:mod] Found explicit name: ", name) - - // Check for explicit color - } else if strings.HasPrefix(args[0], "#") { - name = strings.TrimPrefix(args[1], "@") // this shouldn't be needed, but just in case. - color = args[0] - common.LogDebugln("[color:mod] Found explicit color: ", color) - } else if strings.HasPrefix(args[1], "#") { - name = strings.TrimPrefix(args[0], "@") // this shouldn't be needed, but just in case. - color = args[1] - common.LogDebugln("[color:mod] Found explicit color: ", color) - - // Guess - } else if common.IsValidColor(args[0]) { - name = strings.TrimPrefix(args[1], "@") - color = args[0] - common.LogDebugln("[color:mod] Guessed name: ", name, " and color: ", color) - } else if common.IsValidColor(args[1]) { - name = strings.TrimPrefix(args[0], "@") - color = args[1] - common.LogDebugln("[color:mod] Guessed name: ", name, " and color: ", color) - } - - if name == "" { - return "Cannot determine name. Prefix name with @." - } - if color == "" { - return "Cannot determine color. Prefix name with @." - } - - if color == "" { - common.LogInfof("[color:mod] %s missing color\n", cl.name) - return "Missing color" - } - - if name == "" { - common.LogInfof("[color:mod] %s missing name\n", cl.name) - return "Missing name" - } - - if err := cl.belongsTo.ForceColorChange(name, color); err != nil { - return err.Error() - } - return fmt.Sprintf("Color changed for user %s to %s\n", name, color) - } - - // Don't allow an unprivilaged user to change their color if - // it was changed by a mod - if cl.IsColorForced { - common.LogInfof("[color] %s tried to change a forced color\n", cl.name) - return "You are not allowed to change your color." - } - - if len(args) == 0 { - cl.color = common.RandomColor() - return "Random color chosen: " + cl.color - } - - // Change the color of the user - if !common.IsValidColor(args[0]) { - return "To choose a specific color use the format /color #c029ce. Hex values expected." - } - - cl.color = args[0] - common.LogInfof("[color] %s new color: %s\n", cl.name, cl.color) - return "Color changed successfully." - }, -} - -var cmdWhoAmI = Command{ - HelpText: "Shows debug user info", - Function: func(cl *Client, args []string) string { - return fmt.Sprintf("Name: %s IsMod: %t IsAdmin: %t", - cl.name, - cl.CmdLevel >= common.CmdlMod, - cl.CmdLevel == common.CmdlAdmin) - }, -} From 8e4ac4d600baf8f34ff4c4325b9f42b7530ad675 Mon Sep 17 00:00:00 2001 From: joeyak Date: Thu, 28 Mar 2019 20:01:48 -0400 Subject: [PATCH 09/21] Add color cookies closes #6 --- chatclient.go | 5 +++++ chatcommands.go | 8 ++++++-- common/colors.go | 3 +-- common/constants.go | 1 + static/js/chat.js | 25 ++++++++++++++++++++++++- wasm/main.go | 17 ++++++++++++----- 6 files changed, 49 insertions(+), 10 deletions(-) diff --git a/chatclient.go b/chatclient.go index d2b8145..5c0c0c0 100644 --- a/chatclient.go +++ b/chatclient.go @@ -191,6 +191,11 @@ func (cl *Client) setName(s string) error { return nil } +func (cl *Client) setColor(s string) error { + cl.color = s + return cl.SendChatData(common.NewChatHiddenMessage(common.CdColor, cl.color)) +} + func (cl *Client) replaceColorizedName(chatData common.ChatData) common.ChatData { data := chatData.Data.(common.DataMessage) data.Message = cl.regexName.ReplaceAllString(data.Message, `$1`) diff --git a/chatcommands.go b/chatcommands.go index 48f41e6..6678cec 100644 --- a/chatcommands.go +++ b/chatcommands.go @@ -127,7 +127,7 @@ var commands = &CommandControl{ } if len(args) == 0 { - cl.color = common.RandomColor() + cl.setColor(common.RandomColor()) return "Random color chosen: " + cl.color } @@ -136,7 +136,11 @@ var commands = &CommandControl{ return "To choose a specific color use the format /color #c029ce. Hex values expected." } - cl.color = args[0] + err := cl.setColor(args[0]) + if err != nil { + common.LogErrorf("[color] could not send color update to client: %v\n", err) + } + common.LogInfof("[color] %s new color: %s\n", cl.name, cl.color) return "Color changed successfully." }, diff --git a/common/colors.go b/common/colors.go index 57fb7b1..f2c18e1 100644 --- a/common/colors.go +++ b/common/colors.go @@ -47,8 +47,7 @@ var ( ) // IsValidColor takes a string s and compares it against a list of css color names. -// It also accepts hex codes in the form of #000 (RGB), to #00000000 (RRGGBBAA), with A -// being the alpha value +// It also accepts hex codes in the form of #RGB and #RRGGBB func IsValidColor(s string) bool { s = strings.ToLower(s) for _, c := range colors { diff --git a/common/constants.go b/common/constants.go index e52d4d7..305ee9d 100644 --- a/common/constants.go +++ b/common/constants.go @@ -8,6 +8,7 @@ const ( CdUsers // get a list of users CdPing // ping the server to keep the connection alive CdAuth // get the auth levels of the user + CdColor // get the users color ) type DataType int diff --git a/static/js/chat.js b/static/js/chat.js index 9910cd0..f49fd53 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -1,5 +1,21 @@ /// +function getCookie(cname) { + var name = cname + "="; + var decodedCookie = decodeURIComponent(document.cookie); + var ca = decodedCookie.split(';'); + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == ' ') { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + return ""; +} + function setPlaying(title, link) { if (title !== "") { $('#playing').text(title); @@ -89,6 +105,14 @@ function join() { } setNotifyBox(); openChat(); + + let color = getCookie("color"); + if (color !== "") { + // Do a timeout because timings + setTimeout(() => { + sendMessage("/color " + color); + }, 250); + } } function websocketSend(data) { @@ -169,7 +193,6 @@ function changeColor() { } } - // Get the websocket setup in a function so it can be recalled function setupWebSocket() { ws = new WebSocket(getWsUri()); diff --git a/wasm/main.go b/wasm/main.go index dab4572..23f0ce2 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -10,7 +10,10 @@ import ( "github.com/zorchenhimer/MovieNight/common" ) -var auth common.CommandLevel +var ( + auth common.CommandLevel + color string +) func recieve(v []js.Value) { if len(v) == 0 { @@ -41,6 +44,9 @@ func recieve(v []js.Value) { } case common.CdAuth: auth = h.Data.(common.CommandLevel) + case common.CdColor: + color = h.Data.(string) + js.Get("document").Set("cookie", fmt.Sprintf("color=%s;", color)) } case common.DTEvent: d := chat.Data.(common.DataEvent) @@ -101,19 +107,19 @@ func websocketSend(msg string, dataType common.ClientDataType) error { func send(this js.Value, v []js.Value) interface{} { if len(v) != 1 { - showSendError(fmt.Errorf("expected 1 parameter, got %d", len(v))) + showChatError(fmt.Errorf("expected 1 parameter, got %d", len(v))) return false } err := websocketSend(v[0].String(), common.CdMessage) if err != nil { - showSendError(err) + showChatError(err) return false } return true } -func showSendError(err error) { +func showChatError(err error) { if err != nil { fmt.Printf("Could not send: %v\n", err) js.Call("appendMessages", `
Could not send message
`) @@ -135,8 +141,9 @@ func isValidName(this js.Value, v []js.Value) interface{} { } func debugValues(v []js.Value) { - fmt.Printf("currentName %#v\n", currentName) fmt.Printf("auth %#v\n", auth) + fmt.Printf("color %#v\n", color) + fmt.Printf("currentName %#v\n", currentName) fmt.Printf("names %#v\n", names) fmt.Printf("filteredNames %#v\n", filteredNames) } From 10092e1dd584ff3481777677023a56d07b317706 Mon Sep 17 00:00:00 2001 From: joeyak Date: Thu, 28 Mar 2019 20:25:46 -0400 Subject: [PATCH 10/21] Change wrappers to div so client can add to beginning and end of message issue #66 --- common/chatdata.go | 52 +++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/common/chatdata.go b/common/chatdata.go index 961c0be..0cb4a4b 100644 --- a/common/chatdata.go +++ b/common/chatdata.go @@ -78,7 +78,7 @@ type ClientData struct { func (c ClientData) HTML() string { // Client data is for client to server communication only, so clients should not see this - return `
The developer messed up. You should not be seeing this.
` + return `The developer messed up. You should not be seeing this.` } type DataMessage struct { @@ -93,20 +93,20 @@ type DataMessage struct { func (dc DataMessage) HTML() string { switch dc.Type { case MsgAction: - return `
` + dc.From + - ` ` + dc.Message + `
` + return `` + dc.From + + ` ` + dc.Message + `` case MsgServer: - return `
` + dc.Message + `
` + return `` + dc.Message + `` case MsgError: - return `
` + dc.Message + `
` + return `` + dc.Message + `` case MsgNotice: - return `
` + dc.Message + `
` + return `` + dc.Message + `` case MsgCommandResponse: - return `
` + dc.Message + `
` + return `` + dc.Message + `` default: badge := "" @@ -116,8 +116,8 @@ func (dc DataMessage) HTML() string { case CmdlAdmin: badge = `` } - return `
` + badge + `` + dc.From + - `: ` + dc.Message + `
` + return `` + badge + `` + dc.From + + `: ` + dc.Message + `` } } @@ -142,7 +142,7 @@ type DataCommand struct { func (de DataCommand) HTML() string { switch de.Command { case CmdPurgeChat: - return `
Chat has been purged by a moderator.
` + return `Chat has been purged by a moderator.` default: return "" } @@ -167,37 +167,37 @@ type DataEvent struct { func (de DataEvent) HTML() string { switch de.Event { case EvKick: - return `
` + - de.User + ` has been kicked.
` + return `` + + de.User + ` has been kicked.` case EvLeave: - return `
` + - de.User + ` has left the chat.
` + return `` + + de.User + ` has left the chat.` case EvBan: - return `
` + - de.User + ` has been banned.
` + return `` + + de.User + ` has been banned.` case EvJoin: - return `
` + - de.User + ` has joined the chat.
` + return `` + + de.User + ` has joined the chat.` case EvNameChange: names := strings.Split(de.User, ":") if len(names) != 2 { - return `
Somebody changed their name, but IDK who ` + - ParseEmotes("Jebaited") + `.
` + return `Somebody changed their name, but IDK who ` + + ParseEmotes("Jebaited") + `.` } - return `
` + + return `` + names[0] + ` has changed their name to ` + names[1] + `.
` + de.Color + `">` + names[1] + `.` case EvNameChangeForced: names := strings.Split(de.User, ":") if len(names) != 2 { - return `
An admin changed somebody's name, but IDK who ` + - ParseEmotes("Jebaited") + `.
` + return `An admin changed somebody's name, but IDK who ` + + ParseEmotes("Jebaited") + `.` } - return `
` + + return `` + names[0] + ` has had their name changed to ` + names[1] + ` by an admin.
` + de.Color + `">` + names[1] + ` by an admin.` } return "" } From ee2e92a9a57a3e37af1626032a5fb1503659ecc2 Mon Sep 17 00:00:00 2001 From: joeyak Date: Thu, 28 Mar 2019 20:54:29 -0400 Subject: [PATCH 11/21] Adding timestamps resolves #66 --- static/js/chat.js | 10 ++++++++++ static/main.html | 5 +++++ wasm/main.go | 41 ++++++++++++++++++++++++++++++----------- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/static/js/chat.js b/static/js/chat.js index f49fd53..55e108e 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -193,6 +193,11 @@ function changeColor() { } } +function setTimestamp(v) { + showTimestamp(v) + document.cookie = "timestamp=" + v +} + // Get the websocket setup in a function so it can be recalled function setupWebSocket() { ws = new WebSocket(getWsUri()); @@ -246,6 +251,11 @@ function defaultValues() { $("#colorRed").val(0).trigger("input"); $("#colorGreen").val(0).trigger("input"); $("#colorBlue").val(0).trigger("input"); + + let timestamp = getCookie("timestamp") + if (timestamp !== "") { + showTimestamp(timestamp) + } } window.addEventListener("onresize", updateSuggestionCss); diff --git a/static/main.html b/static/main.html index 3a09f92..3607ce8 100644 --- a/static/main.html +++ b/static/main.html @@ -57,6 +57,11 @@ {{end}}
+ +
diff --git a/wasm/main.go b/wasm/main.go index 23f0ce2..efbe924 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -11,8 +11,9 @@ import ( ) var ( - auth common.CommandLevel - color string + timestamp bool + color string + auth common.CommandLevel ) func recieve(v []js.Value) { @@ -24,7 +25,7 @@ func recieve(v []js.Value) { chatJSON, err := common.DecodeData(v[0].String()) if err != nil { fmt.Printf("Error decoding data: %s\n", err) - js.Call("appendMessages", v) + js.Call("appendMessages", fmt.Sprintf("
%v
", v)) return } @@ -57,7 +58,7 @@ func recieve(v []js.Value) { // on join or leave, update list of possible user names fallthrough case common.DTChat: - js.Call("appendMessages", chat.Data.HTML()) + appendMessage(chat.Data.HTML()) case common.DTCommand: d := chat.Data.(common.DataCommand) @@ -76,18 +77,26 @@ func recieve(v []js.Value) { js.Call("initPlayer", nil) case common.CmdPurgeChat: js.Call("purgeChat", nil) - js.Call("appendMessages", d.HTML()) + appendMessage(d.HTML()) case common.CmdHelp: url := "/help" if d.Arguments != nil && len(d.Arguments) > 0 { url = d.Arguments[0] } - js.Call("appendMessages", d.HTML()) + appendMessage(d.HTML()) js.Get("window").Call("open", url, "_blank", "menubar=0,status=0,toolbar=0,width=300,height=600") } } } +func appendMessage(msg string) { + if timestamp { + h, m, _ := time.Now().Clock() + msg = fmt.Sprintf(`%d:%d %s`, h, m, msg) + } + js.Call("appendMessages", "
"+msg+"
") +} + func websocketSend(msg string, dataType common.ClientDataType) error { if strings.TrimSpace(msg) == "" && dataType == common.CdMessage { return nil @@ -126,6 +135,14 @@ func showChatError(err error) { } } +func showTimestamp(v []js.Value) { + if len(v) != 1 { + // Don't bother with returning a value + return + } + timestamp = v[0].Bool() +} + func isValidColor(this js.Value, v []js.Value) interface{} { if len(v) != 1 { return false @@ -141,11 +158,12 @@ func isValidName(this js.Value, v []js.Value) interface{} { } func debugValues(v []js.Value) { - fmt.Printf("auth %#v\n", auth) - fmt.Printf("color %#v\n", color) - fmt.Printf("currentName %#v\n", currentName) - fmt.Printf("names %#v\n", names) - fmt.Printf("filteredNames %#v\n", filteredNames) + fmt.Printf("timestamp: %#v\n", timestamp) + fmt.Printf("auth: %#v\n", auth) + fmt.Printf("color: %#v\n", color) + fmt.Printf("currentName: %#v\n", currentName) + fmt.Printf("names: %#v\n", names) + fmt.Printf("filteredNames: %#v\n", filteredNames) } func main() { @@ -157,6 +175,7 @@ func main() { js.Set("recieveMessage", js.CallbackOf(recieve)) js.Set("processMessage", js.CallbackOf(processMessage)) js.Set("debugValues", js.CallbackOf(debugValues)) + js.Set("showTimestamp", js.CallbackOf(showTimestamp)) // This is needed so the goroutine does not end for { From b281ebe4085dc54f4b1dd8c747fcc5b392961938 Mon Sep 17 00:00:00 2001 From: joeyak Date: Thu, 28 Mar 2019 21:55:28 -0400 Subject: [PATCH 12/21] Add remote easter egg --- static/base.html | 1 + static/css/site.css | 10 ++++++++++ static/img/remote.png | Bin 0 -> 17677 bytes static/img/remote_active.png | Bin 0 -> 9335 bytes static/js/both.js | 30 ++++++++++++++++++++++++++++++ 5 files changed, 41 insertions(+) create mode 100644 static/img/remote.png create mode 100644 static/img/remote_active.png diff --git a/static/base.html b/static/base.html index 7c4dfc4..6e891ba 100644 --- a/static/base.html +++ b/static/base.html @@ -12,6 +12,7 @@ +
{{template "body" .}}
diff --git a/static/css/site.css b/static/css/site.css index 7b53c86..c1f2b43 100644 --- a/static/css/site.css +++ b/static/css/site.css @@ -286,4 +286,14 @@ span.svmsg { #colorSubmit:disabled { display: none; +} + +#remote { + position: absolute; + left: 0; + right: 0; + top: 0; + margin: 1em auto; + width: 50px; + z-index: 999; } \ No newline at end of file diff --git a/static/img/remote.png b/static/img/remote.png new file mode 100644 index 0000000000000000000000000000000000000000..e82a53e7bc0c7e24f28df087b45ad98ca9574b73 GIT binary patch literal 17677 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 literal 0 HcmV?d00001 diff --git a/static/js/both.js b/static/js/both.js index c41c68a..cf86cf2 100644 --- a/static/js/both.js +++ b/static/js/both.js @@ -1,8 +1,38 @@ /// +let konamiCode = ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"] +let lastKeys = [] + // Make this on all pages so video page also doesn't do this $(document).on("keydown", function (e) { + checkKonami(e); + if (e.which === 8 && !$(e.target).is("input, textarea")) { e.preventDefault(); } }); + + +function checkKonami(e) { + lastKeys.push(e.key); + if (lastKeys.length > 10) { + lastKeys.shift(); + } + + if (lastKeys.length === konamiCode.length) { + for (let i = 0; i < lastKeys.length; i++) { + if (lastKeys[i] != konamiCode[i]) { + console.log(i); + return; + } + } + $("#remote").css("display", ""); + } +} + +function flipRemote() { + $("#remote").attr("src", "/static/img/remote_active.png"); + setTimeout(() => { + $("#remote").attr("src", "/static/img/remote.png"); + }, Math.round(Math.random() * 10000) + 1000); +} From a2cc5115aac807e5748234fb4d1d49da13e09e00 Mon Sep 17 00:00:00 2001 From: joeyak Date: Thu, 28 Mar 2019 22:06:57 -0400 Subject: [PATCH 13/21] Fix timeout not being set and scrolling not catching up with chat --- static/js/chat.js | 16 +++++++--------- wasm/main.go | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/static/js/chat.js b/static/js/chat.js index 55e108e..e2db3c9 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -66,7 +66,7 @@ function appendMessages(msg) { } $("#messages").append(msg); - $("#messages").children().last()[0].scrollIntoView({ block: "end", behavior: "smooth" }); + $("#messages").children().last()[0].scrollIntoView({ block: "end" }); } function purgeChat() { @@ -248,14 +248,12 @@ function setupEvents() { } function defaultValues() { - $("#colorRed").val(0).trigger("input"); - $("#colorGreen").val(0).trigger("input"); - $("#colorBlue").val(0).trigger("input"); - - let timestamp = getCookie("timestamp") - if (timestamp !== "") { - showTimestamp(timestamp) - } + setTimeout(() => { + let timestamp = getCookie("timestamp") + if (timestamp !== "") { + showTimestamp(timestamp === "true") + } + }, 500); } window.addEventListener("onresize", updateSuggestionCss); diff --git a/wasm/main.go b/wasm/main.go index efbe924..8e91dea 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -92,7 +92,7 @@ func recieve(v []js.Value) { func appendMessage(msg string) { if timestamp { h, m, _ := time.Now().Clock() - msg = fmt.Sprintf(`%d:%d %s`, h, m, msg) + msg = fmt.Sprintf(`%02d:%02d %s`, h, m, msg) } js.Call("appendMessages", "
"+msg+"
") } From 237619f57e520e051334f41e80db3029241c8b4c Mon Sep 17 00:00:00 2001 From: joeyak Date: Sat, 30 Mar 2019 13:17:27 -0400 Subject: [PATCH 14/21] Make font 12px to better fit words and timestamp --- static/css/site.css | 1 + 1 file changed, 1 insertion(+) diff --git a/static/css/site.css b/static/css/site.css index c1f2b43..f260cd3 100644 --- a/static/css/site.css +++ b/static/css/site.css @@ -234,6 +234,7 @@ span.svmsg { grid-gap: 10px; margin: 0px 5px; overflow: auto; + font-size: 12px; } #messages { From 0a817eff8703c1e7300aac3dcb9c1836675ae247 Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Sat, 30 Mar 2019 14:21:46 -0400 Subject: [PATCH 15/21] Add a NoCache setting Added this to the settings so it can be configured at runtime. If set to true, a Cache-Control header will be sent with the wasm file as well as the index html. --- handlers.go | 8 ++++++-- settings.go | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/handlers.go b/handlers.go index 710535a..3effeac 100644 --- a/handlers.go +++ b/handlers.go @@ -63,7 +63,9 @@ func wsStaticFiles(w http.ResponseWriter, r *http.Request) { } func wsWasmFile(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-cache, must-revalidate") + if settings.NoCache { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + } http.ServeFile(w, r, "./static/main.wasm") } @@ -215,7 +217,9 @@ func handleIndexTemplate(w http.ResponseWriter, r *http.Request) { } // Force browser to replace cache since file was not changed - w.Header().Set("Cache-Control", "no-cache, must-revalidate") + if settings.NoCache { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + } err = t.Execute(w, data) if err != nil { diff --git a/settings.go b/settings.go index 7143cea..499356b 100644 --- a/settings.go +++ b/settings.go @@ -32,6 +32,9 @@ type Settings struct { Bans []BanInfo LogLevel common.LogLevel LogFile string + + // Send the NoCache header? + NoCache bool } type BanInfo struct { From e7cfcd8688dab4a8e98b58241f5ba28f1c7bc3e5 Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Sat, 30 Mar 2019 14:23:04 -0400 Subject: [PATCH 16/21] Move logging setup to LoadSettings() --- main.go | 4 ---- settings.go | 8 ++++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/main.go b/main.go index 929bae3..f73900a 100644 --- a/main.go +++ b/main.go @@ -28,10 +28,6 @@ func setupSettings() error { return fmt.Errorf("Missing stream key is settings.json") } - if err = settings.SetupLogging(); err != nil { - return fmt.Errorf("Unable to setup logger: %s", err) - } - // Save admin password to file if err = settings.Save(); err != nil { return fmt.Errorf("Unable to save settings: %s", err) diff --git a/settings.go b/settings.go index 499356b..7c27f3c 100644 --- a/settings.go +++ b/settings.go @@ -56,6 +56,10 @@ func LoadSettings(filename string) (*Settings, error) { } s.filename = filename + if err = common.SetupLogging(s.LogLevel, s.LogFile); err != nil { + return nil, fmt.Errorf("Unable to setup logger: %s", err) + } + // have a default of 200 if s.MaxMessageCount == 0 { s.MaxMessageCount = 300 @@ -168,7 +172,3 @@ func (s *Settings) GetStreamKey() string { } return s.StreamKey } - -func (s *Settings) SetupLogging() error { - return common.SetupLogging(s.LogLevel, s.LogFile) -} From 8801269969ecd1cf4bd44a403bb5128e4655c48c Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Sat, 30 Mar 2019 15:13:57 -0400 Subject: [PATCH 17/21] Fix canceling the Auth popup dialog Don't try to auth if the user cancels out of the auth popup. --- static/js/chat.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/chat.js b/static/js/chat.js index e2db3c9..6c5a0eb 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -141,7 +141,7 @@ function setNotifyBox(msg = "") { // Button Wrapper Functions function auth() { let pass = prompt("Enter pass"); - if (pass != "") { + if (pass != "" && pass !== null) { sendMessage("/auth " + pass); } } From ca72dc28c02cff4e483e0a6c314c0ed0fa7d24a5 Mon Sep 17 00:00:00 2001 From: joeyak Date: Sat, 30 Mar 2019 15:23:54 -0400 Subject: [PATCH 18/21] Add autocomplete for emotes closes #9 --- chatroom.go | 3 ++ common/constants.go | 1 + common/emotes.go | 6 ++- static/css/site.css | 24 +++++++---- wasm/main.go | 16 ++++++- wasm/suggestions.go | 100 ++++++++++++++++++++++++++++---------------- 6 files changed, 103 insertions(+), 47 deletions(-) diff --git a/chatroom.go b/chatroom.go index aebde3e..a72bd7a 100644 --- a/chatroom.go +++ b/chatroom.go @@ -125,6 +125,9 @@ func (cr *ChatRoom) Join(name, uid string) (*Client, error) { client.Send(playingCommand) } cr.AddEventMsg(common.EvJoin, name, client.color) + + client.SendChatData(common.NewChatHiddenMessage(common.CdEmote, common.Emotes)) + return client, nil } diff --git a/common/constants.go b/common/constants.go index 305ee9d..791a97d 100644 --- a/common/constants.go +++ b/common/constants.go @@ -9,6 +9,7 @@ const ( CdPing // ping the server to keep the connection alive CdAuth // get the auth levels of the user CdColor // get the users color + CdEmote // get a list of emotes ) type DataType int diff --git a/common/emotes.go b/common/emotes.go index 50db5ed..2f31c2d 100644 --- a/common/emotes.go +++ b/common/emotes.go @@ -8,6 +8,10 @@ import ( var Emotes map[string]string +func EmoteToHtml(file, title string) string { + return fmt.Sprintf(``, file, title) +} + func ParseEmotesArray(words []string) []string { newWords := []string{} for _, word := range words { @@ -17,7 +21,7 @@ func ParseEmotesArray(words []string) []string { found := false for key, val := range Emotes { if key == word { - newWords = append(newWords, fmt.Sprintf(``, val, key)) + newWords = append(newWords, EmoteToHtml(val, key)) found = true } } diff --git a/static/css/site.css b/static/css/site.css index f260cd3..1b3c489 100644 --- a/static/css/site.css +++ b/static/css/site.css @@ -255,14 +255,6 @@ span.svmsg { display: grid; } -#suggestions { - background: #3b3b43; - position: absolute; - min-width: 10em; - border-radius: 5px 5px 0px 5px; - color: var(--var-message-color); -} - #msg { background: transparent; border: var(--var-border); @@ -273,10 +265,24 @@ span.svmsg { resize: none; } -#suggestions div { +#suggestions { + background: #3b3b43; + position: absolute; + min-width: 10em; + border-radius: 5px 5px 0px 5px; + color: var(--var-message-color); +} + +#suggestions>div { + display: flex; + align-items: center; padding: 5px; } +#suggestions>div>img { + margin-right: 1em; +} + #suggestions div.selectedName { color: var(--var-contrast-color); } diff --git a/wasm/main.go b/wasm/main.go index 8e91dea..5df650b 100644 --- a/wasm/main.go +++ b/wasm/main.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "sort" "strings" "time" @@ -43,11 +44,21 @@ func recieve(v []js.Value) { for _, i := range h.Data.([]interface{}) { names = append(names, i.(string)) } + sort.Strings(names) case common.CdAuth: auth = h.Data.(common.CommandLevel) case common.CdColor: color = h.Data.(string) js.Get("document").Set("cookie", fmt.Sprintf("color=%s;", color)) + case common.CdEmote: + data := h.Data.(map[string]interface{}) + emoteNames = make([]string, 0, len(data)) + emotes = make(map[string]string) + for k, v := range data { + emoteNames = append(emoteNames, k) + emotes[k] = v.(string) + } + sort.Strings(emoteNames) } case common.DTEvent: d := chat.Data.(common.DataEvent) @@ -161,9 +172,10 @@ func debugValues(v []js.Value) { fmt.Printf("timestamp: %#v\n", timestamp) fmt.Printf("auth: %#v\n", auth) fmt.Printf("color: %#v\n", color) - fmt.Printf("currentName: %#v\n", currentName) + fmt.Printf("currentSuggestion: %#v\n", currentSug) + fmt.Printf("filteredSuggestions: %#v\n", filteredSug) fmt.Printf("names: %#v\n", names) - fmt.Printf("filteredNames: %#v\n", filteredNames) + fmt.Printf("emoteNames: %#v\n", emoteNames) } func main() { diff --git a/wasm/suggestions.go b/wasm/suggestions.go index 8757370..ecfdf94 100644 --- a/wasm/suggestions.go +++ b/wasm/suggestions.go @@ -4,24 +4,30 @@ import ( "strings" "github.com/dennwc/dom/js" + "github.com/zorchenhimer/MovieNight/common" ) const ( - keyTab = 9 - keyEnter = 13 - keyUp = 38 - keyDown = 40 + keyTab = 9 + keyEnter = 13 + keyUp = 38 + keyDown = 40 + suggestionName = '@' + suggestionEmote = ':' ) var ( - currentName string - names []string - filteredNames []string + currentSugType rune + currentSug string + filteredSug []string + names []string + emoteNames []string + emotes map[string]string ) // The returned value is a bool deciding to prevent the event from propagating func processMessageKey(this js.Value, v []js.Value) interface{} { - if len(filteredNames) == 0 || currentName == "" { + if len(filteredSug) == 0 || currentSug == "" { return false } @@ -30,12 +36,12 @@ func processMessageKey(this js.Value, v []js.Value) interface{} { switch keyCode { case keyUp, keyDown: newidx := 0 - for i, n := range filteredNames { - if n == currentName { + for i, n := range filteredSug { + if n == currentSug { newidx = i if keyCode == keyDown { newidx = i + 1 - if newidx == len(filteredNames) { + if newidx == len(filteredSug) { newidx-- } } else if keyCode == keyUp { @@ -47,14 +53,19 @@ func processMessageKey(this js.Value, v []js.Value) interface{} { break } } - currentName = filteredNames[newidx] + currentSug = filteredSug[newidx] case keyTab, keyEnter: msg := js.Get("msg") val := msg.Get("value").String() newval := val[:startIdx] - if i := strings.LastIndex(newval, "@"); i != -1 { - newval = newval[:i+1] + currentName + if i := strings.LastIndex(newval, string(currentSugType)); i != -1 { + var offset int + if currentSugType == suggestionName { + offset = 1 + } + + newval = newval[:i+offset] + currentSug } endVal := val[startIdx:] @@ -67,7 +78,7 @@ func processMessageKey(this js.Value, v []js.Value) interface{} { msg.Set("selectionEnd", len(newval)+1) // Clear out filtered names since it is no longer needed - filteredNames = nil + filteredSug = nil default: // We only want to handle the caught keys, so return early return false @@ -82,9 +93,9 @@ func processMessage(v []js.Value) { text := strings.ToLower(msg.Get("value").String()) startIdx := msg.Get("selectionStart").Int() - filteredNames = nil + filteredSug = nil if len(text) != 0 { - if len(names) > 0 { + if len(names) > 0 || len(emoteNames) > 0 { var caretIdx int textParts := strings.Split(text, " ") @@ -97,18 +108,29 @@ func processMessage(v []js.Value) { // It is possible to have a double space " ", which will lead to an // empty string element in the slice. Also check that the index of the // cursor is between the start of the word and the end - if len(word) > 0 && word[0] == '@' && - caretIdx <= startIdx && startIdx <= caretIdx+len(word) { - // fill filtered first so the "modifier" keys can modify it - for _, n := range names { - if len(word) == 1 || strings.HasPrefix(strings.ToLower(n), word[1:]) { - filteredNames = append(filteredNames, n) + if len(word) > 0 && caretIdx <= startIdx && startIdx <= caretIdx+len(word) { + var suggestions []string + if word[0] == suggestionName { + currentSugType = suggestionName + suggestions = names + } else if word[0] == suggestionEmote { + suggestions = emoteNames + currentSugType = suggestionEmote + } + + for _, s := range suggestions { + if len(word) == 1 || strings.Contains(strings.ToLower(s), word[1:]) { + filteredSug = append(filteredSug, s) + } + + if len(filteredSug) > 10 { + break } } } - if len(filteredNames) > 0 { - currentName = "" + if len(filteredSug) > 0 { + currentSug = "" break } @@ -124,26 +146,34 @@ func updateSuggestionDiv() { const selectedClass = ` class="selectedName"` var divs []string - if len(filteredNames) > 0 { + if len(filteredSug) > 0 { // set current name to first if not set already - if currentName == "" { - currentName = filteredNames[0] + if currentSug == "" { + currentSug = filteredSug[0] } - var hasCurrentName bool - divs = make([]string, len(filteredNames)) + var hascurrentSuggestion bool + divs = make([]string, len(filteredSug)) // Create inner body of html - for i := range filteredNames { + for i := range filteredSug { divs[i] = "" + divs[i] += ">" + + if currentSugType == suggestionEmote { + divs[i] += common.EmoteToHtml(emotes[sug], sug) + } + + divs[i] += sug + "
" } - if !hasCurrentName { + if !hascurrentSuggestion { divs[0] = divs[0][:4] + selectedClass + divs[0][4:] } } From 67b3143893a59a397db5be89a5455fef0ab36659 Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Sat, 30 Mar 2019 15:39:04 -0400 Subject: [PATCH 19/21] Add rate limiting This should fix #65, although it may be expanded in the future. Default settings for limits can be changed or disabled in the settings. Default values: - Chat: 1 second - Duplicate chat: 30 seconds - /nick: 5 minutes - /auth: 5 seconds - /color: 1 minute Creation of the chat Client object has been moved from ChatRoom.Join() to NewClient(). This function also handles setting the initial name. --- chatclient.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ chatcommands.go | 21 +++++++++++++++++ chatroom.go | 11 ++------- settings.go | 43 +++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 9 deletions(-) diff --git a/chatclient.go b/chatclient.go index 5c0c0c0..6cdc0b6 100644 --- a/chatclient.go +++ b/chatclient.go @@ -5,6 +5,7 @@ import ( "html" "regexp" "strings" + "time" "unicode" "github.com/zorchenhimer/MovieNight/common" @@ -25,6 +26,36 @@ type Client struct { IsColorForced bool IsNameForced bool regexName *regexp.Regexp + + // Times since last event. use time.Duration.Since() + nextChat time.Time // rate limit chat messages + nextNick time.Time // rate limit nickname changes + nextColor time.Time // rate limit color changes + nextAuth time.Time // rate limit failed auth attempts. Sould prolly have a backoff policy. + authTries int // number of failed auth attempts + + nextDuplicate time.Time + lastMsg string +} + +func NewClient(connection *chatConnection, room *ChatRoom, name, color string) (*Client, error) { + c := &Client{ + conn: connection, + belongsTo: room, + color: color, + } + + if err := c.setName(name); err != nil { + return nil, fmt.Errorf("could not set client name to %#v: %v", name, err) + } + + // Set initial vaules to their rate limit duration in the past. + c.nextChat = time.Now() + c.nextNick = time.Now() + c.nextColor = time.Now() + c.nextAuth = time.Now() + + return c, nil } //Client has a new message to broadcast @@ -85,11 +116,42 @@ func (cl *Client) NewMsg(data common.ClientData) { } } else { + // Limit the rate of sent chat messages. Ignore mods and admins + if time.Now().Before(cl.nextChat) && cl.CmdLevel == common.CmdlUser { + err := cl.SendChatData(common.NewChatMessage("", "", + "Slow down.", + common.CmdlUser, + common.MsgCommandResponse)) + if err != nil { + common.LogErrorf("Unable to send slowdown for chat: %v", err) + } + return + } + // Trim long messages if len(msg) > 400 { msg = msg[0:400] } + // Limit the rate of duplicate messages. Ignore mods and admins. + // Only checks the last message. + if strings.TrimSpace(strings.ToLower(msg)) == cl.lastMsg && + time.Now().Before(cl.nextDuplicate) && + cl.CmdLevel == common.CmdlUser { + err := cl.SendChatData(common.NewChatMessage("", "", + common.ParseEmotes("You already sent that PeepoSus"), + common.CmdlUser, + common.MsgCommandResponse)) + if err != nil { + common.LogErrorf("Unable to send slowdown for chat: %v", err) + } + return + } + + cl.nextChat = time.Now().Add(time.Second * settings.RateLimitChat) + cl.nextDuplicate = time.Now().Add(time.Second * settings.RateLimitDuplicate) + cl.lastMsg = strings.TrimSpace(strings.ToLower(msg)) + common.LogChatf("[chat] <%s> %q\n", cl.name, msg) // Enable links for mods and admins @@ -188,6 +250,7 @@ func (cl *Client) setName(s string) error { cl.name = s cl.regexName = regex + cl.conn.clientName = s return nil } diff --git a/chatcommands.go b/chatcommands.go index 6678cec..42761d8 100644 --- a/chatcommands.go +++ b/chatcommands.go @@ -4,6 +4,7 @@ import ( "fmt" "html" "strings" + "time" "github.com/zorchenhimer/MovieNight/common" ) @@ -126,6 +127,10 @@ var commands = &CommandControl{ return "You are not allowed to change your color." } + if time.Now().Before(cl.nextColor) && cl.CmdLevel == common.CmdlUser { + return fmt.Sprintf("Slow down. You can change your color in %0.0f seconds.", time.Until(cl.nextColor).Seconds()) + } + if len(args) == 0 { cl.setColor(common.RandomColor()) return "Random color chosen: " + cl.color @@ -136,6 +141,8 @@ var commands = &CommandControl{ return "To choose a specific color use the format /color #c029ce. Hex values expected." } + cl.nextColor = time.Now().Add(time.Second * settings.RateLimitColor) + err := cl.setColor(args[0]) if err != nil { common.LogErrorf("[color] could not send color update to client: %v\n", err) @@ -163,6 +170,14 @@ var commands = &CommandControl{ return "You are already authenticated." } + // TODO: handle backoff policy + if time.Now().Before(cl.nextAuth) { + cl.nextAuth = time.Now().Add(time.Second * settings.RateLimitAuth) + return "Slow down." + } + cl.authTries += 1 // this isn't used yet + cl.nextAuth = time.Now().Add(time.Second * settings.RateLimitAuth) + pw := html.UnescapeString(strings.Join(args, " ")) if settings.AdminPassword == pw { @@ -196,6 +211,12 @@ var commands = &CommandControl{ common.CNNick.String(): Command{ HelpText: "Change display name", Function: func(cl *Client, args []string) string { + if time.Now().Before(cl.nextNick) { + //cl.nextNick = time.Now().Add(time.Second * settings.RateLimitNick) + return fmt.Sprintf("Slow down. You can change your nick in %0.0f seconds.", time.Until(cl.nextNick).Seconds()) + } + cl.nextNick = time.Now().Add(time.Second * settings.RateLimitNick) + if len(args) == 0 { return "Missing name to change to." } diff --git a/chatroom.go b/chatroom.go index a72bd7a..dc5fe56 100644 --- a/chatroom.go +++ b/chatroom.go @@ -96,16 +96,9 @@ func (cr *ChatRoom) Join(name, uid string) (*Client, error) { } } - conn.clientName = name - client := &Client{ - conn: conn, - belongsTo: cr, - color: common.RandomColor(), - } - - err := client.setName(name) + client, err := NewClient(conn, cr, name, common.RandomColor()) if err != nil { - return nil, fmt.Errorf("could not set client name to %#v: %v", name, err) + return nil, fmt.Errorf("Unable to join client: %v", err) } host := client.Host() diff --git a/settings.go b/settings.go index 7c27f3c..9955824 100644 --- a/settings.go +++ b/settings.go @@ -33,6 +33,13 @@ type Settings struct { LogLevel common.LogLevel LogFile string + // Rate limiting stuff, in seconds + RateLimitChat time.Duration + RateLimitNick time.Duration + RateLimitColor time.Duration + RateLimitAuth time.Duration + RateLimitDuplicate time.Duration // Amount of seconds between allowed duplicate messages + // Send the NoCache header? NoCache bool } @@ -72,6 +79,42 @@ func LoadSettings(filename string) (*Settings, error) { return nil, fmt.Errorf("unable to generate admin password: %s", err) } + if s.RateLimitChat == -1 { + s.RateLimitChat = 0 + } else if s.RateLimitChat <= 0 { + s.RateLimitChat = 1 + } + + if s.RateLimitNick == -1 { + s.RateLimitNick = 0 + } else if s.RateLimitNick <= 0 { + s.RateLimitNick = 300 + } + + if s.RateLimitColor == -1 { + s.RateLimitColor = 0 + } else if s.RateLimitColor <= 0 { + s.RateLimitColor = 60 + } + + if s.RateLimitAuth == -1 { + s.RateLimitAuth = 0 + } else if s.RateLimitAuth <= 0 { + s.RateLimitAuth = 5 + } + + if s.RateLimitDuplicate == -1 { + s.RateLimitDuplicate = 0 + } else if s.RateLimitDuplicate <= 0 { + s.RateLimitDuplicate = 30 + } + + // Print this stuff before we multiply it by time.Second + common.LogInfof("RateLimitChat: %v", s.RateLimitChat) + common.LogInfof("RateLimitNick: %v", s.RateLimitNick) + common.LogInfof("RateLimitColor: %v", s.RateLimitColor) + common.LogInfof("RateLimitAuth: %v", s.RateLimitAuth) + // Don't use LogInfof() here. Log isn't setup yet when LoadSettings() is called from init(). fmt.Printf("Settings reloaded. New admin password: %s\n", s.AdminPassword) From 0ff872065f1853fb90da25df4e83b957871303f5 Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Sat, 30 Mar 2019 15:40:21 -0400 Subject: [PATCH 20/21] Fix trimming characters on non-emote words Don't trim anything from non-emote words when parsing emotes. --- common/emotes.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/emotes.go b/common/emotes.go index 2f31c2d..35f330d 100644 --- a/common/emotes.go +++ b/common/emotes.go @@ -16,11 +16,11 @@ func ParseEmotesArray(words []string) []string { newWords := []string{} for _, word := range words { // make :emote: and [emote] valid for replacement. - word = strings.Trim(word, ":[]") + wordTrimmed := strings.Trim(word, ":[]") found := false for key, val := range Emotes { - if key == word { + if key == wordTrimmed { newWords = append(newWords, EmoteToHtml(val, key)) found = true } From cc3da4292e45cc2302b33f328b9bfbf2e8164b92 Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Sat, 30 Mar 2019 15:55:59 -0400 Subject: [PATCH 21/21] Update settings_example.json with new values This hasn't been updated in a while. Oops. --- settings_example.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/settings_example.json b/settings_example.json index 3153988..b284db3 100644 --- a/settings_example.json +++ b/settings_example.json @@ -5,4 +5,14 @@ "Bans": [], "StreamKey": "ALongStreamKey", "ListenAddress": ":8089" + "ApprovedEmotes": null, + "Bans": [], + "LogLevel": "debug", + "LogFile": "thelog.log", + "RateLimitChat": 1, + "RateLimitNick": 300, + "RateLimitColor": 60, + "RateLimitAuth": 5, + "RateLimitDuplicate": 30, + "NoCache": false }