Implement feature request #77: Support IRCv3 capability negotiation during registration

This commit is contained in:
Stefano 2022-03-06 23:20:06 +01:00 committed by Alex Bee
parent b1565dba18
commit 54099b85a3
5 changed files with 268 additions and 14 deletions

View File

@ -84,6 +84,23 @@ func splitMessage(msg string, splitLen int) (msgs []string) {
return append(msgs, msg) return append(msgs, msg)
} }
func splitArgs(args []string, maxLen int) []string {
res := make([]string, 0)
i := 0
for i < len(args) {
currArg := args[i]
i++
for i < len(args) && len(currArg)+len(args[i])+1 < maxLen {
currArg += " " + args[i]
i++
}
res = append(res, currArg)
}
return res
}
// Raw sends a raw line to the server, should really only be used for // Raw sends a raw line to the server, should really only be used for
// debugging purposes but may well come in handy. // debugging purposes but may well come in handy.
func (conn *Conn) Raw(rawline string) { func (conn *Conn) Raw(rawline string) {
@ -299,6 +316,9 @@ func (conn *Conn) Cap(subcommmand string, capabilities ...string) {
if len(capabilities) == 0 { if len(capabilities) == 0 {
conn.Raw(CAP + " " + subcommmand) conn.Raw(CAP + " " + subcommmand)
} else { } else {
conn.Raw(CAP + " " + subcommmand + " :" + strings.Join(capabilities, " ")) cmdPrefix := CAP + " " + subcommmand + " :"
for _, args := range splitArgs(capabilities, defaultSplit-len(cmdPrefix)) {
conn.Raw(cmdPrefix + args)
}
} }
} }

View File

@ -2,6 +2,8 @@ package client
import ( import (
"reflect" "reflect"
"strconv"
"strings"
"testing" "testing"
) )
@ -203,3 +205,20 @@ func TestClientCommands(t *testing.T) {
c.VHost("user", "pass") c.VHost("user", "pass")
s.nc.Expect("VHOST user pass") s.nc.Expect("VHOST user pass")
} }
func TestSplitCommand(t *testing.T) {
nArgs := 100
args := make([]string, 0)
for i := 0; i < nArgs; i++ {
args = append(args, "arg"+strconv.Itoa(i))
}
for maxLen := 1; maxLen <= defaultSplit; maxLen *= 2 {
for _, argStr := range splitArgs(args, maxLen) {
if len(argStr) > maxLen && len(strings.Split(argStr, " ")) > 1 {
t.Errorf("maxLen = %d, but len(cmd) = %d", maxLen, len(argStr))
}
}
}
}

View File

@ -45,6 +45,12 @@ type Conn struct {
out chan string out chan string
connected bool connected bool
// Capabilities supported by the server
supportedCaps *capSet
// Capabilites currently enabled
currCaps *capSet
// CancelFunc and WaitGroup for goroutines // CancelFunc and WaitGroup for goroutines
die context.CancelFunc die context.CancelFunc
wg sync.WaitGroup wg sync.WaitGroup
@ -89,6 +95,12 @@ type Config struct {
// Passed through to https://golang.org/pkg/net/#Dialer // Passed through to https://golang.org/pkg/net/#Dialer
DualStack bool DualStack bool
// Enable IRCv3 capability negotiation.
EnableCapabilityNegotiation bool
// A list of capabilities to request to the server during registration.
Capabilites []string
// Replaceable function to customise the 433 handler's new nick. // Replaceable function to customise the 433 handler's new nick.
// By default an underscore "_" is appended to the current nick. // By default an underscore "_" is appended to the current nick.
NewNick func(string) string NewNick func(string) string
@ -131,6 +143,7 @@ func NewConfig(nick string, args ...string) *Config {
Recover: (*Conn).LogPanic, // in dispatch.go Recover: (*Conn).LogPanic, // in dispatch.go
SplitLen: defaultSplit, SplitLen: defaultSplit,
Timeout: 60 * time.Second, Timeout: 60 * time.Second,
EnableCapabilityNegotiation: false,
} }
cfg.Me.Ident = "goirc" cfg.Me.Ident = "goirc"
if len(args) > 0 && args[0] != "" { if len(args) > 0 && args[0] != "" {
@ -211,6 +224,8 @@ func Client(cfg *Config) *Conn {
bgHandlers: handlerSet(), bgHandlers: handlerSet(),
stRemovers: make([]Remover, 0, len(stHandlers)), stRemovers: make([]Remover, 0, len(stHandlers)),
lastsent: time.Now(), lastsent: time.Now(),
supportedCaps: capabilitySet(),
currCaps: capabilitySet(),
} }
conn.addIntHandlers() conn.addIntHandlers()
return conn return conn
@ -289,6 +304,16 @@ func (conn *Conn) DisableStateTracking() {
} }
} }
// SupportsCapability returns true if the server supports the given capability.
func (conn *Conn) SupportsCapability(cap string) bool {
return conn.supportedCaps.Has(cap)
}
// HasCapability returns true if the given capability has been acked by the server during negotiation.
func (conn *Conn) HasCapability(cap string) bool {
return conn.currCaps.Has(cap)
}
// Per-connection state initialisation. // Per-connection state initialisation.
func (conn *Conn) initialise() { func (conn *Conn) initialise() {
conn.io = nil conn.io = nil

View File

@ -4,7 +4,9 @@ package client
// to manage tracking an irc connection etc. // to manage tracking an irc connection etc.
import ( import (
"sort"
"strings" "strings"
"sync"
"time" "time"
"github.com/fluffle/goirc/logging" "github.com/fluffle/goirc/logging"
@ -18,8 +20,13 @@ var intHandlers = map[string]HandlerFunc{
CTCP: (*Conn).h_CTCP, CTCP: (*Conn).h_CTCP,
NICK: (*Conn).h_NICK, NICK: (*Conn).h_NICK,
PING: (*Conn).h_PING, PING: (*Conn).h_PING,
CAP: (*Conn).h_CAP,
"410": (*Conn).h_410,
} }
// set up the ircv3 capabilities supported by this client which will be requested by default to the server.
var defaultCaps = []string{}
func (conn *Conn) addIntHandlers() { func (conn *Conn) addIntHandlers() {
for n, h := range intHandlers { for n, h := range intHandlers {
// internal handlers are essential for the IRC client // internal handlers are essential for the IRC client
@ -35,6 +42,10 @@ func (conn *Conn) h_PING(line *Line) {
// Handler for initial registration with server once tcp connection is made. // Handler for initial registration with server once tcp connection is made.
func (conn *Conn) h_REGISTER(line *Line) { func (conn *Conn) h_REGISTER(line *Line) {
if conn.cfg.EnableCapabilityNegotiation {
conn.Cap(CAP_LS)
}
if conn.cfg.Pass != "" { if conn.cfg.Pass != "" {
conn.Pass(conn.cfg.Pass) conn.Pass(conn.cfg.Pass)
} }
@ -42,6 +53,134 @@ func (conn *Conn) h_REGISTER(line *Line) {
conn.User(conn.cfg.Me.Ident, conn.cfg.Me.Name) conn.User(conn.cfg.Me.Ident, conn.cfg.Me.Name)
} }
func (conn *Conn) getRequestCapabilities() *capSet {
s := capabilitySet()
// add capabilites supported by the client
s.Add(defaultCaps...)
// add capabilites requested by the user
s.Add(conn.cfg.Capabilites...)
return s
}
func (conn *Conn) negotiateCapabilities(supportedCaps []string) {
conn.supportedCaps.Add(supportedCaps...)
reqCaps := conn.getRequestCapabilities()
reqCaps.Intersect(conn.supportedCaps)
if reqCaps.Size() > 0 {
conn.Cap(CAP_REQ, reqCaps.Slice()...)
} else {
conn.Cap(CAP_END)
}
}
func (conn *Conn) handleCapAck(caps []string) {
for _, cap := range caps {
conn.currCaps.Add(cap)
}
conn.Cap(CAP_END)
}
func (conn *Conn) handleCapNak(caps []string) {
conn.Cap(CAP_END)
}
const (
CAP_LS = "LS"
CAP_REQ = "REQ"
CAP_ACK = "ACK"
CAP_NAK = "NAK"
CAP_END = "END"
)
type capSet struct {
caps map[string]bool
mu sync.RWMutex
}
func capabilitySet() *capSet {
return &capSet{
caps: make(map[string]bool),
}
}
func (c *capSet) Add(caps ...string) {
c.mu.Lock()
for _, cap := range caps {
if strings.HasPrefix(cap, "-") {
c.caps[cap[1:]] = false
} else {
c.caps[cap] = true
}
}
c.mu.Unlock()
}
func (c *capSet) Has(cap string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.caps[cap]
}
// Intersect computes the intersection of two sets.
func (c *capSet) Intersect(other *capSet) {
c.mu.Lock()
for cap := range c.caps {
if !other.Has(cap) {
delete(c.caps, cap)
}
}
c.mu.Unlock()
}
func (c *capSet) Slice() []string {
c.mu.RLock()
defer c.mu.RUnlock()
capSlice := make([]string, 0, len(c.caps))
for cap := range c.caps {
capSlice = append(capSlice, cap)
}
// make output predictable for testing
sort.Strings(capSlice)
return capSlice
}
func (c *capSet) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.caps)
}
// This handler is triggered when an invalid cap command is received by the server.
func (conn *Conn) h_410(line *Line) {
logging.Warn("Invalid cap subcommand: ", line.Args[1])
}
// Handler for capability negotiation commands.
// Note that even if multiple CAP_END commands may be sent to the server during negotiation,
// only the first will be considered.
func (conn *Conn) h_CAP(line *Line) {
subcommand := line.Args[1]
caps := strings.Fields(line.Text())
switch subcommand {
case CAP_LS:
conn.negotiateCapabilities(caps)
case CAP_ACK:
conn.handleCapAck(caps)
case CAP_NAK:
conn.handleCapNak(caps)
}
}
// Handler to trigger a CONNECTED event on receipt of numeric 001 // Handler to trigger a CONNECTED event on receipt of numeric 001
// :<server> 001 <nick> :Welcome message <nick>!<user>@<host> // :<server> 001 <nick> :Welcome message <nick>!<user>@<host>
func (conn *Conn) h_001(line *Line) { func (conn *Conn) h_001(line *Line) {

View File

@ -456,3 +456,54 @@ func Test671(t *testing.T) {
s.st.EXPECT().GetNick("user2").Return(nil) s.st.EXPECT().GetNick("user2").Return(nil)
c.h_671(ParseLine(":irc.server.org 671 test user2 :some ignored text")) c.h_671(ParseLine(":irc.server.org 671 test user2 :some ignored text"))
} }
func TestCap(t *testing.T) {
c, s := setUp(t)
defer s.tearDown()
c.Config().EnableCapabilityNegotiation = true
c.Config().Capabilites = []string{"cap1", "cap2", "cap3", "cap4"}
c.h_REGISTER(&Line{Cmd: REGISTER})
s.nc.Expect("CAP LS")
s.nc.Expect("NICK test")
s.nc.Expect("USER test 12 * :Testing IRC")
// Ensure that capabilities not supported by the server are not requested
s.nc.Send("CAP * LS :cap2 cap4")
s.nc.Expect("CAP REQ :cap2 cap4")
s.nc.Send("CAP * ACK :cap2 cap4")
s.nc.Expect("CAP END")
for _, cap := range []string{"cap2", "cap4"} {
if !c.SupportsCapability(cap) {
t.Fail()
}
if !c.HasCapability(cap) {
t.Fail()
}
}
for _, cap := range []string{"cap1", "cap3"} {
if c.HasCapability(cap) {
t.Fail()
}
}
// test disable capability after registration
s.c.Cap("REQ", "-cap4")
s.nc.Expect("CAP REQ :-cap4")
s.nc.Send("CAP * ACK :-cap4")
s.nc.Expect("CAP END")
if !c.HasCapability("cap2") {
t.Fail()
}
if c.HasCapability("cap4") {
t.Fail()
}
}