diff --git a/irc/batch.go b/irc/batch.go new file mode 100644 index 00000000..f5bfd852 --- /dev/null +++ b/irc/batch.go @@ -0,0 +1,78 @@ +// Copyright (c) 2017 Daniel Oaks +// released under the MIT license + +package irc + +import ( + "strconv" + "time" + + "github.com/goshuirc/irc-go/ircmsg" + "github.com/oragono/oragono/irc/caps" +) + +const ( + // maxBatchID is the maximum ID the batch counter can get to before it rotates. + // + // Batch IDs are made up of the current unix timestamp plus a rolling int ID that's + // incremented for every new batch. It's an alright solution and will work unless we get + // more than maxId batches per nanosecond. Later on when we have S2S linking, the batch + // ID will also contain the server ID to ensure they stay unique. + maxBatchID uint64 = 60000 +) + +// BatchManager helps generate new batches and new batch IDs. +type BatchManager struct { + idCounter uint64 +} + +// NewBatchManager returns a new Manager. +func NewBatchManager() *BatchManager { + return &BatchManager{} +} + +// NewID returns a new batch ID that should be unique. +func (bm *BatchManager) NewID() string { + bm.idCounter++ + if maxBatchID < bm.idCounter { + bm.idCounter = 0 + } + + return strconv.FormatInt(time.Now().UnixNano(), 10) + strconv.FormatUint(bm.idCounter, 10) +} + +// Batch represents an IRCv3 batch. +type Batch struct { + ID string + Type string + Params []string +} + +// New returns a new batch. +func (bm *BatchManager) New(batchType string, params ...string) *Batch { + newBatch := Batch{ + ID: bm.NewID(), + Type: batchType, + Params: params, + } + + return &newBatch +} + +// Start sends the batch start message to this client +func (b *Batch) Start(client *Client, tags *map[string]ircmsg.TagValue) { + if client.capabilities.Has(caps.Batch) { + params := []string{"+" + b.ID, b.Type} + for _, param := range b.Params { + params = append(params, param) + } + client.Send(tags, client.server.name, "BATCH", params...) + } +} + +// End sends the batch end message to this client +func (b *Batch) End(client *Client) { + if client.capabilities.Has(caps.Batch) { + client.Send(nil, client.server.name, "BATCH", "-"+b.ID) + } +} diff --git a/irc/capability.go b/irc/capability.go index afb4760e..1c197df6 100644 --- a/irc/capability.go +++ b/irc/capability.go @@ -14,7 +14,7 @@ import ( var ( // SupportedCapabilities are the caps we advertise. // MaxLine, SASL and STS are set during server startup. - SupportedCapabilities = caps.NewSet(caps.AccountTag, caps.AccountNotify, caps.AwayNotify, caps.CapNotify, caps.ChgHost, caps.EchoMessage, caps.ExtendedJoin, caps.InviteNotify, caps.MessageTags, caps.MultiPrefix, caps.Rename, caps.ServerTime, caps.UserhostInNames) + SupportedCapabilities = caps.NewSet(caps.AccountTag, caps.AccountNotify, caps.AwayNotify, caps.Batch, caps.CapNotify, caps.ChgHost, caps.EchoMessage, caps.ExtendedJoin, caps.InviteNotify, caps.LabeledResponse, caps.MessageTags, caps.MultiPrefix, caps.Rename, caps.ServerTime, caps.UserhostInNames) // CapValues are the actual values we advertise to v3.2 clients. // actual values are set during server startup. diff --git a/irc/client.go b/irc/client.go index 241c8a51..7a0439d3 100644 --- a/irc/client.go +++ b/irc/client.go @@ -621,7 +621,7 @@ func (client *Client) SendFromClient(msgid string, from *Client, tags *map[strin var ( // these are all the output commands that MUST have their last param be a trailing. - // this is needed because silly clients like to treat trailing as separate from the + // this is needed because dumb clients like to treat trailing params separately from the // other params in messages. commandsThatMustUseTrailing = map[string]bool{ "PRIVMSG": true, @@ -632,6 +632,47 @@ var ( } ) +// SendRawMessage sends a raw message to the client. +func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error { + // use dumb hack to force the last param to be a trailing param if required + var usedTrailingHack bool + if commandsThatMustUseTrailing[strings.ToUpper(message.Command)] && len(message.Params) > 0 { + lastParam := message.Params[len(message.Params)-1] + // to force trailing, we ensure the final param contains a space + if !strings.Contains(lastParam, " ") { + message.Params[len(message.Params)-1] = lastParam + " " + usedTrailingHack = true + } + } + + // assemble message + maxlenTags, maxlenRest := client.maxlens() + line, err := message.LineMaxLen(maxlenTags, maxlenRest) + if err != nil { + // try not to fail quietly - especially useful when running tests, as a note to dig deeper + // log.Println("Error assembling message:") + // spew.Dump(message) + // debug.PrintStack() + + message = ircmsg.MakeMessage(nil, client.server.name, ERR_UNKNOWNERROR, "*", "Error assembling message for sending") + line, _ := message.Line() + + // if we used the trailing hack, we need to strip the final space we appended earlier on + if usedTrailingHack { + line = line[:len(line)-3] + "\r\n" + } + + client.socket.Write(line) + return err + } + + client.server.logger.Debug("useroutput", client.nick, " ->", strings.TrimRight(line, "\r\n")) + + client.socket.Write(line) + + return nil +} + // Send sends an IRC line to the client. func (client *Client) Send(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) error { // attach server-time @@ -644,41 +685,9 @@ func (client *Client) Send(tags *map[string]ircmsg.TagValue, prefix string, comm } } - // force trailing, if message requires it - var usedTrailingHack bool - if commandsThatMustUseTrailing[strings.ToUpper(command)] && len(params) > 0 { - lastParam := params[len(params)-1] - // to force trailing, we ensure the final param contains a space - if !strings.Contains(lastParam, " ") { - params[len(params)-1] = lastParam + " " - usedTrailingHack = true - } - } - // send out the message message := ircmsg.MakeMessage(tags, prefix, command, params...) - maxlenTags, maxlenRest := client.maxlens() - line, err := message.LineMaxLen(maxlenTags, maxlenRest) - if err != nil { - // try not to fail quietly - especially useful when running tests, as a note to dig deeper - // log.Println("Error assembling message:") - // spew.Dump(message) - // debug.PrintStack() - - message = ircmsg.MakeMessage(nil, client.server.name, ERR_UNKNOWNERROR, "*", "Error assembling message for sending") - line, _ := message.Line() - client.socket.Write(line) - return err - } - - // is we used the trailing hack, we need to strip the final space we appended earlier - if usedTrailingHack { - line = line[:len(line)-3] + "\r\n" - } - - client.server.logger.Debug("useroutput", client.nick, " ->", strings.TrimRight(line, "\r\n")) - - client.socket.Write(line) + client.SendRawMessage(message) return nil } diff --git a/irc/responsebuffer.go b/irc/responsebuffer.go new file mode 100644 index 00000000..29de865b --- /dev/null +++ b/irc/responsebuffer.go @@ -0,0 +1,120 @@ +// Copyright (c) 2016-2017 Daniel Oaks +// released under the MIT license + +package irc + +import ( + "time" + + "github.com/goshuirc/irc-go/ircmsg" + "github.com/oragono/oragono/irc/caps" +) + +// ResponseBuffer - put simply - buffers messages and then outputs them to a given client. +// +// Using a ResponseBuffer lets you really easily implement labeled-response, since the +// buffer will silently create a batch if required and label the outgoing messages as +// necessary (or leave it off and simply tag the outgoing message). +type ResponseBuffer struct { + Label string + target *Client + messages []ircmsg.IrcMessage +} + +// GetLabel returns the label from the given message. +func GetLabel(msg ircmsg.IrcMessage) string { + return msg.Tags["label"].Value +} + +// NewResponseBuffer returns a new ResponseBuffer. +func NewResponseBuffer(target *Client) *ResponseBuffer { + return &ResponseBuffer{ + target: target, + } +} + +// Add adds a standard new message to our queue. +func (rb *ResponseBuffer) Add(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) { + message := ircmsg.MakeMessage(tags, prefix, command, params...) + + rb.messages = append(rb.messages, message) +} + +// AddFromClient adds a new message from a specific client to our queue. +func (rb *ResponseBuffer) AddFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, params ...string) { + // attach account-tag + if rb.target.capabilities.Has(caps.AccountTag) && from.account != &NoAccount { + if tags == nil { + tags = ircmsg.MakeTags("account", from.account.Name) + } else { + (*tags)["account"] = ircmsg.MakeTagValue(from.account.Name) + } + } + // attach message-id + if len(msgid) > 0 && rb.target.capabilities.Has(caps.MessageTags) { + if tags == nil { + tags = ircmsg.MakeTags("draft/msgid", msgid) + } else { + (*tags)["draft/msgid"] = ircmsg.MakeTagValue(msgid) + } + } + + rb.Add(tags, from.nickMaskString, command, params...) +} + +// AddSplitMessageFromClient adds a new split message from a specific client to our queue. +func (rb *ResponseBuffer) AddSplitMessageFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, target string, message SplitMessage) { + if rb.target.capabilities.Has(caps.MaxLine) { + rb.AddFromClient(msgid, from, tags, command, target, message.ForMaxLine) + } else { + for _, str := range message.For512 { + rb.AddFromClient(msgid, from, tags, command, target, str) + } + } +} + +// Send sends the message to our target client. +func (rb *ResponseBuffer) Send() error { + // make batch and all if required + var batch *Batch + useLabel := rb.target.capabilities.Has(caps.LabeledResponse) && rb.Label != "" + if useLabel && 1 < len(rb.messages) && rb.target.capabilities.Has(caps.Batch) { + batch = rb.target.server.batches.New("draft/labeled-response") + } + + // if label but no batch, add label to first message + if useLabel && batch == nil { + message := rb.messages[0] + message.Tags["label"] = ircmsg.MakeTagValue(rb.Label) + rb.messages[0] = message + } + + // start batch if required + if batch != nil { + batch.Start(rb.target, ircmsg.MakeTags("label", rb.Label)) + } + + // send each message out + for _, message := range rb.messages { + // attach server-time if needed + if rb.target.capabilities.Has(caps.ServerTime) { + t := time.Now().UTC().Format("2006-01-02T15:04:05.999Z") + message.Tags["time"] = ircmsg.MakeTagValue(t) + } + + // attach batch ID + if batch != nil { + message.Tags["batch"] = ircmsg.MakeTagValue(batch.ID) + } + + // send message out + rb.target.SendRawMessage(message) + } + + // end batch if required + if batch != nil { + batch.End(rb.target) + } + + return nil +} diff --git a/irc/server.go b/irc/server.go index 51048d01..213dd6c4 100644 --- a/irc/server.go +++ b/irc/server.go @@ -78,6 +78,7 @@ type Server struct { accountAuthenticationEnabled bool accountRegistration *AccountRegistration accounts map[string]*ClientAccount + batches *BatchManager channelRegistrationEnabled bool channels ChannelNameMap channelJoinPartMutex sync.Mutex // used when joining/parting channels to prevent stomping over each others' access and all @@ -146,6 +147,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) { // initialize data structures server := &Server{ accounts: make(map[string]*ClientAccount), + batches: NewBatchManager(), channels: *NewChannelNameMap(), clients: NewClientLookupSet(), commands: make(chan Command), @@ -988,8 +990,12 @@ func whoisHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { masksString = msg.Params[0] } + rb := NewResponseBuffer(client) + rb.Label = GetLabel(msg) + if len(strings.TrimSpace(masksString)) < 1 { - client.Send(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, "No masks given") + rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, "No masks given") + rb.Send() return false } @@ -998,16 +1004,16 @@ func whoisHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { for _, mask := range masks { casefoldedMask, err := Casefold(mask) if err != nil { - client.Send(nil, client.server.name, ERR_NOSUCHNICK, client.nick, mask, "No such nick") + rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, mask, "No such nick") continue } matches := server.clients.FindAll(casefoldedMask) if len(matches) == 0 { - client.Send(nil, client.server.name, ERR_NOSUCHNICK, client.nick, mask, "No such nick") + rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, mask, "No such nick") continue } for mclient := range matches { - client.getWhoisOf(mclient) + client.getWhoisOf(mclient, rb) } } } else { @@ -1015,36 +1021,37 @@ func whoisHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { casefoldedMask, err := Casefold(strings.Split(masksString, ",")[0]) mclient := server.clients.Get(casefoldedMask) if err != nil || mclient == nil { - client.Send(nil, client.server.name, ERR_NOSUCHNICK, client.nick, masksString, "No such nick") + rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, masksString, "No such nick") // fall through, ENDOFWHOIS is always sent } else { - client.getWhoisOf(mclient) + client.getWhoisOf(mclient, rb) } } - client.Send(nil, server.name, RPL_ENDOFWHOIS, client.nick, masksString, "End of /WHOIS list") + rb.Add(nil, server.name, RPL_ENDOFWHOIS, client.nick, masksString, "End of /WHOIS list") + rb.Send() return false } -func (client *Client) getWhoisOf(target *Client) { - client.Send(nil, client.server.name, RPL_WHOISUSER, client.nick, target.nick, target.username, target.hostname, "*", target.realname) +func (client *Client) getWhoisOf(target *Client, rb *ResponseBuffer) { + rb.Add(nil, client.server.name, RPL_WHOISUSER, client.nick, target.nick, target.username, target.hostname, "*", target.realname) whoischannels := client.WhoisChannelsNames(target) if whoischannels != nil { - client.Send(nil, client.server.name, RPL_WHOISCHANNELS, client.nick, target.nick, strings.Join(whoischannels, " ")) + rb.Add(nil, client.server.name, RPL_WHOISCHANNELS, client.nick, target.nick, strings.Join(whoischannels, " ")) } if target.class != nil { - client.Send(nil, client.server.name, RPL_WHOISOPERATOR, client.nick, target.nick, target.whoisLine) + rb.Add(nil, client.server.name, RPL_WHOISOPERATOR, client.nick, target.nick, target.whoisLine) } if client.flags[Operator] || client == target { - client.Send(nil, client.server.name, RPL_WHOISACTUALLY, client.nick, target.nick, fmt.Sprintf("%s@%s", target.username, utils.LookupHostname(target.IPString())), target.IPString(), "Actual user@host, Actual IP") + rb.Add(nil, client.server.name, RPL_WHOISACTUALLY, client.nick, target.nick, fmt.Sprintf("%s@%s", target.username, utils.LookupHostname(target.IPString())), target.IPString(), "Actual user@host, Actual IP") } if target.flags[TLS] { - client.Send(nil, client.server.name, RPL_WHOISSECURE, client.nick, target.nick, "is using a secure connection") + rb.Add(nil, client.server.name, RPL_WHOISSECURE, client.nick, target.nick, "is using a secure connection") } if target.certfp != "" && (client.flags[Operator] || client == target) { - client.Send(nil, client.server.name, RPL_WHOISCERTFP, client.nick, target.nick, fmt.Sprintf("has client certificate fingerprint %s", target.certfp)) + rb.Add(nil, client.server.name, RPL_WHOISCERTFP, client.nick, target.nick, fmt.Sprintf("has client certificate fingerprint %s", target.certfp)) } - client.Send(nil, client.server.name, RPL_WHOISIDLE, client.nick, target.nick, strconv.FormatUint(target.IdleSeconds(), 10), strconv.FormatInt(target.SignonTime(), 10), "seconds idle, signon time") + rb.Add(nil, client.server.name, RPL_WHOISIDLE, client.nick, target.nick, strconv.FormatUint(target.IdleSeconds(), 10), strconv.FormatInt(target.SignonTime(), 10), "seconds idle, signon time") } // RplWhoReplyNoMutex returns the WHO reply between one user and another channel/user.