From 241e27b00efbb3fd2cb608229085a7da355690d8 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 2 Nov 2021 18:15:45 +0100 Subject: [PATCH] Add support for WHOX This adds support for WHOX, without bothering about flags and mask2 because Solanum and Ergo [1] don't support it either. The motivation is to allow clients to reliably query account names. It's not possible to use WHOX tokens to route replies to the right client, because RPL_ENDOFWHO doesn't contain it. [1]: https://github.com/ergochat/ergo/pull/1184 Closes: https://todo.sr.ht/~emersion/soju/135 --- downstream.go | 83 +++++++++++++++++++++++++++++++++++++-------------- irc.go | 78 +++++++++++++++++++++++++++++++++++++++++++++++ upstream.go | 5 ++++ 3 files changed, 143 insertions(+), 23 deletions(-) diff --git a/downstream.go b/downstream.go index d3bb8d5..97b7674 100644 --- a/downstream.go +++ b/downstream.go @@ -236,6 +236,7 @@ var passthroughIsupport = map[string]bool{ "TOPICLEN": true, "USERLEN": true, "UTF8ONLY": true, + "WHOX": true, } type downstreamConn struct { @@ -1157,6 +1158,10 @@ func (dc *downstreamConn) welcome() error { isupport = append(isupport, fmt.Sprintf("BOUNCER_NETID=%v", dc.network.ID)) } + if dc.network == nil && dc.caps["soju.im/bouncer-networks"] { + isupport = append(isupport, "WHOX") + } + if uc := dc.upstream(); uc != nil { for k := range passthroughIsupport { v, ok := uc.isupport[k] @@ -1882,6 +1887,10 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error { }) } } + // For WHOX docs, see: + // - http://faerion.sourceforge.net/doc/irc/whox.var + // - https://github.com/quakenet/snircd/blob/master/doc/readme.who + // Note, many features aren't widely implemented, such as flags and mask2 case "WHO": if len(msg.Params) == 0 { // TODO: support WHO without parameters @@ -1893,52 +1902,80 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error { return nil } - // TODO: support WHO masks - entity := msg.Params[0] - entityCM := casemapASCII(entity) + // Clients will use the first mask to match RPL_ENDOFWHO + endOfWhoToken := msg.Params[0] - if dc.network == nil && entityCM == dc.nickCM { + // TODO: add support for WHOX mask2 + mask := msg.Params[0] + var options string + if len(msg.Params) > 1 { + options = msg.Params[1] + } + + optionsParts := strings.SplitN(options, "%", 2) + // TODO: add support for WHOX flags in optionsParts[0] + var fields, whoxToken string + if len(optionsParts) == 2 { + optionsParts := strings.SplitN(optionsParts[1], ",", 2) + fields = strings.ToLower(optionsParts[0]) + if len(optionsParts) == 2 && strings.Contains(fields, "t") { + whoxToken = optionsParts[1] + } + } + + // TODO: support mixed bouncer/upstream WHO queries + maskCM := casemapASCII(mask) + if dc.network == nil && maskCM == dc.nickCM { // TODO: support AWAY (H/G) in self WHO reply flags := "H" if dc.user.Admin { flags += "*" } - dc.SendMessage(&irc.Message{ - Prefix: dc.srv.prefix(), - Command: irc.RPL_WHOREPLY, - Params: []string{dc.nick, "*", dc.user.Username, dc.hostname, dc.srv.Hostname, dc.nick, flags, "0 " + dc.realname}, - }) + info := whoxInfo{ + Token: whoxToken, + Username: dc.user.Username, + Hostname: dc.hostname, + Server: dc.srv.Hostname, + Nickname: dc.nick, + Flags: flags, + Realname: dc.realname, + } + dc.SendMessage(generateWHOXReply(dc.srv.prefix(), dc.nick, fields, &info)) dc.SendMessage(&irc.Message{ Prefix: dc.srv.prefix(), Command: irc.RPL_ENDOFWHO, - Params: []string{dc.nick, dc.nick, "End of /WHO list"}, + Params: []string{dc.nick, endOfWhoToken, "End of /WHO list"}, }) return nil } - if entityCM == serviceNickCM { - dc.SendMessage(&irc.Message{ - Prefix: dc.srv.prefix(), - Command: irc.RPL_WHOREPLY, - Params: []string{serviceNick, "*", servicePrefix.User, servicePrefix.Host, dc.srv.Hostname, serviceNick, "H*", "0 " + serviceRealname}, - }) + if maskCM == serviceNickCM { + info := whoxInfo{ + Token: whoxToken, + Username: servicePrefix.User, + Hostname: servicePrefix.Host, + Server: dc.srv.Hostname, + Nickname: serviceNick, + Flags: "H*", + Realname: serviceRealname, + } + dc.SendMessage(generateWHOXReply(dc.srv.prefix(), dc.nick, fields, &info)) dc.SendMessage(&irc.Message{ Prefix: dc.srv.prefix(), Command: irc.RPL_ENDOFWHO, - Params: []string{dc.nick, serviceNick, "End of /WHO list"}, + Params: []string{dc.nick, endOfWhoToken, "End of /WHO list"}, }) return nil } - uc, upstreamName, err := dc.unmarshalEntity(entity) + // TODO: properly support WHO masks + uc, upstreamMask, err := dc.unmarshalEntity(mask) if err != nil { return err } - var params []string - if len(msg.Params) == 2 { - params = []string{upstreamName, msg.Params[1]} - } else { - params = []string{upstreamName} + params := []string{upstreamMask} + if options != "" { + params = append(params, options) } uc.SendMessageLabeled(dc.id, &irc.Message{ diff --git a/irc.go b/irc.go index 3c1d5cf..4d519c9 100644 --- a/irc.go +++ b/irc.go @@ -17,6 +17,7 @@ const ( rpl_globalusers = "266" rpl_creationtime = "329" rpl_topicwhotime = "333" + rpl_whospcrpl = "354" err_invalidcapcmd = "410" ) @@ -682,3 +683,80 @@ func parseChatHistoryBound(param string) time.Time { return time.Time{} } } + +type whoxInfo struct { + Token string + Username string + Hostname string + Server string + Nickname string + Flags string + Account string + Realname string +} + +func generateWHOXReply(prefix *irc.Prefix, nick, fields string, info *whoxInfo) *irc.Message { + if fields == "" { + return &irc.Message{ + Prefix: prefix, + Command: irc.RPL_WHOREPLY, + Params: []string{nick, "*", info.Username, info.Hostname, info.Server, info.Nickname, info.Flags, "0 " + info.Realname}, + } + } + + fieldSet := make(map[byte]bool) + for i := 0; i < len(fields); i++ { + fieldSet[fields[i]] = true + } + + var params []string + if fieldSet['t'] { + params = append(params, info.Token) + } + if fieldSet['c'] { + params = append(params, "*") + } + if fieldSet['u'] { + params = append(params, info.Username) + } + if fieldSet['i'] { + params = append(params, "255.255.255.255") + } + if fieldSet['h'] { + params = append(params, info.Hostname) + } + if fieldSet['s'] { + params = append(params, info.Server) + } + if fieldSet['n'] { + params = append(params, info.Nickname) + } + if fieldSet['f'] { + params = append(params, info.Flags) + } + if fieldSet['d'] { + params = append(params, "0") + } + if fieldSet['l'] { // idle time + params = append(params, "0") + } + if fieldSet['a'] { + account := "0" // WHOX uses "0" to mean "no account" + if info.Account != "" && info.Account != "*" { + account = info.Account + } + params = append(params, account) + } + if fieldSet['o'] { + params = append(params, "0") + } + if fieldSet['r'] { + params = append(params, info.Realname) + } + + return &irc.Message{ + Prefix: prefix, + Command: rpl_whospcrpl, + Params: append([]string{nick}, params...), + } +} diff --git a/upstream.go b/upstream.go index ea1e5b0..fc9b2fd 100644 --- a/upstream.go +++ b/upstream.go @@ -1452,6 +1452,11 @@ func (uc *upstreamConn) handleMessage(msg *irc.Message) error { // Ignore case irc.RPL_YOURHOST, irc.RPL_CREATED: // Ignore + case rpl_whospcrpl: + // Not supported in multi-upstream mode, forward as-is + uc.forEachDownstream(func(dc *downstreamConn) { + dc.SendMessage(msg) + }) case irc.RPL_LUSERCLIENT, irc.RPL_LUSEROP, irc.RPL_LUSERUNKNOWN, irc.RPL_LUSERCHANNELS, irc.RPL_LUSERME: fallthrough case irc.RPL_STATSVLINE, rpl_statsping, irc.RPL_STATSBLINE, irc.RPL_STATSDLINE: