diff --git a/channel.go b/channel.go index 1cdeb72..c26caf2 100644 --- a/channel.go +++ b/channel.go @@ -1,7 +1,27 @@ package main +import "slices" + type Channel struct { name string members []string history []string } + +func (channel *Channel) addMember(nick string) { + channel.members = append(channel.members, nick) +} + +func (channel *Channel) removeMember(nick string) { + channel.members = slices.DeleteFunc(channel.members, func(s string) bool { + return s == nick + }) +} + +func (channel *Channel) renameMember(oldNick, newNick string) { + for i := range channel.members { + if channel.members[i] == oldNick { + channel.members[i] = newNick + } + } +} diff --git a/client.go b/client.go index f9b136e..7eca400 100644 --- a/client.go +++ b/client.go @@ -6,6 +6,12 @@ import ( "strings" ) +type commandDef struct { + desc string + args string + fn func(string) (Message, error) +} + type Client struct { nick string user string @@ -16,6 +22,7 @@ type Client struct { ignored map[string]bool handlers map[string]func(Message) + commands map[string]commandDef ui UI } @@ -27,16 +34,18 @@ func NewClient(nick, user, address string, port int, ui UI) *Client { go func() { for msg := range client.server.incoming { client.ui.App.QueueUpdateDraw(func() { - msg := msg + if client.ignored[msg.Nick()] { + return + } if handler, ok := client.handlers[msg.command]; ok { handler(msg) } else { - client.print("%s\n", msg) + client.printStatus("%s\n", msg) } }) } client.ui.App.QueueUpdateDraw(func() { - client.print("disconnected from server") + client.printStatus("disconnected from server") }) }() @@ -59,6 +68,21 @@ func NewClient(nick, user, address string, port int, ui UI) *Client { "372": client.handleMOTD, "376": client.handleMOTDEnd, } + client.commands = map[string]commandDef{ + "help": {"display available commands", "optional command name", client.cmdHelp}, + "clear": {"clear the chat window", "", client.cmdClear}, + "motd": {"display current server message of the day", "", client.cmdMOTD}, + "list": {"lists all channels and their topics", "filter with >, <", client.cmdList}, + "quit": {"quit the application", "optional quit message", client.cmdQuit}, + "nick": {"change your nickname displayed on the server", "new nickname", client.cmdNick}, + "join": {"join the specified channel", "#channel name", client.cmdJoin}, + "msg": {"privately message a user on the server", "username, message", client.cmdMsg}, + "part": {"leave a channel", "channel to leave (default: current), optional parting message", client.cmdPart}, + "me": {"send a message from yourself", "message", client.cmdMe}, + "ignore": {"add a user to your ignore list (will not see messages, join, part, quit, etc.)", "user nick", client.cmdIgnore}, + "unignore": {"remove a user from your ignore list", "user nick", client.cmdUnignore}, + "ignores": {"display ignore list", "", client.cmdIgnores}, + } return client } @@ -70,21 +94,23 @@ func (client *Client) register() error { } user := Message{"", "USER", []string{client.nick, "0", "*", client.user}} - err = client.server.send(user) - if err != nil { - return err - } - - return nil + return client.server.send(user) } -func (client *Client) print(format string, args ...any) { +func (client *Client) printStatus(format string, args ...any) { _, err := fmt.Fprintf(client.ui.Chat, format, args...) if err != nil { return } } +func (client *Client) anyChannel() string { + for name := range client.channels { + return name + } + return "" +} + func (client *Client) printChannel(channel string, format string, args ...any) { text := fmt.Sprintf(format, args...) ch, ok := client.channels[channel] @@ -110,11 +136,24 @@ func (client *Client) refreshNames() { client.ui.Members.AddItem(name, "", 0, nil) } memberCount := len(ch.members) - if memberCount < 1 { - client.ui.Members.SetTitle("Members") - } else { - client.ui.Members.SetTitle(fmt.Sprintf("Members - %d", memberCount)) + client.ui.Members.SetTitle(memberCountTitle(memberCount)) +} + +func (client *Client) switchChannel(name string) { + client.currentChannel = name + client.ui.Chat.SetTitle(name) + client.ui.Chat.Clear() + client.refreshNames() + if ch, ok := client.channels[name]; ok { + fmt.Fprintf(client.ui.Chat, strings.Join(ch.history, "")) + } +} + +func memberCountTitle(n int) string { + if n < 1 { + return "Members" } + return fmt.Sprintf("Members - %d", n) } func (client *Client) parseInput(line string) (Message, error) { @@ -136,34 +175,8 @@ func (client *Client) parseInput(line string) (Message, error) { args = parts[1] } - switch command { - case "help": - return client.cmdHelp(args) - case "clear": - return client.cmdClear(args) - case "motd": - return client.cmdMOTD(args) - case "list": - return client.cmdList(args) - case "quit": - return client.cmdQuit(args) - case "nick": - return client.cmdNick(args) - case "join": - return client.cmdJoin(args) - case "msg": - return client.cmdMsg(args) - case "part": - return client.cmdPart(args) - case "me": - return client.cmdMe(args) - case "ignore": - return client.cmdIgnore(args) - case "unignore": - return client.cmdUnignore(args) - case "ignores": - return client.cmdIgnores(args) - default: - return Message{}, errors.New("unrecognised command (see /help)") + if def, ok := client.commands[command]; ok { + return def.fn(args) } + return Message{}, errors.New("unrecognised command (see /help)") } diff --git a/client_handlers.go b/client_handlers.go index e60c35a..3fb6df3 100644 --- a/client_handlers.go +++ b/client_handlers.go @@ -5,43 +5,31 @@ import ( "strings" ) +func optionalArgs(args string) []string { + if args != "" { + return []string{args} + } + return []string{} +} + func (client *Client) cmdHelp(args string) (Message, error) { - cmds := map[string][]string{ - "motd": {"display current server message of the day", ""}, - "clear": {"clear the chat window", ""}, - "quit": {"quit the application", "optional quit message"}, - "list": {"lists all channels and their topics", "filter with >, <"}, - "nick": {"change your nickname displayed on the server", "new nickname"}, - "join": {"join the specified channel", "#channel name"}, - "msg": {"privately message a user on the server", "username, message"}, - "part": {"leave a channel", "channel to leave (default: current), optional parting message"}, - "me": {"send a message from yourself", "message"}, - "ignore": {"add a user to your ignore list (will not see messages, join, part, quit, etc.)", "user nick"}, - "unignore": {"remove a user to your ignore list", "user nick"}, - "ignores": {"display ignore list", ""}, + printCmd := func(cmd string, def commandDef) { + if def.args != "" { + client.printStatus("/%s - %s\n\t%s\n", cmd, def.desc, def.args) + } else { + client.printStatus("/%s - %s\n", cmd, def.desc) + } } if args != "" { - cmd := args - if cmds[cmd] == nil { + def, ok := client.commands[args] + if !ok { return Message{}, errors.New("unknown command") } - cmdDesc := cmds[cmd][0] - cmdArgs := cmds[cmd][1] - if cmdArgs != "" { - client.print("/%s - %s\n\t%s\n", cmd, cmdDesc, cmdArgs) - } else { - client.print("/%s - %s\n", cmd, cmdDesc) - } + printCmd(args, def) } else { - for cmd, info := range cmds { - cmdDesc := info[0] - cmdArgs := info[1] - if cmdArgs != "" { - client.print("/%s - %s\n\t%s\n", cmd, cmdDesc, cmdArgs) - } else { - client.print("/%s - %s\n", cmd, cmdDesc) - } + for cmd, def := range client.commands { + printCmd(cmd, def) } } return Message{}, nil @@ -56,24 +44,15 @@ func (client *Client) cmdClear(args string) (Message, error) { } func (client *Client) cmdMOTD(args string) (Message, error) { - if args != "" { - return Message{"", "MOTD", []string{args}}, nil - } - return Message{"", "MOTD", []string{}}, nil + return Message{"", "MOTD", optionalArgs(args)}, nil } func (client *Client) cmdQuit(args string) (Message, error) { - if args != "" { - return Message{"", "QUIT", []string{args}}, nil - } - return Message{"", "QUIT", []string{}}, nil + return Message{"", "QUIT", optionalArgs(args)}, nil } func (client *Client) cmdList(args string) (Message, error) { - if args != "" { - return Message{"", "LIST", []string{args}}, nil - } - return Message{"", "LIST", []string{}}, nil + return Message{"", "LIST", optionalArgs(args)}, nil } func (client *Client) cmdNick(args string) (Message, error) { @@ -117,7 +96,6 @@ func (client *Client) cmdPart(args string) (Message, error) { if channel == "" { return Message{}, errors.New("not in a channel") } - client.ui.Chat.Clear() if partMsg != "" { return Message{"", "PART", []string{channel, partMsg}}, nil } @@ -153,7 +131,7 @@ func (client *Client) cmdUnignore(args string) (Message, error) { func (client *Client) cmdIgnores(args string) (Message, error) { if len(client.ignored) > 0 { for nick := range client.ignored { - client.print("%s\n", nick) + client.printStatus("%s\n", nick) } return Message{}, nil } diff --git a/config.go b/config.go index 849050e..cbc31c7 100644 --- a/config.go +++ b/config.go @@ -13,6 +13,22 @@ type Config struct { Port int `json:"port"` } +func (config Config) Validate() error { + if config.Nick == "" { + return errors.New("config: nick is required") + } + if config.User == "" { + return errors.New("config: user is required") + } + if config.Server == "" { + return errors.New("config: server is required") + } + if config.Port == 0 { + return errors.New("config: port is required") + } + return nil +} + func loadConfig(path string) (Config, error) { var config Config @@ -26,18 +42,5 @@ func loadConfig(path string) (Config, error) { return Config{}, err } - if config.Nick == "" { - return Config{}, errors.New("config: nick is required") - } - if config.User == "" { - return Config{}, errors.New("config: user is required") - } - if config.Server == "" { - return Config{}, errors.New("config: server is required") - } - if config.Port == 0 { - return Config{}, errors.New("config: port is required") - } - - return config, nil + return config, config.Validate() } diff --git a/main.go b/main.go index fbe948d..10a3246 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,8 @@ package main import ( - "fmt" "log" "net" - "strings" "github.com/gdamore/tcell/v2" ) @@ -19,18 +17,7 @@ func main() { client.ui.Channels.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { client.ui.App.QueueUpdateDraw(func() { - client.currentChannel = mainText - client.ui.Chat.SetTitle(mainText) - client.refreshNames() - client.ui.Chat.Clear() - ch, ok := client.channels[mainText] - if !ok { - return - } - _, err = fmt.Fprint(client.ui.Chat, strings.Join(ch.history, "")) - if err != nil { - return - } + client.switchChannel(mainText) client.ui.App.SetFocus(client.ui.Input) }) }) @@ -57,7 +44,7 @@ func main() { var msg Message msg, err = client.parseInput(text) if err != nil { - client.print("%s\n", err) + client.printStatus("%s\n", err) return } if msg.command != "" { diff --git a/message.go b/message.go index 5aef8d8..a59c884 100644 --- a/message.go +++ b/message.go @@ -48,12 +48,19 @@ func parse(line string) (Message, error) { break } parameters = append(parameters, line[0:spaceIndex]) - line = strings.SplitN(line, " ", 2)[1] + line = line[spaceIndex+1:] } return Message{prefix, command, parameters}, nil } +func parseAction(text string) (string, bool) { + if strings.HasPrefix(text, "\x01ACTION ") && strings.HasSuffix(text, "\x01") { + return text[8 : len(text)-1], true + } + return "", false +} + func (msg Message) String() string { var builder strings.Builder @@ -90,3 +97,10 @@ func (msg Message) Nick() string { } return strings.SplitN(msg.prefix, "!", 2)[0] } + +func (msg Message) param(n int) (string, bool) { + if n < len(msg.parameters) { + return msg.parameters[n], true + } + return "", false +} diff --git a/server.go b/server.go index 41eeb37..30d9f43 100644 --- a/server.go +++ b/server.go @@ -24,7 +24,7 @@ func (server *Server) connect() error { } func (server *Server) send(msg Message) error { - _, err := fmt.Fprintf(server.conn, "%s", msg) + _, err := fmt.Fprint(server.conn, msg) return err } diff --git a/server_handlers.go b/server_handlers.go index 16a4852..726dd7d 100644 --- a/server_handlers.go +++ b/server_handlers.go @@ -1,41 +1,40 @@ package main import ( - "fmt" "slices" "strings" ) func (client *Client) handleMOTDStart(msg Message) { - if len(msg.parameters) < 2 { + text, ok := msg.param(1) + if !ok { return } - client.print("%s\n", msg.parameters[1]) + client.printStatus("%s\n", text) } func (client *Client) handleMOTD(msg Message) { - if len(msg.parameters) < 2 { + text, ok := msg.param(1) + if !ok || len(text) < 2 { return } - if len(msg.parameters[1]) < 2 { - return - } - client.print("%s\t\n", msg.parameters[1][2:]) + client.printStatus("%s\t\n", text[2:]) } func (client *Client) handleMOTDEnd(msg Message) { - client.print("\n") + client.printStatus("\n") } func (client *Client) handleNotice(msg Message) { - if len(msg.parameters) < 2 { + text, ok := msg.param(1) + if !ok { return } - client.print("%s %s\n", msg.parameters[0], msg.parameters[1]) + client.printStatus("%s %s\n", msg.parameters[0], text) } func (client *Client) handleListStart(msg Message) { - client.print("\n") + client.printStatus("\n") } func (client *Client) handleList(msg Message) { @@ -47,14 +46,14 @@ func (client *Client) handleList(msg Message) { topic := msg.parameters[len(msg.parameters)-1] if topic != "" { - client.print("- %s: %s current users - %s\n", channel, userCount, topic) + client.printStatus("- %s: %s current users - %s\n", channel, userCount, topic) } else { - client.print("- %s: %s current users\n", channel, userCount) + client.printStatus("- %s: %s current users\n", channel, userCount) } } func (client *Client) handleListEnd(msg Message) { - client.print("\n") + client.printStatus("\n") } func (client *Client) handleNames(msg Message) { @@ -63,44 +62,41 @@ func (client *Client) handleNames(msg Message) { } channel := msg.parameters[2] members := strings.Fields(msg.parameters[len(msg.parameters)-1]) - client.channels[channel].members = nil + ch, ok := client.channels[channel] + if !ok { + return + } + ch.members = nil for _, member := range members { - ch, ok := client.channels[channel] - if !ok { - continue - } - ch.members = append(ch.members, member) + ch.addMember(member) } client.refreshNames() } func (client *Client) handleNamesEnd(msg Message) { - client.print("\n") + client.printStatus("\n") } func (client *Client) handlePing(msg Message) { pong := Message{"", "PONG", msg.parameters} err := client.server.send(pong) if err != nil { - client.print("%s\n", err) + client.printStatus("%s\n", err) } } func (client *Client) handlePrivmsg(msg Message) { - if client.ignored[msg.Nick()] { - return - } - if len(msg.parameters) < 2 { + text, ok := msg.param(1) + if !ok { return } nick := msg.Nick() target := msg.parameters[0] - text := msg.parameters[1] switch target[0] { case '#': - if strings.HasPrefix(text, "\x01ACTION ") && strings.HasSuffix(text, "\x01") { - client.printChannel(target, "* %s %s\n", nick, text[8:len(text)-1]) + if action, ok := parseAction(text); ok { + client.printChannel(target, "* %s %s\n", nick, action) } else { client.printChannel(target, "<%s> %s\n", nick, text) } @@ -115,30 +111,21 @@ func (client *Client) handlePrivmsg(msg Message) { client.ui.Channels.AddItem(key, "", 0, nil) } - if strings.HasPrefix(text, "\x01ACTION ") && strings.HasSuffix(text, "\x01") { - client.printChannel(key, "* %s %s\n", key, text[8:len(text)-1]) + if action, ok := parseAction(text); ok { + client.printChannel(target, "* %s %s\n", nick, action) } else { - client.printChannel(key, "<%s> %s\n", key, text) + client.printChannel(target, "<%s> %s\n", nick, text) } } } func (client *Client) handleJoin(msg Message) { - if client.ignored[msg.Nick()] { - return - } - nick := msg.Nick() channel := msg.parameters[0] if nick == client.nick { - client.print("you joined %s\n", channel) - client.currentChannel = channel - client.ui.Chat.SetTitle(channel) - client.ui.Chat.Clear() - if ch, ok := client.channels[channel]; ok { - fmt.Fprintf(client.ui.Chat, strings.Join(ch.history, "")) - } + client.printStatus("you joined %s\n", channel) + client.switchChannel(channel) if _, ok := client.channels[channel]; !ok { client.channels[channel] = &Channel{name: channel} client.ui.Channels.AddItem(channel, "", 0, nil) @@ -149,73 +136,53 @@ func (client *Client) handleJoin(msg Message) { if !ok { return } - ch.members = append(ch.members, nick) + ch.addMember(nick) } client.refreshNames() } func (client *Client) handlePart(msg Message) { - if client.ignored[msg.Nick()] { - return - } - nick := msg.Nick() channel := msg.parameters[0] if nick == client.nick { - client.print("you left %s\n", channel) + client.printStatus("you left %s\n", channel) delete(client.channels, channel) client.currentChannel = "" - client.ui.Chat.SetTitle(client.currentChannel) indices := client.ui.Channels.FindItems(channel, "", false, true) if len(indices) > 0 { client.ui.Channels.RemoveItem(indices[0]) } - for name := range client.channels { - client.currentChannel = name - break - } - client.ui.Chat.SetTitle(client.currentChannel) - client.ui.Chat.Clear() - if ch, ok := client.channels[client.currentChannel]; ok { - fmt.Fprintf(client.ui.Chat, strings.Join(ch.history, "")) - } + client.currentChannel = client.anyChannel() + client.switchChannel(client.currentChannel) } else { client.printChannel(channel, "%s left %s\n", nick, channel) ch, ok := client.channels[channel] if !ok { return } - ch.members = slices.DeleteFunc(ch.members, func(s string) bool { - return s == nick - }) + ch.removeMember(nick) } client.refreshNames() } func (client *Client) handleQuit(msg Message) { - if client.ignored[msg.Nick()] { - return - } - nick := msg.Nick() - if len(msg.parameters) < 1 { + text, ok := msg.param(0) + if !ok { if nick == client.nick { - client.print("you quit\n") + client.printStatus("you quit\n") } else { client.printChannel(client.currentChannel, "%s quit\n", nick) } } else { - text := msg.parameters[0] quitReason := strings.TrimPrefix(text, "Quit: ") if nick == client.nick { - client.print("you quit: %s\n", quitReason) + client.printStatus("you quit: %s\n", quitReason) } else { for _, channel := range client.channels { if slices.Contains(channel.members, nick) { client.printChannel(channel.name, "%s quit: %s\n", nick, quitReason) - channel.members = slices.DeleteFunc(channel.members, func(s string) bool { - return s == nick - }) + channel.removeMember(nick) } } } @@ -224,24 +191,19 @@ func (client *Client) handleQuit(msg Message) { } func (client *Client) handleNick(msg Message) { - if len(msg.parameters) < 1 { + newNick, ok := msg.param(0) + if !ok { return } if msg.Nick() == client.nick { - newNick := msg.parameters[0] client.nick = newNick - client.print("you are now known as %s\n", newNick) + client.printStatus("you are now known as %s\n", newNick) } else { oldNick := msg.Nick() - newNick := msg.parameters[0] for _, channel := range client.channels { if slices.Contains(channel.members, oldNick) { client.printChannel(channel.name, "%s is now known as %s\n", oldNick, newNick) - for i := range channel.members { - if channel.members[i] == oldNick { - channel.members[i] = newNick - } - } + channel.renameMember(oldNick, newNick) } } }