Merge branch 'master' into room-access-restrictions
# Conflicts: # common/chatcommands.go # main.go # settings.go
This commit is contained in:
commit
4e35418a79
|
@ -35,3 +35,6 @@ static/main.wasm
|
|||
|
||||
# tags for vim
|
||||
tags
|
||||
|
||||
# channel and emote list from twitch
|
||||
subscribers.json
|
||||
|
|
|
@ -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
|
||||
|
@ -180,16 +242,23 @@ 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)
|
||||
}
|
||||
|
||||
cl.name = s
|
||||
cl.regexName = regex
|
||||
cl.conn.clientName = s
|
||||
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, `<span class="mention">$1</span>`)
|
||||
|
|
272
chatcommands.go
272
chatcommands.go
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zorchenhimer/MovieNight/common"
|
||||
)
|
||||
|
@ -45,9 +46,122 @@ 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 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
|
||||
}
|
||||
|
||||
// Change the color of the user
|
||||
if !common.IsValidColor(args[0]) {
|
||||
return "To choose a specific color use the format <i>/color #c029ce</i>. 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)
|
||||
}
|
||||
|
||||
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",
|
||||
|
@ -56,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 {
|
||||
|
@ -89,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."
|
||||
}
|
||||
|
@ -390,6 +518,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, ", ")
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -461,112 +620,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 <i>/color #c029ce</i>. 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)
|
||||
},
|
||||
}
|
||||
|
|
14
chatroom.go
14
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()
|
||||
|
@ -125,6 +118,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
|
||||
}
|
||||
|
||||
|
|
|
@ -36,17 +36,40 @@ var (
|
|||
CNReloadEmotes ChatCommandNames = []string{"reloademotes"}
|
||||
CNModpass ChatCommandNames = []string{"modpass"}
|
||||
CNIP ChatCommandNames = []string{"iplist"}
|
||||
CNAddEmotes ChatCommandNames = []string{"addemotes"}
|
||||
CNNewPin ChatCommandNames = []string{"newpin", "newpassword"}
|
||||
CNRoomAccess ChatCommandNames = []string{"changeaccess", "hodor"}
|
||||
)
|
||||
|
||||
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, CNPin,
|
||||
CNSv,
|
||||
CNPlaying,
|
||||
CNUnmod,
|
||||
CNKick,
|
||||
CNBan,
|
||||
CNUnban,
|
||||
CNPurge,
|
||||
|
||||
// Admin
|
||||
CNMod, CNReloadPlayer, CNReloadEmotes, CNModpass, CNRoomAccess, CNIP,
|
||||
CNMod,
|
||||
CNReloadPlayer,
|
||||
CNReloadEmotes,
|
||||
CNModpass,
|
||||
CNIP,
|
||||
CNAddEmotes,
|
||||
}
|
||||
|
||||
func GetFullChatCommand(c string) string {
|
||||
|
|
|
@ -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 `<div style="color: red;"><span>The developer messed up. You should not be seeing this.</span></div>`
|
||||
return `<span style="color: red;">The developer messed up. You should not be seeing this.</span>`
|
||||
}
|
||||
|
||||
type DataMessage struct {
|
||||
|
@ -93,20 +93,20 @@ type DataMessage struct {
|
|||
func (dc DataMessage) HTML() string {
|
||||
switch dc.Type {
|
||||
case MsgAction:
|
||||
return `<div style="color:` + dc.Color + `"><span class="name">` + dc.From +
|
||||
`</span> <span class="cmdme">` + dc.Message + `</span></div>`
|
||||
return `<span style="color:` + dc.Color + `"><span class="name">` + dc.From +
|
||||
`</span> <span class="cmdme">` + dc.Message + `</span></span>`
|
||||
|
||||
case MsgServer:
|
||||
return `<div class="announcement">` + dc.Message + `</div>`
|
||||
return `<span class="announcement">` + dc.Message + `</span>`
|
||||
|
||||
case MsgError:
|
||||
return `<div class="error">` + dc.Message + `</div>`
|
||||
return `<span class="error">` + dc.Message + `</span>`
|
||||
|
||||
case MsgNotice:
|
||||
return `<div class="notice">` + dc.Message + `</div>`
|
||||
return `<span class="notice">` + dc.Message + `</span>`
|
||||
|
||||
case MsgCommandResponse:
|
||||
return `<div class="command">` + dc.Message + `</div>`
|
||||
return `<span class="command">` + dc.Message + `</span>`
|
||||
|
||||
default:
|
||||
badge := ""
|
||||
|
@ -116,8 +116,8 @@ func (dc DataMessage) HTML() string {
|
|||
case CmdlAdmin:
|
||||
badge = `<img src="/static/img/admin.png" class="badge" />`
|
||||
}
|
||||
return `<div>` + badge + `<span class="name" style="color:` + dc.Color + `">` + dc.From +
|
||||
`</span><b>:</b> <span class="msg">` + dc.Message + `</span></div>`
|
||||
return `<span>` + badge + `<span class="name" style="color:` + dc.Color + `">` + dc.From +
|
||||
`</span><b>:</b> <span class="msg">` + dc.Message + `</span></span>`
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -142,7 +142,7 @@ type DataCommand struct {
|
|||
func (de DataCommand) HTML() string {
|
||||
switch de.Command {
|
||||
case CmdPurgeChat:
|
||||
return `<div class="notice">Chat has been purged by a moderator.</div>`
|
||||
return `<span class="notice">Chat has been purged by a moderator.</span>`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
@ -167,37 +167,37 @@ type DataEvent struct {
|
|||
func (de DataEvent) HTML() string {
|
||||
switch de.Event {
|
||||
case EvKick:
|
||||
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
de.User + `</span> has been kicked.</div>`
|
||||
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
de.User + `</span> has been kicked.</span>`
|
||||
case EvLeave:
|
||||
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
de.User + `</span> has left the chat.</div>`
|
||||
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
de.User + `</span> has left the chat.</span>`
|
||||
case EvBan:
|
||||
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
de.User + `</span> has been banned.</div>`
|
||||
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
de.User + `</span> has been banned.</span>`
|
||||
case EvJoin:
|
||||
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
de.User + `</span> has joined the chat.</div>`
|
||||
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
de.User + `</span> has joined the chat.</span>`
|
||||
case EvNameChange:
|
||||
names := strings.Split(de.User, ":")
|
||||
if len(names) != 2 {
|
||||
return `<div class="event">Somebody changed their name, but IDK who ` +
|
||||
ParseEmotes("Jebaited") + `.</div>`
|
||||
return `<span class="event">Somebody changed their name, but IDK who ` +
|
||||
ParseEmotes("Jebaited") + `.</span>`
|
||||
}
|
||||
|
||||
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
names[0] + `</span> has changed their name to <span class="name" style="color:` +
|
||||
de.Color + `">` + names[1] + `</span>.</div>`
|
||||
de.Color + `">` + names[1] + `</span>.</span>`
|
||||
case EvNameChangeForced:
|
||||
names := strings.Split(de.User, ":")
|
||||
if len(names) != 2 {
|
||||
return `<div class="event">An admin changed somebody's name, but IDK who ` +
|
||||
ParseEmotes("Jebaited") + `.</div>`
|
||||
return `<span class="event">An admin changed somebody's name, but IDK who ` +
|
||||
ParseEmotes("Jebaited") + `.</span>`
|
||||
}
|
||||
|
||||
return `<div class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
return `<span class="event"><span class="name" style="color:` + de.Color + `">` +
|
||||
names[0] + `</span> has had their name changed to <span class="name" style="color:` +
|
||||
de.Color + `">` + names[1] + `</span> by an admin.</div>`
|
||||
de.Color + `">` + names[1] + `</span> by an admin.</span>`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -8,6 +8,8 @@ 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
|
||||
CdEmote // get a list of emotes
|
||||
)
|
||||
|
||||
type DataType int
|
||||
|
|
|
@ -8,15 +8,20 @@ import (
|
|||
|
||||
var Emotes map[string]string
|
||||
|
||||
func EmoteToHtml(file, title string) string {
|
||||
return fmt.Sprintf(`<img src="/emotes/%s" height="28px" title="%s" />`, file, title)
|
||||
}
|
||||
|
||||
func ParseEmotesArray(words []string) []string {
|
||||
newWords := []string{}
|
||||
for _, word := range words {
|
||||
word = strings.Trim(word, "[]")
|
||||
// make :emote: and [emote] valid for replacement.
|
||||
wordTrimmed := strings.Trim(word, ":[]")
|
||||
|
||||
found := false
|
||||
for key, val := range Emotes {
|
||||
if key == word {
|
||||
newWords = append(newWords, fmt.Sprintf(`<img src="/emotes/%s" height="28px" title="%s" />`, val, key))
|
||||
if key == wordTrimmed {
|
||||
newWords = append(newWords, EmoteToHtml(val, key))
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 int `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
|
||||
}
|
|
@ -64,7 +64,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")
|
||||
}
|
||||
|
||||
|
@ -333,7 +335,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 {
|
||||
|
|
34
main.go
34
main.go
|
@ -1,10 +1,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
@ -31,33 +29,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)
|
||||
}
|
||||
|
||||
// Is this a good way to do this? Probably not...
|
||||
if len(settings.SessionKey) == 0 {
|
||||
out := ""
|
||||
large := big.NewInt(int64(1 << 60))
|
||||
large = large.Add(large, large)
|
||||
for len(out) < 50 {
|
||||
num, err := rand.Int(rand.Reader, large)
|
||||
if err != nil {
|
||||
panic("Error generating session key: " + err.Error())
|
||||
}
|
||||
out = fmt.Sprintf("%s%X", out, num)
|
||||
}
|
||||
settings.SessionKey = out
|
||||
}
|
||||
|
||||
if len(settings.RoomAccess) == 0 {
|
||||
settings.RoomAccess = AccessOpen
|
||||
}
|
||||
|
||||
if settings.RoomAccess != AccessOpen && len(settings.RoomAccessPin) == 0 {
|
||||
settings.RoomAccessPin = "1234"
|
||||
}
|
||||
|
||||
sstore = sessions.NewCookieStore([]byte(settings.SessionKey))
|
||||
sstore.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
|
@ -65,11 +36,6 @@ func setupSettings() error {
|
|||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
|
||||
// Save admin password to file
|
||||
if err = settings.Save(); err != nil {
|
||||
return fmt.Errorf("Unable to save settings: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
85
settings.go
85
settings.go
|
@ -30,12 +30,23 @@ type Settings struct {
|
|||
AdminPassword string
|
||||
StreamKey string
|
||||
ListenAddress string
|
||||
SessionKey string // key for session data
|
||||
ApprovedEmotes []EmoteSet // list of channels that have been approved for emote use. Global emotes are always "approved".
|
||||
SessionKey string // key for session data
|
||||
Bans []BanInfo
|
||||
LogLevel common.LogLevel
|
||||
LogFile string
|
||||
RoomAccess AccessMode
|
||||
RoomAccessPin string // auto generate this,
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
type AccessMode string
|
||||
|
@ -65,6 +76,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
|
||||
|
@ -77,6 +92,50 @@ 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)
|
||||
|
||||
if len(settings.RoomAccess) == 0 {
|
||||
settings.RoomAccess = AccessOpen
|
||||
}
|
||||
|
||||
if settings.RoomAccess != AccessOpen && len(settings.RoomAccessPin) == 0 {
|
||||
settings.RoomAccessPin = "1234"
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
|
@ -84,6 +143,26 @@ func LoadSettings(filename string) (*Settings, error) {
|
|||
s.TitleLength = 50
|
||||
}
|
||||
|
||||
// Is this a good way to do this? Probably not...
|
||||
if len(settings.SessionKey) == 0 {
|
||||
out := ""
|
||||
large := big.NewInt(int64(1 << 60))
|
||||
large = large.Add(large, large)
|
||||
for len(out) < 50 {
|
||||
num, err := rand.Int(rand.Reader, large)
|
||||
if err != nil {
|
||||
panic("Error generating session key: " + err.Error())
|
||||
}
|
||||
out = fmt.Sprintf("%s%X", out, num)
|
||||
}
|
||||
settings.SessionKey = out
|
||||
}
|
||||
|
||||
// Save admin password to file
|
||||
if err = settings.Save(); err != nil {
|
||||
return nil, fmt.Errorf("Unable to save settings: %s", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
@ -178,10 +257,6 @@ func (s *Settings) GetStreamKey() string {
|
|||
return s.StreamKey
|
||||
}
|
||||
|
||||
func (s *Settings) SetupLogging() error {
|
||||
return common.SetupLogging(s.LogLevel, s.LogFile)
|
||||
}
|
||||
|
||||
func (s *Settings) generateNewPin() (string, error) {
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(9999)))
|
||||
if err != nil {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
</head>
|
||||
|
||||
<body class="scrollbar">
|
||||
<img id="remote" src="/static/img/remote.png" style="display: none;" onclick="flipRemote();" />
|
||||
<div class="root">
|
||||
{{template "body" .}}
|
||||
</div>
|
||||
|
|
|
@ -238,6 +238,7 @@ span.svmsg {
|
|||
grid-gap: 10px;
|
||||
margin: 0px 5px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#messages {
|
||||
|
@ -258,14 +259,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);
|
||||
|
@ -276,10 +269,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);
|
||||
}
|
||||
|
@ -290,4 +297,14 @@ span.svmsg {
|
|||
|
||||
#colorSubmit:disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#remote {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
margin: 1em auto;
|
||||
width: 50px;
|
||||
z-index: 999;
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.1 KiB |
|
@ -1,8 +1,38 @@
|
|||
/// <reference path="./jquery.js" />
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,21 @@
|
|||
/// <reference path="./both.js" />
|
||||
|
||||
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);
|
||||
|
@ -50,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() {
|
||||
|
@ -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) {
|
||||
|
@ -117,14 +141,14 @@ function setNotifyBox(msg = "") {
|
|||
// Button Wrapper Functions
|
||||
function auth() {
|
||||
let pass = prompt("Enter pass");
|
||||
if (pass != "") {
|
||||
if (pass != "" && pass !== null) {
|
||||
sendMessage("/auth " + pass);
|
||||
}
|
||||
}
|
||||
|
||||
function nick() {
|
||||
let nick = prompt("Enter new name");
|
||||
if (nick != "") {
|
||||
if (nick != "" && nick !== null) {
|
||||
sendMessage("/nick " + nick);
|
||||
}
|
||||
}
|
||||
|
@ -169,6 +193,10 @@ 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() {
|
||||
|
@ -220,9 +248,12 @@ function setupEvents() {
|
|||
}
|
||||
|
||||
function defaultValues() {
|
||||
$("#colorRed").val(0).trigger("input");
|
||||
$("#colorGreen").val(0).trigger("input");
|
||||
$("#colorBlue").val(0).trigger("input");
|
||||
setTimeout(() => {
|
||||
let timestamp = getCookie("timestamp")
|
||||
if (timestamp !== "") {
|
||||
showTimestamp(timestamp === "true")
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
window.addEventListener("onresize", updateSuggestionCss);
|
||||
|
@ -270,4 +301,4 @@ function pleaseremovethis() {
|
|||
"tomato", "turquoise", "violet", "wheat", "white",
|
||||
"whitesmoke", "yellow", "yellowgreen",]
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,6 +57,11 @@
|
|||
{{end}}
|
||||
</div>
|
||||
<hr />
|
||||
<label class="contrast">
|
||||
<input type="checkbox" checked="false" onchange="setTimestamp(this.checked);" />
|
||||
Show Timestamp
|
||||
</label>
|
||||
<hr />
|
||||
<div id="hiddencolor" class="hiddendiv">
|
||||
<div class="range-div" style="background-image: linear-gradient(to right, transparent, red);">
|
||||
<input id="colorRed" type="range" min="0" max="255" value="0" oninput="updateColor();" />
|
||||
|
|
62
wasm/main.go
62
wasm/main.go
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -10,7 +11,11 @@ import (
|
|||
"github.com/zorchenhimer/MovieNight/common"
|
||||
)
|
||||
|
||||
var auth common.CommandLevel
|
||||
var (
|
||||
timestamp bool
|
||||
color string
|
||||
auth common.CommandLevel
|
||||
)
|
||||
|
||||
func recieve(v []js.Value) {
|
||||
if len(v) == 0 {
|
||||
|
@ -21,7 +26,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("<div>%v</div>", v))
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -39,8 +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)
|
||||
|
@ -51,7 +69,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)
|
||||
|
||||
|
@ -70,18 +88,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(`<span class="time">%02d:%02d</span> %s`, h, m, msg)
|
||||
}
|
||||
js.Call("appendMessages", "<div>"+msg+"</div>")
|
||||
}
|
||||
|
||||
func websocketSend(msg string, dataType common.ClientDataType) error {
|
||||
if strings.TrimSpace(msg) == "" && dataType == common.CdMessage {
|
||||
return nil
|
||||
|
@ -101,25 +127,33 @@ 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", `<div><span style="color: red;">Could not send message</span></div>`)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -135,10 +169,13 @@ 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("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("currentSuggestion: %#v\n", currentSug)
|
||||
fmt.Printf("filteredSuggestions: %#v\n", filteredSug)
|
||||
fmt.Printf("names: %#v\n", names)
|
||||
fmt.Printf("emoteNames: %#v\n", emoteNames)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
@ -150,6 +187,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 {
|
||||
|
|
|
@ -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] = "<div"
|
||||
if filteredNames[i] == currentName {
|
||||
hasCurrentName = true
|
||||
|
||||
sug := filteredSug[i]
|
||||
if sug == currentSug {
|
||||
hascurrentSuggestion = true
|
||||
divs[i] += selectedClass
|
||||
}
|
||||
divs[i] += ">" + filteredNames[i] + "</div>"
|
||||
divs[i] += ">"
|
||||
|
||||
if currentSugType == suggestionEmote {
|
||||
divs[i] += common.EmoteToHtml(emotes[sug], sug)
|
||||
}
|
||||
|
||||
divs[i] += sug + "</div>"
|
||||
}
|
||||
|
||||
if !hasCurrentName {
|
||||
if !hascurrentSuggestion {
|
||||
divs[0] = divs[0][:4] + selectedClass + divs[0][4:]
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue