1// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net> 2// released under the MIT license 3 4package irc 5 6import ( 7 "runtime/debug" 8 "time" 9 10 "github.com/ergochat/ergo/irc/caps" 11 "github.com/ergochat/ergo/irc/utils" 12 "github.com/ergochat/irc-go/ircmsg" 13) 14 15const ( 16 // https://ircv3.net/specs/extensions/labeled-response.html 17 defaultBatchType = "labeled-response" 18) 19 20// ResponseBuffer - put simply - buffers messages and then outputs them to a given client. 21// 22// Using a ResponseBuffer lets you really easily implement labeled-response, since the 23// buffer will silently create a batch if required and label the outgoing messages as 24// necessary (or leave it off and simply tag the outgoing message). 25type ResponseBuffer struct { 26 Label string // label if this is a labeled response batch 27 batchID string // ID of the labeled response batch, if one has been initiated 28 batchType string // type of the labeled response batch (currently either `labeled-response` or `chathistory`) 29 30 // stack of batch IDs of nested batches, which are handled separately 31 // from the underlying labeled-response batch. starting a new nested batch 32 // unconditionally enqueues its batch start message; subsequent messages 33 // are tagged with the nested batch ID, until nested batch end. 34 // (the nested batch start itself may have no batch tag, or the batch tag of the 35 // underlying labeled-response batch, or the batch tag of the next outermost 36 // nested batch.) 37 nestedBatches []string 38 39 messages []ircmsg.Message 40 finalized bool 41 target *Client 42 session *Session 43} 44 45// GetLabel returns the label from the given message. 46func GetLabel(msg ircmsg.Message) string { 47 _, value := msg.GetTag(caps.LabelTagName) 48 return value 49} 50 51// NewResponseBuffer returns a new ResponseBuffer. 52func NewResponseBuffer(session *Session) *ResponseBuffer { 53 return &ResponseBuffer{ 54 session: session, 55 target: session.client, 56 batchType: defaultBatchType, 57 } 58} 59 60func (rb *ResponseBuffer) AddMessage(msg ircmsg.Message) { 61 if rb.finalized { 62 rb.target.server.logger.Error("internal", "message added to finalized ResponseBuffer, undefined behavior") 63 debug.PrintStack() 64 // TODO(dan): send a NOTICE to the end user with a string representation of the message, 65 // for debugging purposes 66 return 67 } 68 69 rb.session.setTimeTag(&msg, time.Time{}) 70 rb.setNestedBatchTag(&msg) 71 72 rb.messages = append(rb.messages, msg) 73} 74 75func (rb *ResponseBuffer) setNestedBatchTag(msg *ircmsg.Message) { 76 if 0 < len(rb.nestedBatches) { 77 msg.SetTag("batch", rb.nestedBatches[len(rb.nestedBatches)-1]) 78 } 79} 80 81// Add adds a standard new message to our queue. 82func (rb *ResponseBuffer) Add(tags map[string]string, prefix string, command string, params ...string) { 83 rb.AddMessage(ircmsg.MakeMessage(tags, prefix, command, params...)) 84} 85 86// Broadcast adds a standard new message to our queue, then sends an unlabeled copy 87// to all other sessions. 88func (rb *ResponseBuffer) Broadcast(tags map[string]string, prefix string, command string, params ...string) { 89 // can't reuse the Message object because of tag pollution :-\ 90 rb.Add(tags, prefix, command, params...) 91 for _, session := range rb.session.client.Sessions() { 92 if session != rb.session { 93 session.Send(tags, prefix, command, params...) 94 } 95 } 96} 97 98// AddFromClient adds a new message from a specific client to our queue. 99func (rb *ResponseBuffer) AddFromClient(time time.Time, msgid string, fromNickMask string, fromAccount string, isBot bool, tags map[string]string, command string, params ...string) { 100 msg := ircmsg.MakeMessage(nil, fromNickMask, command, params...) 101 if rb.session.capabilities.Has(caps.MessageTags) { 102 msg.UpdateTags(tags) 103 } 104 105 // attach account-tag 106 if rb.session.capabilities.Has(caps.AccountTag) && fromAccount != "*" { 107 msg.SetTag("account", fromAccount) 108 } 109 // attach message-id 110 if rb.session.capabilities.Has(caps.MessageTags) { 111 if len(msgid) != 0 { 112 msg.SetTag("msgid", msgid) 113 } 114 if isBot { 115 msg.SetTag(caps.BotTagName, "") 116 } 117 } 118 // attach server-time 119 rb.session.setTimeTag(&msg, time) 120 121 rb.AddMessage(msg) 122} 123 124// AddSplitMessageFromClient adds a new split message from a specific client to our queue. 125func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAccount string, isBot bool, tags map[string]string, command string, target string, message utils.SplitMessage) { 126 if message.Is512() { 127 if message.Message == "" { 128 // XXX this is a TAGMSG 129 rb.AddFromClient(message.Time, message.Msgid, fromNickMask, fromAccount, isBot, tags, command, target) 130 } else { 131 rb.AddFromClient(message.Time, message.Msgid, fromNickMask, fromAccount, isBot, tags, command, target, message.Message) 132 } 133 } else { 134 if rb.session.capabilities.Has(caps.Multiline) { 135 batch := composeMultilineBatch(rb.session.generateBatchID(), fromNickMask, fromAccount, isBot, tags, command, target, message) 136 rb.setNestedBatchTag(&batch[0]) 137 rb.setNestedBatchTag(&batch[len(batch)-1]) 138 rb.messages = append(rb.messages, batch...) 139 } else { 140 for i, messagePair := range message.Split { 141 var msgid string 142 if i == 0 { 143 msgid = message.Msgid 144 } 145 rb.AddFromClient(message.Time, msgid, fromNickMask, fromAccount, isBot, tags, command, target, messagePair.Message) 146 } 147 } 148 } 149} 150 151func (rb *ResponseBuffer) addEchoMessage(tags map[string]string, nickMask, accountName, command, target string, message utils.SplitMessage) { 152 // TODO fix isBot here 153 if rb.session.capabilities.Has(caps.EchoMessage) { 154 hasTagsCap := rb.session.capabilities.Has(caps.MessageTags) 155 if command == "TAGMSG" { 156 if hasTagsCap { 157 rb.AddFromClient(message.Time, message.Msgid, nickMask, accountName, false, tags, command, target) 158 } 159 } else { 160 tagsToSend := tags 161 if !hasTagsCap { 162 tagsToSend = nil 163 } 164 rb.AddSplitMessageFromClient(nickMask, accountName, false, tagsToSend, command, target, message) 165 } 166 } 167} 168 169func (rb *ResponseBuffer) sendBatchStart(blocking bool) { 170 if rb.batchID != "" { 171 // batch already initialized 172 return 173 } 174 175 rb.batchID = rb.session.generateBatchID() 176 message := ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "+"+rb.batchID, rb.batchType) 177 if rb.Label != "" { 178 message.SetTag(caps.LabelTagName, rb.Label) 179 } 180 rb.session.SendRawMessage(message, blocking) 181} 182 183func (rb *ResponseBuffer) sendBatchEnd(blocking bool) { 184 if rb.batchID == "" { 185 // we are not sending a batch, skip this 186 return 187 } 188 189 message := ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "-"+rb.batchID) 190 rb.session.SendRawMessage(message, blocking) 191} 192 193// Starts a nested batch (see the ResponseBuffer struct definition for a description of 194// how this works) 195func (rb *ResponseBuffer) StartNestedBatch(batchType string, params ...string) (batchID string) { 196 batchID = rb.session.generateBatchID() 197 msgParams := make([]string, len(params)+2) 198 msgParams[0] = "+" + batchID 199 msgParams[1] = batchType 200 copy(msgParams[2:], params) 201 rb.AddMessage(ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", msgParams...)) 202 rb.nestedBatches = append(rb.nestedBatches, batchID) 203 return 204} 205 206// Ends a nested batch 207func (rb *ResponseBuffer) EndNestedBatch(batchID string) { 208 if batchID == "" { 209 return 210 } 211 212 if 0 == len(rb.nestedBatches) || rb.nestedBatches[len(rb.nestedBatches)-1] != batchID { 213 rb.target.server.logger.Error("internal", "inconsistent batch nesting detected") 214 debug.PrintStack() 215 return 216 } 217 218 rb.nestedBatches = rb.nestedBatches[0 : len(rb.nestedBatches)-1] 219 rb.AddMessage(ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "-"+batchID)) 220} 221 222// Convenience to start a nested batch for history lines, at the highest level 223// supported by the client (`history`, `chathistory`, or no batch, in descending order). 224func (rb *ResponseBuffer) StartNestedHistoryBatch(params ...string) (batchID string) { 225 var batchType string 226 if rb.session.capabilities.Has(caps.Batch) { 227 batchType = "chathistory" 228 } 229 if batchType != "" { 230 batchID = rb.StartNestedBatch(batchType, params...) 231 } 232 return 233} 234 235// Send sends all messages in the buffer to the client. 236// Afterwards, the buffer is in an undefined state and MUST NOT be used further. 237// If `blocking` is true you MUST be sending to the client from its own goroutine. 238func (rb *ResponseBuffer) Send(blocking bool) error { 239 return rb.flushInternal(true, blocking) 240} 241 242// Flush sends all messages in the buffer to the client. 243// Afterwards, the buffer can still be used. Client code MUST subsequently call Send() 244// to ensure that the final `BATCH -` message is sent. 245// If `blocking` is true you MUST be sending to the client from its own goroutine. 246func (rb *ResponseBuffer) Flush(blocking bool) error { 247 return rb.flushInternal(false, blocking) 248} 249 250// detects whether the response buffer consists of a single, unflushed nested batch, 251// in which case it can be collapsed down to that batch 252func (rb *ResponseBuffer) isCollapsible() (result bool) { 253 // rb.batchID indicates that we already flushed some lines 254 if rb.batchID != "" || len(rb.messages) < 2 { 255 return false 256 } 257 first, last := rb.messages[0], rb.messages[len(rb.messages)-1] 258 if first.Command != "BATCH" || last.Command != "BATCH" { 259 return false 260 } 261 if len(first.Params) == 0 || len(first.Params[0]) == 0 || len(last.Params) == 0 || len(last.Params[0]) == 0 { 262 return false 263 } 264 return first.Params[0][1:] == last.Params[0][1:] 265} 266 267// flushInternal sends the contents of the buffer, either blocking or nonblocking 268// It sends the `BATCH +` message if the client supports it and it hasn't been sent already. 269// If `final` is true, it also sends `BATCH -` (if necessary). 270func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error { 271 if rb.finalized { 272 return nil 273 } 274 275 if rb.session.capabilities.Has(caps.LabeledResponse) && rb.Label != "" { 276 if final && rb.isCollapsible() { 277 // collapse to the outermost nested batch 278 rb.messages[0].SetTag(caps.LabelTagName, rb.Label) 279 } else if !final || 2 <= len(rb.messages) { 280 // we either have 2+ messages, or we are doing a Flush() and have to assume 281 // there will be more messages in the future 282 rb.sendBatchStart(blocking) 283 } else if len(rb.messages) == 1 && rb.batchID == "" { 284 // single labeled message 285 rb.messages[0].SetTag(caps.LabelTagName, rb.Label) 286 } else if len(rb.messages) == 0 && rb.batchID == "" { 287 // ACK message 288 message := ircmsg.MakeMessage(nil, rb.session.client.server.name, "ACK") 289 message.SetTag(caps.LabelTagName, rb.Label) 290 rb.session.setTimeTag(&message, time.Time{}) 291 rb.session.SendRawMessage(message, blocking) 292 } 293 } 294 295 // send each message out 296 for _, message := range rb.messages { 297 // attach batch ID, unless this message was part of a nested batch and is 298 // already tagged 299 if rb.batchID != "" && !message.HasTag("batch") { 300 message.SetTag("batch", rb.batchID) 301 } 302 303 // send message out 304 rb.session.SendRawMessage(message, blocking) 305 } 306 307 // end batch if required 308 if final { 309 rb.sendBatchEnd(blocking) 310 rb.finalized = true 311 } 312 313 // clear out any existing messages 314 rb.messages = rb.messages[:0] 315 316 return nil 317} 318 319// Notice sends the client the given notice from the server. 320func (rb *ResponseBuffer) Notice(text string) { 321 rb.Add(nil, rb.target.server.name, "NOTICE", rb.target.Nick(), text) 322} 323