mirror of
https://github.com/shazow/ssh-chat.git
synced 2025-04-12 15:17:16 +03:00
Fixes #402 When the user is not set as away, using the `/back` or `/away` command should return error. The previous behaviour was inconsistent, `/away` sent a message and `/back` ignored it. New behaviour is error for both cases. Co-authored-by: Akshay <akshay.shekher@gmail.com>
530 lines
13 KiB
Go
530 lines
13 KiB
Go
package chat
|
|
|
|
// FIXME: Would be sweet if we could piggyback on a cli parser or something.
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shazow/ssh-chat/chat/message"
|
|
"github.com/shazow/ssh-chat/internal/sanitize"
|
|
"github.com/shazow/ssh-chat/set"
|
|
)
|
|
|
|
// ErrInvalidCommand is the error returned when an invalid command is issued.
|
|
var ErrInvalidCommand = errors.New("invalid command")
|
|
|
|
// ErrNoOwner is the error returned when a command is given without an owner.
|
|
var ErrNoOwner = errors.New("command without owner")
|
|
|
|
// ErrMissingArg is the error returned when a command is performed without the necessary
|
|
// number of arguments.
|
|
var ErrMissingArg = errors.New("missing argument")
|
|
|
|
// ErrMissingPrefix is the error returned when a command is added without a prefix.
|
|
var ErrMissingPrefix = errors.New("command missing prefix")
|
|
|
|
// Command is a definition of a handler for a command.
|
|
type Command struct {
|
|
Prefix string // The command's key, such as /foo
|
|
PrefixHelp string // Extra help regarding arguments
|
|
Help string // help text, if omitted, command is hidden from /help
|
|
Op bool // does the command require Op permissions?
|
|
|
|
// Handler for the command
|
|
Handler func(*Room, message.CommandMsg) error
|
|
}
|
|
|
|
// Commands is a registry of available commands.
|
|
type Commands map[string]*Command
|
|
|
|
// Add will register a command. If help string is empty, it will be hidden from
|
|
// Help().
|
|
func (c Commands) Add(cmd Command) error {
|
|
if cmd.Prefix == "" {
|
|
return ErrMissingPrefix
|
|
}
|
|
|
|
c[cmd.Prefix] = &cmd
|
|
return nil
|
|
}
|
|
|
|
// Alias will add another command for the same handler, won't get added to help.
|
|
func (c Commands) Alias(command string, alias string) error {
|
|
cmd, ok := c[command]
|
|
if !ok {
|
|
return ErrInvalidCommand
|
|
}
|
|
c[alias] = cmd
|
|
return nil
|
|
}
|
|
|
|
// Run executes a command message.
|
|
func (c Commands) Run(room *Room, msg message.CommandMsg) error {
|
|
if msg.From() == nil {
|
|
return ErrNoOwner
|
|
}
|
|
|
|
cmd, ok := c[msg.Command()]
|
|
if !ok {
|
|
return ErrInvalidCommand
|
|
}
|
|
|
|
return cmd.Handler(room, msg)
|
|
}
|
|
|
|
// Help will return collated help text as one string.
|
|
func (c Commands) Help(showOp bool) string {
|
|
// Filter by op
|
|
op := []*Command{}
|
|
normal := []*Command{}
|
|
for _, cmd := range c {
|
|
if cmd.Op {
|
|
op = append(op, cmd)
|
|
} else {
|
|
normal = append(normal, cmd)
|
|
}
|
|
}
|
|
help := "Available commands:" + message.Newline + NewCommandsHelp(normal).String()
|
|
if showOp {
|
|
help += message.Newline + "-> Operator commands:" + message.Newline + NewCommandsHelp(op).String()
|
|
}
|
|
return help
|
|
}
|
|
|
|
var timeformatDatetime = "2006-01-02 15:04:05"
|
|
|
|
var timeformatTime = "15:04"
|
|
|
|
var defaultCommands *Commands
|
|
|
|
func init() {
|
|
defaultCommands = &Commands{}
|
|
InitCommands(defaultCommands)
|
|
}
|
|
|
|
// InitCommands injects default commands into a Commands registry.
|
|
func InitCommands(c *Commands) {
|
|
c.Add(Command{
|
|
Prefix: "/help",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
op := room.IsOp(msg.From())
|
|
room.Send(message.NewSystemMsg(room.commands.Help(op), msg.From()))
|
|
return nil
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/me",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
me := strings.TrimLeft(msg.Body(), "/me")
|
|
if me == "" {
|
|
me = "is at a loss for words."
|
|
} else {
|
|
me = me[1:]
|
|
}
|
|
|
|
room.Send(message.NewEmoteMsg(me, msg.From()))
|
|
return nil
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/exit",
|
|
Help: "Exit the chat.",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
msg.From().Close()
|
|
return nil
|
|
},
|
|
})
|
|
c.Alias("/exit", "/quit")
|
|
|
|
c.Add(Command{
|
|
Prefix: "/nick",
|
|
PrefixHelp: "NAME",
|
|
Help: "Rename yourself.",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
args := msg.Args()
|
|
if len(args) != 1 {
|
|
return ErrMissingArg
|
|
}
|
|
u := msg.From()
|
|
|
|
member, ok := room.MemberByID(u.ID())
|
|
if !ok {
|
|
return errors.New("failed to find member")
|
|
}
|
|
|
|
oldID := member.ID()
|
|
newID := sanitize.Name(args[0])
|
|
if newID == oldID {
|
|
return errors.New("new name is the same as the original")
|
|
}
|
|
member.SetID(newID)
|
|
err := room.Rename(oldID, member)
|
|
if err != nil {
|
|
member.SetID(oldID)
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/names",
|
|
Help: "List users who are connected.",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
theme := msg.From().Config().Theme
|
|
|
|
colorize := func(u *message.User) string {
|
|
return theme.ColorName(u)
|
|
}
|
|
|
|
if theme == nil {
|
|
colorize = func(u *message.User) string {
|
|
return u.Name()
|
|
}
|
|
}
|
|
|
|
names := room.Members.ListPrefix("")
|
|
sort.Slice(names, func(i, j int) bool { return names[i].Key() < names[j].Key() })
|
|
colNames := make([]string, len(names))
|
|
for i, uname := range names {
|
|
colNames[i] = colorize(uname.Value().(*Member).User)
|
|
}
|
|
|
|
body := fmt.Sprintf("%d connected: %s", len(colNames), strings.Join(colNames, ", "))
|
|
room.Send(message.NewSystemMsg(body, msg.From()))
|
|
return nil
|
|
},
|
|
})
|
|
c.Alias("/names", "/list")
|
|
|
|
c.Add(Command{
|
|
Prefix: "/theme",
|
|
PrefixHelp: "[colors|...]",
|
|
Help: "Set your color theme.",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
user := msg.From()
|
|
args := msg.Args()
|
|
cfg := user.Config()
|
|
if len(args) == 0 {
|
|
theme := "plain"
|
|
if cfg.Theme != nil {
|
|
theme = cfg.Theme.ID()
|
|
}
|
|
var output strings.Builder
|
|
fmt.Fprintf(&output, "Current theme: %s%s", theme, message.Newline)
|
|
fmt.Fprintf(&output, " Themes available: ")
|
|
|
|
for i, t := range message.Themes {
|
|
output.WriteString(t.ID())
|
|
if i < len(message.Themes)-1 {
|
|
output.WriteString(", ")
|
|
}
|
|
}
|
|
room.Send(message.NewSystemMsg(output.String(), user))
|
|
return nil
|
|
}
|
|
|
|
id := args[0]
|
|
for _, t := range message.Themes {
|
|
if t.ID() == id {
|
|
cfg.Theme = &t
|
|
user.SetConfig(cfg)
|
|
body := fmt.Sprintf("Set theme: %s", id)
|
|
room.Send(message.NewSystemMsg(body, user))
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("theme not found")
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/quiet",
|
|
Help: "Silence room announcements.",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
u := msg.From()
|
|
cfg := u.Config()
|
|
cfg.Quiet = !cfg.Quiet
|
|
u.SetConfig(cfg)
|
|
|
|
var body string
|
|
if cfg.Quiet {
|
|
body = "Quiet mode is toggled ON"
|
|
} else {
|
|
body = "Quiet mode is toggled OFF"
|
|
}
|
|
room.Send(message.NewSystemMsg(body, u))
|
|
return nil
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/slap",
|
|
PrefixHelp: "NAME",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
var me string
|
|
args := msg.Args()
|
|
if len(args) == 0 {
|
|
me = "slaps themselves around a bit with a large trout."
|
|
} else {
|
|
me = fmt.Sprintf("slaps %s around a bit with a large trout.", strings.Join(args, " "))
|
|
}
|
|
|
|
room.Send(message.NewEmoteMsg(me, msg.From()))
|
|
return nil
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/shrug",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
room.Send(message.NewEmoteMsg(`¯\_(ツ)_/¯`, msg.From()))
|
|
return nil
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/timestamp",
|
|
PrefixHelp: "[time|datetime]",
|
|
Help: "Prefix messages with a timestamp. You can also provide the UTC offset: /timestamp time +5h45m",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
u := msg.From()
|
|
cfg := u.Config()
|
|
|
|
args := msg.Args()
|
|
mode := ""
|
|
if len(args) >= 1 {
|
|
mode = args[0]
|
|
}
|
|
if len(args) >= 2 {
|
|
// FIXME: This is an annoying format to demand from users, but
|
|
// hopefully we can make it a non-primary flow if we add GeoIP
|
|
// someday.
|
|
offset, err := time.ParseDuration(args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Timezone = time.FixedZone("", int(offset.Seconds()))
|
|
}
|
|
|
|
switch mode {
|
|
case "time":
|
|
cfg.Timeformat = &timeformatTime
|
|
case "datetime":
|
|
cfg.Timeformat = &timeformatDatetime
|
|
case "":
|
|
// Toggle
|
|
if cfg.Timeformat != nil {
|
|
cfg.Timeformat = nil
|
|
} else {
|
|
cfg.Timeformat = &timeformatTime
|
|
}
|
|
case "off":
|
|
cfg.Timeformat = nil
|
|
default:
|
|
return errors.New("timestamp value must be one of: time, datetime, off")
|
|
}
|
|
|
|
u.SetConfig(cfg)
|
|
|
|
var body string
|
|
if cfg.Timeformat != nil {
|
|
if cfg.Timezone != nil {
|
|
tzname := time.Now().In(cfg.Timezone).Format("MST")
|
|
body = fmt.Sprintf("Timestamp is toggled ON, timezone is %q", tzname)
|
|
} else {
|
|
body = "Timestamp is toggled ON, timezone is UTC"
|
|
}
|
|
} else {
|
|
body = "Timestamp is toggled OFF"
|
|
}
|
|
room.Send(message.NewSystemMsg(body, u))
|
|
return nil
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/ignore",
|
|
PrefixHelp: "[USER]",
|
|
Help: "Hide messages from USER, /unignore USER to stop hiding.",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
id := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/ignore"))
|
|
if id == "" {
|
|
// Print ignored names, if any.
|
|
var names []string
|
|
msg.From().Ignored.Each(func(_ string, item set.Item) error {
|
|
names = append(names, item.Key())
|
|
return nil
|
|
})
|
|
|
|
var systemMsg string
|
|
if len(names) == 0 {
|
|
systemMsg = "0 users ignored."
|
|
} else {
|
|
systemMsg = fmt.Sprintf("%d ignored: %s", len(names), strings.Join(names, ", "))
|
|
}
|
|
|
|
room.Send(message.NewSystemMsg(systemMsg, msg.From()))
|
|
return nil
|
|
}
|
|
|
|
if id == msg.From().ID() {
|
|
return errors.New("cannot ignore self")
|
|
}
|
|
target, ok := room.MemberByID(id)
|
|
if !ok {
|
|
return fmt.Errorf("user not found: %s", id)
|
|
}
|
|
|
|
err := msg.From().Ignored.Add(set.Itemize(id, target))
|
|
if err == set.ErrCollision {
|
|
return fmt.Errorf("user already ignored: %s", id)
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
room.Send(message.NewSystemMsg(fmt.Sprintf("Ignoring: %s", target.Name()), msg.From()))
|
|
return nil
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/unignore",
|
|
PrefixHelp: "USER",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
id := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/unignore"))
|
|
if id == "" {
|
|
return errors.New("must specify user")
|
|
}
|
|
|
|
if err := msg.From().Ignored.Remove(id); err != nil {
|
|
return err
|
|
}
|
|
|
|
room.Send(message.NewSystemMsg(fmt.Sprintf("No longer ignoring: %s", id), msg.From()))
|
|
return nil
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/focus",
|
|
PrefixHelp: "[USER ...]",
|
|
Help: "Only show messages from focused users, or $ to reset.",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
ids := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/focus"))
|
|
if ids == "" {
|
|
// Print focused names, if any.
|
|
var names []string
|
|
msg.From().Focused.Each(func(_ string, item set.Item) error {
|
|
names = append(names, item.Key())
|
|
return nil
|
|
})
|
|
|
|
var systemMsg string
|
|
if len(names) == 0 {
|
|
systemMsg = "Unfocused."
|
|
} else {
|
|
systemMsg = fmt.Sprintf("Focusing on %d users: %s", len(names), strings.Join(names, ", "))
|
|
}
|
|
|
|
room.Send(message.NewSystemMsg(systemMsg, msg.From()))
|
|
return nil
|
|
}
|
|
|
|
n := msg.From().Focused.Clear()
|
|
if ids == "$" {
|
|
room.Send(message.NewSystemMsg(fmt.Sprintf("Removed focus from %d users.", n), msg.From()))
|
|
return nil
|
|
}
|
|
|
|
var focused []string
|
|
for _, name := range strings.Split(ids, " ") {
|
|
id := sanitize.Name(name)
|
|
if id == "" {
|
|
continue // Skip
|
|
}
|
|
focused = append(focused, id)
|
|
if err := msg.From().Focused.Set(set.Itemize(id, set.ZeroValue)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
room.Send(message.NewSystemMsg(fmt.Sprintf("Focusing: %s", strings.Join(focused, ", ")), msg.From()))
|
|
return nil
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/away",
|
|
PrefixHelp: "[REASON]",
|
|
Help: "Set away reason, or empty to unset.",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
awayMsg := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/away"))
|
|
isAway, _, _ := msg.From().GetAway()
|
|
msg.From().SetAway(awayMsg)
|
|
if awayMsg != "" {
|
|
room.Send(message.NewEmoteMsg("has gone away: "+awayMsg, msg.From()))
|
|
return nil
|
|
}
|
|
if isAway {
|
|
room.Send(message.NewEmoteMsg("is back.", msg.From()))
|
|
return nil
|
|
}
|
|
return errors.New("not away. Append a reason message to set away")
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Prefix: "/back",
|
|
Help: "Clear away status.",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
isAway, _, _ := msg.From().GetAway()
|
|
if isAway {
|
|
msg.From().SetAway("")
|
|
room.Send(message.NewEmoteMsg("is back.", msg.From()))
|
|
return nil
|
|
}
|
|
return errors.New("must be away to be back")
|
|
},
|
|
})
|
|
|
|
c.Add(Command{
|
|
Op: true,
|
|
Prefix: "/mute",
|
|
PrefixHelp: "USER",
|
|
Help: "Toggle muting USER, preventing messages from broadcasting.",
|
|
Handler: func(room *Room, msg message.CommandMsg) error {
|
|
if !room.IsOp(msg.From()) {
|
|
return errors.New("must be op")
|
|
}
|
|
|
|
args := msg.Args()
|
|
if len(args) == 0 {
|
|
return errors.New("must specify user")
|
|
}
|
|
|
|
member, ok := room.MemberByID(args[0])
|
|
if !ok {
|
|
return errors.New("user not found")
|
|
}
|
|
|
|
setMute := !member.IsMuted()
|
|
member.SetMute(setMute)
|
|
id := member.ID()
|
|
|
|
if setMute {
|
|
room.Send(message.NewSystemMsg("Muted: "+id, msg.From()))
|
|
} else {
|
|
room.Send(message.NewSystemMsg("Unmuted: "+id, msg.From()))
|
|
}
|
|
|
|
return nil
|
|
},
|
|
})
|
|
}
|