From bfe490cd5c08b38c006a406d0fbe6a441a41c28e Mon Sep 17 00:00:00 2001 From: IllusionMan1212 Date: Wed, 22 Feb 2023 08:52:26 +0200 Subject: [PATCH] feat: handle CAP command and support a few capabilities --- irc/client.go | 12 +++-- irc/commands/commands.go | 46 ++++++++++++++++--- irc/handler/handler.go | 96 ++++++++++++++++++++++++++++++++++++++++ irc/parser/parser.go | 15 +++++-- 4 files changed, 157 insertions(+), 12 deletions(-) diff --git a/irc/client.go b/irc/client.go index 1e7b875..15523fb 100644 --- a/irc/client.go +++ b/irc/client.go @@ -82,8 +82,13 @@ type Client struct { // Index of the last visible tab in the tab bar LastTabIndexInTabBar int + + // The acknowledged capabilities + EnabledCapabilities Capabilities } +type Capabilities map[string]string + type MsgFmtOpts struct { WithTimestamp bool AsServerMsg bool @@ -138,6 +143,7 @@ func (s *Client) Initialize(host string, port string, tlsEnabled bool) { s.Port = port s.ActiveChannel = host s.Channels = make([]Channel, 0) + s.EnabledCapabilities = make(Capabilities, 0) return } @@ -156,6 +162,7 @@ func (s *Client) Initialize(host string, port string, tlsEnabled bool) { s.Port = port s.ActiveChannel = host s.Channels = make([]Channel, 0) + s.EnabledCapabilities = make(Capabilities, 0) } func (c *Client) Register(nick string, password string, channel string) { @@ -164,17 +171,14 @@ func (c *Client) Register(nick string, password string, channel string) { Users: make(map[string]User), }) - c.SendCommand(commands.CAP, "LS") + c.SendCommand(commands.CAP, "LS", "302") if password != "" { c.SendCommand(commands.PASS, password) } - // TODO: check if nickname has spaces and remove them c.SendCommand(commands.NICK, nick) // set user-wanted nickname c.Nickname = nick c.SendCommand(commands.USER, nick, "0", "*", nick) - // TODO: CAP REQ :whatever capability the client recognizes and supports - c.SendCommand(commands.CAP, "END") // joining a channel when registering is optional if channel != "" { c.SendCommand(commands.JOIN, channel) diff --git a/irc/commands/commands.go b/irc/commands/commands.go index b42f2b6..c8350f1 100644 --- a/irc/commands/commands.go +++ b/irc/commands/commands.go @@ -116,6 +116,7 @@ const ( RPL_MYINFO = "004" // RFC2812 - Implemented RPL_ISUPPORT = "005" // ??????? - Not Implemented (TODO:) RPL_BOUNCE = "010" // ??????? - Not Implemented (TODO:) + RPL_REMOTEISUPPORT = "105" // ??????? - Not Implemented (TODO:) // Exact same as RPL_ISUPPORT but for remote servers RPL_TRACELINK = "200" // RFC1459 - Not Implemented (TODO:) RPL_TRACECONNECTING = "201" // RFC1459 - Not Implemented (TODO:) RPL_TRACEHANDSHAKE = "202" // RFC1459 - Not Implemented (TODO:) @@ -240,7 +241,7 @@ const ( ERR_NOTOPLEVEL = "413" // RFC1459 - Not Implemented (TODO:) ERR_WILDTOPLEVEL = "414" // RFC1459 - Not Implemented (TODO:) ERR_BADMASK = "415" // RFC2812 - Not Implemented (TODO:) - ERR_UNKNOWNCOMMAND = "421" // RFC1459 - Not Implemented (TODO:) + ERR_UNKNOWNCOMMAND = "421" // RFC1459 - Implemented ERR_NOMOTD = "422" // RFC1459 - Not Implemented (TODO:) ERR_NOADMININFO = "423" // RFC1459 - Not Implemented (TODO:) ERR_FILEERROR = "424" // RFC1459 - Not Implemented (TODO:) @@ -312,7 +313,7 @@ var Features = map[string]bool{ "CHANLIMIT": true, "CHANMODES": true, "CHANNELLEN": true, - "CHANTYPES": true, + "CHANTYPES": true, // Channel Types. Default is #. Available are #& "CHARSET": true, // Deprecated but might still be used "CLIENTVER": true, // Deprecated but might still be used "CNOTICE": true, @@ -364,7 +365,42 @@ var Features = map[string]bool{ "WHOX": true, } -// Messages MUST end in CRLF and SHOULD be encoded and decoded using UTF-8 (with fallbacks such as Latin-1). -// Names are casemapped, read the casemapping from the RPL_ISUPPORT the server sends when registration is completed. +var Capabilities = map[string]bool{ + // Twitch-specific capabilities + "twitch.tv/membership": false, + "twitch.tv/commands": false, + "twitch.tv/tags": false, -// When receiving a numeric reply, client MUST be able to handle any number of parameters on a numeric reply. + // Solanum-specific capabilities + "solanum.chat/identify-msg": false, + "solanum.chat/oper": false, + "solanum.chat/realhost": false, + + // IRCv3 capabilities + "account-notify": false, + "account-registration": false, // Draft + "account-tag": false, + "away-notify": false, + "batch": false, + "cap-notify": true, + "channel-rename": false, // Draft + "chathistory": false, // Draft + "chghost": false, + "echo-message": false, + "event-playback": false, // Draft + "extended-join": false, + "extended-monitor": false, + "invite-notify": false, + "labeled-response": false, + "message-tags": false, + "metadata": false, + "monitor": false, + "multi-prefix": false, + "multiline": false, // Draft + "read-marker": false, // Draft + "sasl": false, + "server-time": true, + "setname": false, + "tls": false, // Deprecated + "userhost-in-names": false, +} diff --git a/irc/handler/handler.go b/irc/handler/handler.go index 937e342..7681d47 100644 --- a/irc/handler/handler.go +++ b/irc/handler/handler.go @@ -169,6 +169,87 @@ func handlePart(msg parser.IRCMessage, client *irc.Client) { client.Tea.Send(cmds.SwitchChannels()) } +func handleCAP(msg parser.IRCMessage, client *irc.Client) { + switch msg.Parameters[1] { + case "LIST": + fallthrough + case "NEW": + fallthrough + case "LS": + caps := strings.Split(msg.Parameters[2], " ") + recognizedCaps := make([]irc.Capabilities, 0) + capabilities := make(irc.Capabilities, 0) + length := 0 + + for _, capability := range caps { + // NOTE: not sure if I should store the cap value the server advertises or use my own + parts := strings.Split(capability, "=") + key := parts[0] + value := "" + if len(parts) > 1 { + value = parts[1] + } + + if commands.Capabilities[key] { + // When the final parameter approaches 510 bytes, + // we send multiple REQ commands + // (https://ircv3.net/specs/extensions/capability-negotiation.html#the-cap-req-subcommand) + if length+len(capability) >= 500 { + recognizedCaps = append(recognizedCaps, capabilities) + capabilities = make(irc.Capabilities, 0) + length = 0 + } + + capabilities[key] = value + length += len(capability) + } else { + log.Println("Unknown capability:", capability) + } + } + recognizedCaps = append(recognizedCaps, capabilities) + + for _, capsList := range recognizedCaps { + list := make([]string, 0) + for key, value := range capsList { + if len(value) > 0 { + list = append(list, key+"="+value) + } else { + list = append(list, key) + } + } + client.SendCommand(commands.CAP, "REQ", strings.Join(list, " ")) + } + client.SendCommand(commands.CAP, "END") + case "ACK": + caps := strings.Split(msg.Parameters[2], " ") + for _, capability := range caps { + if capability[0] == '-' { + delete(client.EnabledCapabilities, capability[1:]) + } else { + parts := strings.Split(capability, "=") + key := parts[0] + value := "" + if len(parts) > 1 { + value = parts[1] + } + client.EnabledCapabilities[key] = value + } + } + case "NAK": + caps := strings.Split(msg.Parameters[2], " ") + msgOpts := irc.MsgFmtOpts{ + WithTimestamp: true, + AsServerMsg: true, + } + client.Channels[0].AppendMsg(msg.Timestamp, "Unrecognized capabilities: "+strings.Join(caps, " "), msgOpts) + case "DEL": + caps := strings.Split(msg.Parameters[2], " ") + for _, capability := range caps { + delete(client.EnabledCapabilities, capability) + } + } +} + func handleWELCOME(msg parser.IRCMessage, client *irc.Client) { nick := msg.Parameters[0] welcomeMsg := msg.Parameters[1] @@ -467,6 +548,17 @@ func handleNOSUCHSERVER(msg parser.IRCMessage, client *irc.Client) { client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) } +func handleUNKNOWNCOMMAND(msg parser.IRCMessage, client *irc.Client) { + message := msg.Parameters[1] + " " + msg.Parameters[2] + + msgOpts := irc.MsgFmtOpts{ + WithTimestamp: true, + AsServerMsg: true, + } + + client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) +} + func handleNONICKNAMEGIVEN(msg parser.IRCMessage, client *irc.Client) { message := msg.Parameters[1] @@ -513,6 +605,8 @@ func HandleCommand(msg parser.IRCMessage, client *irc.Client) { handleQuit(msg, client) case commands.PART: handlePart(msg, client) + case commands.CAP: + handleCAP(msg, client) case commands.RPL_WELCOME: handleWELCOME(msg, client) case commands.RPL_YOURHOST: @@ -569,6 +663,8 @@ func HandleCommand(msg parser.IRCMessage, client *irc.Client) { handleWHOISMODES(msg, client) case commands.ERR_NOSUCHSERVER: handleNOSUCHSERVER(msg, client) + case commands.ERR_UNKNOWNCOMMAND: + handleUNKNOWNCOMMAND(msg, client) case commands.ERR_NONICKNAMEGIVEN: handleNONICKNAMEGIVEN(msg, client) case commands.ERR_NEEDMOREPARAMS: diff --git a/irc/parser/parser.go b/irc/parser/parser.go index d09dfb7..fd2cf76 100644 --- a/irc/parser/parser.go +++ b/irc/parser/parser.go @@ -18,6 +18,7 @@ package parser import ( "fmt" + "log" "regexp" "strings" "time" @@ -34,9 +35,17 @@ type IRCMessage struct { type Tags map[string]string func (m *IRCMessage) setTimestamp() { - // TODO: get timestamp from time tag - now := time.Now() - m.Timestamp = fmt.Sprintf("[%02d:%02d]", now.Hour(), now.Minute()) + if serverTime, ok := m.Tags["time"]; ok { + t, err := time.Parse("2006-01-02T15:04:05.000Z", serverTime) + if err != nil { + // TODO: properly handle error + log.Fatalln(err) + } + m.Timestamp = fmt.Sprintf("[%02d:%02d]", t.Local().Hour(), t.Local().Minute()) + } else { + now := time.Now() + m.Timestamp = fmt.Sprintf("[%02d:%02d]", now.Hour(), now.Minute()) + } } func escapeCharacters(char rune) string {