diff --git a/doc/ext/bouncer-networks.md b/doc/ext/bouncer-networks.md index 28f99db..aeb16b7 100644 --- a/doc/ext/bouncer-networks.md +++ b/doc/ext/bouncer-networks.md @@ -42,6 +42,13 @@ format. Clients MUST ignore unknown attributes. The `bouncer-networks` extension defines a new `RPL_ISUPPORT` token and a new `BOUNCER` command. +The `bouncer-networks` capability MUST be negociated. This allows the server and +client to behave differently when the client is aware of the bouncer networks. + +The `bouncer-networks-notify` capability MAY be negociated. This allows the +client to signal that it is capable of receiving and correctly processing +bouncer network notifications. + ### `RPL_ISUPPORT` token The server can advertise a `BOUNCER_NETID` token in its `RPL_ISUPPORT` message. @@ -127,13 +134,25 @@ On success, the server replies with: ### Network notifications -When a network attributes are updated, the bouncer MUST broadcast a -`BOUNCER NETWORK` message to all connected clients with the updated attributes: +If the client has negociated the `bouncer-networks-notify` capability, the +server MUST send an initial batch of `BOUNCER NETWORK` messages with the current +list of network, and MUST send notification messages whenever a network is +added, updated or removed. + +If the client has not negociated the `bouncer-networks-notify` capability, the +server MUST NOT send implicit `BOUNCER NETWORK` messages. + +When network attributes are updated, the bouncer MUST broadcast a +`BOUNCER NETWORK` message with the updated attributes to all connected clients +with the `bouncer-networks-notify` capability enabled: BOUNCER NETWORK +The notification SHOULD NOT contain attributes that haven't been updated. + When a network is removed, the bouncer MUST broadcast a `BOUNCER NETWORK` -message to all connected clients: +message with the special argument `*` to all connected clients with the +`bouncer-networks-notify` capability enabled: BOUNCER NETWORK * @@ -231,7 +250,7 @@ Binding to a network: C: CAP LS 302 C: NICK emersion C: USER emersion 0 0 :Simon - S: CAP * LS :sasl=PLAIN bouncer-networks + S: CAP * LS :sasl=PLAIN bouncer-networks bouncer-networks-notify C: CAP REQ :sasl bouncer-networks [SASL authentication] C: BOUNCER BIND 42 @@ -248,7 +267,7 @@ Listing networks: Adding a new network: C: BOUNCER ADDNETWORK name=OFTC;host=irc.oftc.net - S: BOUNCER NETWORK 44 status=connecting + S: BOUNCER NETWORK 44 name=OFTC;host=irc.oftc.net;status=connecting S: BOUNCER ADDNETWORK 44 S: BOUNCER NETWORK 44 status=connected diff --git a/downstream.go b/downstream.go index 1a4dc79..6a49be0 100644 --- a/downstream.go +++ b/downstream.go @@ -57,17 +57,57 @@ var errAuthFailed = ircError{&irc.Message{ Params: []string{"*", "Invalid username or password"}, }} -func parseBouncerNetID(s string) (int64, error) { +func parseBouncerNetID(subcommand, s string) (int64, error) { id, err := strconv.ParseInt(s, 10, 64) if err != nil { return 0, ircError{&irc.Message{ Command: "FAIL", - Params: []string{"BOUNCER", "INVALID_NETID", s, "Invalid network ID"}, + Params: []string{"BOUNCER", "INVALID_NETID", subcommand, s, "Invalid network ID"}, }} } return id, nil } +func getNetworkAttrs(network *network) irc.Tags { + state := "disconnected" + if uc := network.conn; uc != nil { + state = "connected" + } + + attrs := irc.Tags{ + "name": irc.TagValue(network.GetName()), + "state": irc.TagValue(state), + "nickname": irc.TagValue(network.Nick), + } + + if network.Username != "" { + attrs["username"] = irc.TagValue(network.Username) + } + if network.Realname != "" { + attrs["realname"] = irc.TagValue(network.Realname) + } + + if u, err := network.URL(); err == nil { + hasHostPort := true + switch u.Scheme { + case "ircs": + attrs["tls"] = irc.TagValue("1") + case "irc+insecure": + attrs["tls"] = irc.TagValue("0") + default: + hasHostPort = false + } + if host, port, err := net.SplitHostPort(u.Host); err == nil && hasHostPort { + attrs["host"] = irc.TagValue(host) + attrs["port"] = irc.TagValue(port) + } else if hasHostPort { + attrs["host"] = irc.TagValue(u.Host) + } + } + + return attrs +} + // ' ' and ':' break the IRC message wire format, '@' and '!' break prefixes, // '*' and '?' break masks const illegalNickChars = " :@!*?" @@ -75,14 +115,16 @@ const illegalNickChars = " :@!*?" // permanentDownstreamCaps is the list of always-supported downstream // capabilities. var permanentDownstreamCaps = map[string]string{ - "batch": "", - "soju.im/bouncer-networks": "", - "cap-notify": "", - "echo-message": "", - "invite-notify": "", - "message-tags": "", - "sasl": "PLAIN", - "server-time": "", + "batch": "", + "cap-notify": "", + "echo-message": "", + "invite-notify": "", + "message-tags": "", + "sasl": "PLAIN", + "server-time": "", + + "soju.im/bouncer-networks": "", + "soju.im/bouncer-networks-notify": "", } // needAllDownstreamCaps is the list of downstream capabilities that @@ -598,7 +640,7 @@ func (dc *downstreamConn) handleMessageUnregistered(msg *irc.Message) error { }} } - id, err := parseBouncerNetID(idStr) + id, err := parseBouncerNetID(subcommand, idStr) if err != nil { return err } @@ -1022,6 +1064,29 @@ func (dc *downstreamConn) welcome() error { dc.updateNick() dc.updateSupportedCaps() + if dc.caps["soju.im/bouncer-networks-notify"] { + dc.SendMessage(&irc.Message{ + Prefix: dc.srv.prefix(), + Command: "BATCH", + Params: []string{"+networks", "bouncer-networks"}, + }) + dc.user.forEachNetwork(func(network *network) { + idStr := fmt.Sprintf("%v", network.ID) + attrs := getNetworkAttrs(network) + dc.SendMessage(&irc.Message{ + Tags: irc.Tags{"batch": irc.TagValue("networks")}, + Prefix: dc.srv.prefix(), + Command: "BOUNCER", + Params: []string{"NETWORK", idStr, attrs.String()}, + }) + }) + dc.SendMessage(&irc.Message{ + Prefix: dc.srv.prefix(), + Command: "BATCH", + Params: []string{"-networks"}, + }) + } + dc.forEachUpstream(func(uc *upstreamConn) { for _, entry := range uc.channels.innerMap { ch := entry.value.(*upstreamChannel) @@ -1985,49 +2050,13 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error { Params: []string{"+networks", "bouncer-networks"}, }) dc.user.forEachNetwork(func(network *network) { - id := fmt.Sprintf("%v", network.ID) - - state := "disconnected" - if uc := network.conn; uc != nil { - state = "connected" - } - - attrs := irc.Tags{ - "name": irc.TagValue(network.GetName()), - "state": irc.TagValue(state), - "nickname": irc.TagValue(network.Nick), - } - - if network.Username != "" { - attrs["username"] = irc.TagValue(network.Username) - } - if network.Realname != "" { - attrs["realname"] = irc.TagValue(network.Realname) - } - - if u, err := network.URL(); err == nil { - hasHostPort := true - switch u.Scheme { - case "ircs": - attrs["tls"] = irc.TagValue("1") - case "irc+insecure": - attrs["tls"] = irc.TagValue("0") - default: - hasHostPort = false - } - if host, port, err := net.SplitHostPort(u.Host); err == nil && hasHostPort { - attrs["host"] = irc.TagValue(host) - attrs["port"] = irc.TagValue(port) - } else if hasHostPort { - attrs["host"] = irc.TagValue(u.Host) - } - } - + idStr := fmt.Sprintf("%v", network.ID) + attrs := getNetworkAttrs(network) dc.SendMessage(&irc.Message{ Tags: irc.Tags{"batch": irc.TagValue("networks")}, Prefix: dc.srv.prefix(), Command: "BOUNCER", - Params: []string{"NETWORK", id, attrs.String()}, + Params: []string{"NETWORK", idStr, attrs.String()}, }) }) dc.SendMessage(&irc.Message{ @@ -2095,7 +2124,7 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error { if err := parseMessageParams(msg, nil, &idStr, &attrsStr); err != nil { return err } - id, err := parseBouncerNetID(idStr) + id, err := parseBouncerNetID(subcommand, idStr) if err != nil { return err } @@ -2105,7 +2134,7 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error { if net == nil { return ircError{&irc.Message{ Command: "FAIL", - Params: []string{"BOUNCER", "INVALID_NETID", idStr, "Invalid network ID"}, + Params: []string{"BOUNCER", "INVALID_NETID", subcommand, idStr, "Invalid network ID"}, }} } @@ -2148,7 +2177,7 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error { if err := parseMessageParams(msg, nil, &idStr); err != nil { return err } - id, err := parseBouncerNetID(idStr) + id, err := parseBouncerNetID(subcommand, idStr) if err != nil { return err } @@ -2157,7 +2186,7 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error { if net == nil { return ircError{&irc.Message{ Command: "FAIL", - Params: []string{"BOUNCER", "INVALID_NETID", idStr, "Invalid network ID"}, + Params: []string{"BOUNCER", "INVALID_NETID", subcommand, idStr, "Invalid network ID"}, }} } diff --git a/user.go b/user.go index 48dbcff..e16abef 100644 --- a/user.go +++ b/user.go @@ -518,7 +518,7 @@ func (u *user) run() { uc.forEachDownstream(func(dc *downstreamConn) { dc.updateSupportedCaps() - if dc.caps["soju.im/bouncer-networks"] { + if dc.caps["soju.im/bouncer-networks-notify"] { dc.SendMessage(&irc.Message{ Prefix: dc.srv.prefix(), Command: "BOUNCER", @@ -657,7 +657,7 @@ func (u *user) handleUpstreamDisconnected(uc *upstreamConn) { uc.forEachDownstream(func(dc *downstreamConn) { dc.updateSupportedCaps() - if dc.caps["soju.im/bouncer-networks"] { + if dc.caps["soju.im/bouncer-networks-notify"] { dc.SendMessage(&irc.Message{ Prefix: dc.srv.prefix(), Command: "BOUNCER", @@ -725,14 +725,14 @@ func (u *user) createNetwork(record *Network) (*network, error) { u.addNetwork(network) - // TODO: broadcast network status idStr := fmt.Sprintf("%v", network.ID) + attrs := getNetworkAttrs(network) u.forEachDownstream(func(dc *downstreamConn) { - if dc.caps["soju.im/bouncer-networks"] { + if dc.caps["soju.im/bouncer-networks-notify"] { dc.SendMessage(&irc.Message{ Prefix: dc.srv.prefix(), Command: "BOUNCER", - Params: []string{"NETWORK", idStr, "network=" + network.GetName()}, + Params: []string{"NETWORK", idStr, attrs.String()}, }) } }) @@ -790,7 +790,18 @@ func (u *user) updateNetwork(record *Network) (*network, error) { // This will re-connect to the upstream server u.addNetwork(updatedNetwork) - // TODO: broadcast BOUNCER NETWORK notifications + // TODO: only broadcast attributes that have changed + idStr := fmt.Sprintf("%v", updatedNetwork.ID) + attrs := getNetworkAttrs(updatedNetwork) + u.forEachDownstream(func(dc *downstreamConn) { + if dc.caps["soju.im/bouncer-networks-notify"] { + dc.SendMessage(&irc.Message{ + Prefix: dc.srv.prefix(), + Command: "BOUNCER", + Params: []string{"NETWORK", idStr, attrs.String()}, + }) + } + }) return updatedNetwork, nil } @@ -809,7 +820,7 @@ func (u *user) deleteNetwork(id int64) error { idStr := fmt.Sprintf("%v", network.ID) u.forEachDownstream(func(dc *downstreamConn) { - if dc.caps["soju.im/bouncer-networks"] { + if dc.caps["soju.im/bouncer-networks-notify"] { dc.SendMessage(&irc.Message{ Prefix: dc.srv.prefix(), Command: "BOUNCER",