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