feat: handle CAP command and support a few capabilities

This commit is contained in:
2023-02-22 08:52:26 +02:00
parent bd4d332b75
commit bfe490cd5c
4 changed files with 157 additions and 12 deletions
+8 -4
View File
@@ -82,8 +82,13 @@ type Client struct {
// Index of the last visible tab in the tab bar // Index of the last visible tab in the tab bar
LastTabIndexInTabBar int LastTabIndexInTabBar int
// The acknowledged capabilities
EnabledCapabilities Capabilities
} }
type Capabilities map[string]string
type MsgFmtOpts struct { type MsgFmtOpts struct {
WithTimestamp bool WithTimestamp bool
AsServerMsg bool AsServerMsg bool
@@ -138,6 +143,7 @@ func (s *Client) Initialize(host string, port string, tlsEnabled bool) {
s.Port = port s.Port = port
s.ActiveChannel = host s.ActiveChannel = host
s.Channels = make([]Channel, 0) s.Channels = make([]Channel, 0)
s.EnabledCapabilities = make(Capabilities, 0)
return return
} }
@@ -156,6 +162,7 @@ func (s *Client) Initialize(host string, port string, tlsEnabled bool) {
s.Port = port s.Port = port
s.ActiveChannel = host s.ActiveChannel = host
s.Channels = make([]Channel, 0) s.Channels = make([]Channel, 0)
s.EnabledCapabilities = make(Capabilities, 0)
} }
func (c *Client) Register(nick string, password string, channel string) { 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), Users: make(map[string]User),
}) })
c.SendCommand(commands.CAP, "LS") c.SendCommand(commands.CAP, "LS", "302")
if password != "" { if password != "" {
c.SendCommand(commands.PASS, password) c.SendCommand(commands.PASS, password)
} }
// TODO: check if nickname has spaces and remove them
c.SendCommand(commands.NICK, nick) c.SendCommand(commands.NICK, nick)
// set user-wanted nickname // set user-wanted nickname
c.Nickname = nick c.Nickname = nick
c.SendCommand(commands.USER, nick, "0", "*", 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 // joining a channel when registering is optional
if channel != "" { if channel != "" {
c.SendCommand(commands.JOIN, channel) c.SendCommand(commands.JOIN, channel)
+41 -5
View File
@@ -116,6 +116,7 @@ const (
RPL_MYINFO = "004" // RFC2812 - Implemented RPL_MYINFO = "004" // RFC2812 - Implemented
RPL_ISUPPORT = "005" // ??????? - Not Implemented (TODO:) RPL_ISUPPORT = "005" // ??????? - Not Implemented (TODO:)
RPL_BOUNCE = "010" // ??????? - 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_TRACELINK = "200" // RFC1459 - Not Implemented (TODO:)
RPL_TRACECONNECTING = "201" // RFC1459 - Not Implemented (TODO:) RPL_TRACECONNECTING = "201" // RFC1459 - Not Implemented (TODO:)
RPL_TRACEHANDSHAKE = "202" // RFC1459 - Not Implemented (TODO:) RPL_TRACEHANDSHAKE = "202" // RFC1459 - Not Implemented (TODO:)
@@ -240,7 +241,7 @@ const (
ERR_NOTOPLEVEL = "413" // RFC1459 - Not Implemented (TODO:) ERR_NOTOPLEVEL = "413" // RFC1459 - Not Implemented (TODO:)
ERR_WILDTOPLEVEL = "414" // RFC1459 - Not Implemented (TODO:) ERR_WILDTOPLEVEL = "414" // RFC1459 - Not Implemented (TODO:)
ERR_BADMASK = "415" // RFC2812 - 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_NOMOTD = "422" // RFC1459 - Not Implemented (TODO:)
ERR_NOADMININFO = "423" // RFC1459 - Not Implemented (TODO:) ERR_NOADMININFO = "423" // RFC1459 - Not Implemented (TODO:)
ERR_FILEERROR = "424" // RFC1459 - Not Implemented (TODO:) ERR_FILEERROR = "424" // RFC1459 - Not Implemented (TODO:)
@@ -312,7 +313,7 @@ var Features = map[string]bool{
"CHANLIMIT": true, "CHANLIMIT": true,
"CHANMODES": true, "CHANMODES": true,
"CHANNELLEN": true, "CHANNELLEN": true,
"CHANTYPES": true, "CHANTYPES": true, // Channel Types. Default is #. Available are #&
"CHARSET": true, // Deprecated but might still be used "CHARSET": true, // Deprecated but might still be used
"CLIENTVER": true, // Deprecated but might still be used "CLIENTVER": true, // Deprecated but might still be used
"CNOTICE": true, "CNOTICE": true,
@@ -364,7 +365,42 @@ var Features = map[string]bool{
"WHOX": true, "WHOX": true,
} }
// Messages MUST end in CRLF and SHOULD be encoded and decoded using UTF-8 (with fallbacks such as Latin-1). var Capabilities = map[string]bool{
// Names are casemapped, read the casemapping from the RPL_ISUPPORT the server sends when registration is completed. // 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,
}
+96
View File
@@ -169,6 +169,87 @@ func handlePart(msg parser.IRCMessage, client *irc.Client) {
client.Tea.Send(cmds.SwitchChannels()) 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) { func handleWELCOME(msg parser.IRCMessage, client *irc.Client) {
nick := msg.Parameters[0] nick := msg.Parameters[0]
welcomeMsg := msg.Parameters[1] welcomeMsg := msg.Parameters[1]
@@ -467,6 +548,17 @@ func handleNOSUCHSERVER(msg parser.IRCMessage, client *irc.Client) {
client.Channels[0].AppendMsg(msg.Timestamp, message, msgOpts) 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) { func handleNONICKNAMEGIVEN(msg parser.IRCMessage, client *irc.Client) {
message := msg.Parameters[1] message := msg.Parameters[1]
@@ -513,6 +605,8 @@ func HandleCommand(msg parser.IRCMessage, client *irc.Client) {
handleQuit(msg, client) handleQuit(msg, client)
case commands.PART: case commands.PART:
handlePart(msg, client) handlePart(msg, client)
case commands.CAP:
handleCAP(msg, client)
case commands.RPL_WELCOME: case commands.RPL_WELCOME:
handleWELCOME(msg, client) handleWELCOME(msg, client)
case commands.RPL_YOURHOST: case commands.RPL_YOURHOST:
@@ -569,6 +663,8 @@ func HandleCommand(msg parser.IRCMessage, client *irc.Client) {
handleWHOISMODES(msg, client) handleWHOISMODES(msg, client)
case commands.ERR_NOSUCHSERVER: case commands.ERR_NOSUCHSERVER:
handleNOSUCHSERVER(msg, client) handleNOSUCHSERVER(msg, client)
case commands.ERR_UNKNOWNCOMMAND:
handleUNKNOWNCOMMAND(msg, client)
case commands.ERR_NONICKNAMEGIVEN: case commands.ERR_NONICKNAMEGIVEN:
handleNONICKNAMEGIVEN(msg, client) handleNONICKNAMEGIVEN(msg, client)
case commands.ERR_NEEDMOREPARAMS: case commands.ERR_NEEDMOREPARAMS:
+12 -3
View File
@@ -18,6 +18,7 @@ package parser
import ( import (
"fmt" "fmt"
"log"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@@ -34,9 +35,17 @@ type IRCMessage struct {
type Tags map[string]string type Tags map[string]string
func (m *IRCMessage) setTimestamp() { func (m *IRCMessage) setTimestamp() {
// TODO: get timestamp from time tag if serverTime, ok := m.Tags["time"]; ok {
now := time.Now() t, err := time.Parse("2006-01-02T15:04:05.000Z", serverTime)
m.Timestamp = fmt.Sprintf("[%02d:%02d]", now.Hour(), now.Minute()) 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 { func escapeCharacters(char rune) string {