diff --git a/irc/channel.go b/irc/channel.go index 374bb57f..ab83e20b 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -578,7 +578,7 @@ func (channel *Channel) resumeAndAnnounce(newClient, oldClient *Client) { } func (channel *Channel) replayHistoryForResume(newClient *Client, after time.Time, before time.Time) { - items, complete := channel.history.Between(after, before) + items, complete := channel.history.Between(after, before, false, 0) rb := NewResponseBuffer(newClient) channel.replayHistoryItems(rb, items) if !complete && !newClient.resumeDetails.HistoryIncomplete { diff --git a/irc/client.go b/irc/client.go index 19068fec..3e56927b 100644 --- a/irc/client.go +++ b/irc/client.go @@ -479,7 +479,7 @@ func (client *Client) TryResume() { privmsgMatcher := func(item history.Item) bool { return item.Type == history.Privmsg || item.Type == history.Notice } - privmsgHistory := oldClient.history.Match(privmsgMatcher, 0) + privmsgHistory := oldClient.history.Match(privmsgMatcher, false, 0) lastDiscarded := oldClient.history.LastDiscarded() if lastDiscarded.Before(oldestLostMessage) { oldestLostMessage = lastDiscarded @@ -541,28 +541,39 @@ func (client *Client) tryResumeChannels() { // replay direct PRIVSMG history if !details.Timestamp.IsZero() { now := time.Now() - nick := client.Nick() - items, complete := client.history.Between(details.Timestamp, now) - for _, item := range items { - var command string - switch item.Type { - case history.Privmsg: - command = "PRIVMSG" - case history.Notice: - command = "NOTICE" - default: - continue - } - client.sendSplitMsgFromClientInternal(true, item.Time, item.Msgid, item.Nick, item.AccountName, nil, command, nick, item.Message) - } - if !complete { - client.Send(nil, "HistServ", "NOTICE", nick, client.t("Some additional message history may have been lost")) - } + items, complete := client.history.Between(details.Timestamp, now, false, 0) + rb := NewResponseBuffer(client) + client.replayPrivmsgHistory(rb, items, complete) + rb.Send(true) } details.OldClient.destroy(true) } +func (client *Client) replayPrivmsgHistory(rb *ResponseBuffer, items []history.Item, complete bool) { + nick := client.Nick() + serverTime := client.capabilities.Has(caps.ServerTime) + for _, item := range items { + var command string + switch item.Type { + case history.Privmsg: + command = "PRIVMSG" + case history.Notice: + command = "NOTICE" + default: + continue + } + var tags Tags + if serverTime { + tags = ensureTag(tags, "time", item.Time.Format(IRCv3TimestampFormat)) + } + rb.AddSplitMessageFromClient(item.Msgid, item.Nick, item.AccountName, tags, command, nick, item.Message) + } + if !complete { + rb.Add(nil, "HistServ", "NOTICE", nick, client.t("Some additional message history may have been lost")) + } +} + // copy applicable state from oldClient to client as part of a resume func (client *Client) copyResumeData(oldClient *Client) { oldClient.stateMutex.RLock() diff --git a/irc/commands.go b/irc/commands.go index ec098f0d..5ff86088 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -92,6 +92,10 @@ func init() { usablePreReg: true, minParams: 1, }, + "CHATHISTORY": { + handler: chathistoryHandler, + minParams: 3, + }, "DEBUG": { handler: debugHandler, minParams: 1, @@ -110,6 +114,10 @@ func init() { handler: helpHandler, minParams: 0, }, + "HISTORY": { + handler: historyHandler, + minParams: 1, + }, "INFO": { handler: infoHandler, }, diff --git a/irc/config.go b/irc/config.go index 60870a9d..01a18976 100644 --- a/irc/config.go +++ b/irc/config.go @@ -322,6 +322,7 @@ type Config struct { ChannelLength int `yaml:"channel-length"` ClientLength int `yaml:"client-length"` AutoreplayOnJoin int `yaml:"autoreplay-on-join"` + ChathistoryMax int `yaml:"chathistory-maxmessages"` } Filename string diff --git a/irc/errors.go b/irc/errors.go index a906a391..cc22ace8 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -41,6 +41,7 @@ var ( errResumeTokenAlreadySet = errors.New("Client was already assigned a resume token") errInvalidUsername = errors.New("Invalid username") errFeatureDisabled = errors.New("That feature is disabled") + errInvalidParams = errors.New("Invalid parameters") ) // Socket Errors diff --git a/irc/handlers.go b/irc/handlers.go index d78efff4..e2d8f278 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -522,6 +522,233 @@ func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo return false } +// CHATHISTORY [] +// e.g., CHATHISTORY #ircv3 AFTER id=ytNBbt565yt4r3err3 10 +// CHATHISTORY BETWEEN [] +// e.g., CHATHISTORY #ircv3 BETWEEN timestamp=YYYY-MM-DDThh:mm:ss.sssZ timestamp=YYYY-MM-DDThh:mm:ss.sssZ + 100 +func chathistoryHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) (exiting bool) { + config := server.Config() + // batch type is chathistory; send an empty batch if necessary + rb.InitializeBatch("chathistory", true) + + var items []history.Item + success := false + var hist *history.Buffer + var channel *Channel + defer func() { + if success { + if channel == nil { + client.replayPrivmsgHistory(rb, items, true) + } else { + channel.replayHistoryItems(rb, items) + } + } + rb.Send(true) // terminate the chathistory batch + if success && len(items) > 0 { + return + } + newRb := NewResponseBuffer(client) + newRb.Label = rb.Label // same label, new batch + // TODO: send `WARN CHATHISTORY MAX_MESSAGES_EXCEEDED` when appropriate + if !success { + newRb.Add(nil, server.name, "ERR", "CHATHISTORY", "NEED_MORE_PARAMS") + } else if hist == nil { + newRb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_SUCH_CHANNEL") + } else if len(items) == 0 { + newRb.Add(nil, server.name, "ERR", "CHATHISTORY", "NO_TEXT_TO_SEND") + } + newRb.Send(true) + }() + + target := msg.Params[0] + channel = server.channels.Get(target) + if channel != nil { + hist = &channel.history + } else { + targetClient := server.clients.Get(target) + if targetClient != nil { + myAccount := client.Account() + targetAccount := targetClient.Account() + if myAccount != "" && targetAccount != "" && myAccount == targetAccount { + hist = targetClient.history + } + } + } + if hist == nil { + return + } + + preposition := strings.ToLower(msg.Params[1]) + + parseQueryParam := func(param string) (msgid string, timestamp time.Time, err error) { + err = errInvalidParams + pieces := strings.SplitN(param, "=", 2) + if len(pieces) < 2 { + return + } + identifier, value := strings.ToLower(pieces[0]), pieces[1] + if identifier == "id" { + msgid, err = value, nil + return + } else if identifier == "timestamp" { + timestamp, err = time.Parse(IRCv3TimestampFormat, value) + return + } + return + } + + maxChathistoryLimit := config.History.ChathistoryMax + if maxChathistoryLimit == 0 { + return + } + parseHistoryLimit := func(paramIndex int) (limit int) { + if len(msg.Params) < (paramIndex + 1) { + return maxChathistoryLimit + } + limit, err := strconv.Atoi(msg.Params[paramIndex]) + if err != nil || limit == 0 || limit > maxChathistoryLimit { + limit = maxChathistoryLimit + } + return + } + + // TODO: as currently implemented, almost all of thes queries are worst-case O(n) + // in the number of stored history entries. Every one of them can be made O(1) + // if necessary, without too much difficulty. Some ideas: + // * Ensure that the ring buffer is sorted by time, enabling binary search for times + // * Maintain a map from msgid to position in the ring buffer + + if preposition == "between" { + if len(msg.Params) >= 5 { + startMsgid, startTimestamp, startErr := parseQueryParam(msg.Params[2]) + endMsgid, endTimestamp, endErr := parseQueryParam(msg.Params[3]) + ascending := msg.Params[4] == "+" + limit := parseHistoryLimit(5) + if startErr != nil || endErr != nil { + success = false + } else if startMsgid != "" && endMsgid != "" { + inInterval := false + matches := func(item history.Item) (result bool) { + result = inInterval + if item.HasMsgid(startMsgid) { + if ascending { + inInterval = true + } else { + inInterval = false + return false // interval is exclusive + } + } else if item.HasMsgid(endMsgid) { + if ascending { + inInterval = false + return false + } else { + inInterval = true + } + } + return + } + items = hist.Match(matches, ascending, limit) + success = true + } else if !startTimestamp.IsZero() && !endTimestamp.IsZero() { + items, _ = hist.Between(startTimestamp, endTimestamp, ascending, limit) + if !ascending { + history.Reverse(items) + } + success = true + } + // else: mismatched params, success = false, fail + } + return + } + + // before, after, latest, around + queryParam := msg.Params[2] + msgid, timestamp, err := parseQueryParam(queryParam) + limit := parseHistoryLimit(3) + before := false + switch preposition { + case "before": + before = true + fallthrough + case "after": + var matches history.Predicate + if err != nil { + break + } else if msgid != "" { + inInterval := false + matches = func(item history.Item) (result bool) { + result = inInterval + if item.HasMsgid(msgid) { + inInterval = true + } + return + } + } else { + matches = func(item history.Item) bool { + return before == item.Time.Before(timestamp) + } + } + items = hist.Match(matches, !before, limit) + success = true + case "latest": + if queryParam == "*" { + items = hist.Latest(limit) + } else if err != nil { + break + } else { + var matches history.Predicate + if msgid != "" { + shouldStop := false + matches = func(item history.Item) bool { + if shouldStop { + return false + } + shouldStop = item.HasMsgid(msgid) + return !shouldStop + } + } else { + matches = func(item history.Item) bool { + return item.Time.After(timestamp) + } + } + items = hist.Match(matches, false, limit) + } + success = true + case "around": + if err != nil { + break + } + var initialMatcher history.Predicate + if msgid != "" { + inInterval := false + initialMatcher = func(item history.Item) (result bool) { + if inInterval { + return true + } else { + inInterval = item.HasMsgid(msgid) + return inInterval + } + } + } else { + initialMatcher = func(item history.Item) (result bool) { + return item.Time.Before(timestamp) + } + } + var halfLimit int + halfLimit = (limit + 1) / 2 + firstPass := hist.Match(initialMatcher, false, halfLimit) + if len(firstPass) > 0 { + timeWindowStart := firstPass[0].Time + items = hist.Match(func(item history.Item) bool { + return item.Time.Equal(timeWindowStart) || item.Time.After(timeWindowStart) + }, true, limit) + } + success = true + } + + return +} + // DEBUG func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { param := strings.ToUpper(msg.Params[0]) @@ -779,6 +1006,58 @@ Get an explanation of , or "index" for a list of help topics.`), rb) return false } +// HISTORY [] +// e.g., HISTORY #ubuntu 10 +// HISTORY me 15 +func historyHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + config := server.Config() + target := msg.Params[0] + var hist *history.Buffer + channel := server.channels.Get(target) + if channel != nil { + hist = &channel.history + } else { + if strings.ToLower(target) == "me" { + hist = client.history + } else { + targetClient := server.clients.Get(target) + if targetClient != nil { + myAccount, targetAccount := client.Account(), targetClient.Account() + if myAccount != "" && targetAccount != "" && myAccount == targetAccount { + hist = targetClient.history + } + } + } + } + + if hist == nil { + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), target, client.t("No such channel")) + return false + } + + limit := 10 + maxChathistoryLimit := config.History.ChathistoryMax + if len(msg.Params) > 1 { + providedLimit, err := strconv.Atoi(msg.Params[1]) + if providedLimit > maxChathistoryLimit { + providedLimit = maxChathistoryLimit + } + if err == nil && providedLimit != 0 { + limit = providedLimit + } + } + + items := hist.Latest(limit) + + if channel != nil { + channel.replayHistoryItems(rb, items) + } else { + client.replayPrivmsgHistory(rb, items, true) + } + + return false +} + // INFO func infoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { // we do the below so that the human-readable lines in info can be translated. diff --git a/irc/help.go b/irc/help.go index be487cb4..a8fb29e2 100644 --- a/irc/help.go +++ b/irc/help.go @@ -130,6 +130,13 @@ http://ircv3.net/specs/core/capability-negotiation-3.2.html`, text: `CHANSERV [params] ChanServ controls channel registrations.`, + }, + "chathistory": { + text: `CHATHISTORY [params] + +CHATHISTORY is an experimental history replay command. See these documents: +https://github.com/MuffinMedic/ircv3-specifications/blob/chathistory/extensions/chathistory.md +https://gist.github.com/DanielOaks/c104ad6e8759c01eb5c826d627caf80da`, }, "cs": { text: `CS [params] @@ -187,6 +194,14 @@ Get an explanation of , or "index" for a list of help topics.`, text: `HELPOP Get an explanation of , or "index" for a list of help topics.`, + }, + "history": { + text: `HISTORY [limit] + +Replay message history. can be a channel name, "me" to replay direct +message history, or a nickname to replay another client's direct message +history (they must be logged into the same account as you). At most [limit] +messages will be replayed.`, }, "hostserv": { text: `HOSTSERV [params] diff --git a/irc/history/history.go b/irc/history/history.go index e410912f..c8c8acdc 100644 --- a/irc/history/history.go +++ b/irc/history/history.go @@ -36,6 +36,13 @@ type Item struct { Msgid string } +// HasMsgid tests whether a message has the message id `msgid`. +func (item *Item) HasMsgid(msgid string) bool { + // XXX we stuff other data in the Msgid field sometimes, + // don't match it by accident + return (item.Type == Privmsg || item.Type == Notice) && item.Msgid == msgid +} + type Predicate func(item Item) (matches bool) // Buffer is a ring buffer holding message/event history for a channel or user @@ -115,7 +122,8 @@ func (list *Buffer) Add(item Item) { list.buffer[pos] = item } -func reverse(results []Item) { +// Reverse reverses an []Item, in-place. +func Reverse(results []Item) { for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 { results[i], results[j] = results[j], results[i] } @@ -125,7 +133,7 @@ func reverse(results []Item) { // with an indication of whether the results are complete or are missing items // because some of that period was discarded. A zero value of `before` is considered // higher than all other times. -func (list *Buffer) Between(after, before time.Time) (results []Item, complete bool) { +func (list *Buffer) Between(after, before time.Time, ascending bool, limit int) (results []Item, complete bool) { if !list.Enabled() { return } @@ -139,14 +147,17 @@ func (list *Buffer) Between(after, before time.Time) (results []Item, complete b return (after.IsZero() || item.Time.After(after)) && (before.IsZero() || item.Time.Before(before)) } - return list.matchInternal(satisfies, 0), complete + return list.matchInternal(satisfies, ascending, limit), complete } // Match returns all history items such that `predicate` returns true for them. -// Items are considered in reverse insertion order, up to a total of `limit` matches. +// Items are considered in reverse insertion order if `ascending` is false, or +// in insertion order if `ascending` is true, up to a total of `limit` matches +// if `limit` > 0 (unlimited otherwise). // `predicate` MAY be a closure that maintains its own state across invocations; // it MUST NOT acquire any locks or otherwise do anything weird. -func (list *Buffer) Match(predicate Predicate, limit int) (results []Item) { +// Results are always returned in insertion order. +func (list *Buffer) Match(predicate Predicate, ascending bool, limit int) (results []Item) { if !list.Enabled() { return } @@ -154,28 +165,42 @@ func (list *Buffer) Match(predicate Predicate, limit int) (results []Item) { list.RLock() defer list.RUnlock() - return list.matchInternal(predicate, limit) + return list.matchInternal(predicate, ascending, limit) } // you must be holding the read lock to call this -func (list *Buffer) matchInternal(predicate Predicate, limit int) (results []Item) { +func (list *Buffer) matchInternal(predicate Predicate, ascending bool, limit int) (results []Item) { if list.start == -1 { return } - pos := list.prev(list.end) + var pos, stop int + if ascending { + pos = list.start + stop = list.prev(list.end) + } else { + pos = list.prev(list.end) + stop = list.start + } + for { if predicate(list.buffer[pos]) { results = append(results, list.buffer[pos]) } - if pos == list.start || (limit != 0 && len(results) == limit) { + if pos == stop || (limit != 0 && len(results) == limit) { break } - pos = list.prev(pos) + if ascending { + pos = list.next(pos) + } else { + pos = list.prev(pos) + } } // TODO sort by time instead? - reverse(results) + if !ascending { + Reverse(results) + } return } @@ -183,7 +208,7 @@ func (list *Buffer) matchInternal(predicate Predicate, limit int) (results []Ite // it returns all items. func (list *Buffer) Latest(limit int) (results []Item) { matchAll := func(item Item) bool { return true } - return list.Match(matchAll, limit) + return list.Match(matchAll, false, limit) } // LastDiscarded returns the latest time of any entry that was evicted @@ -204,6 +229,15 @@ func (list *Buffer) prev(index int) int { } } +func (list *Buffer) next(index int) int { + switch index { + case len(list.buffer) - 1: + return 0 + default: + return index + 1 + } +} + // Resize shrinks or expands the buffer func (list *Buffer) Resize(size int) { newbuffer := make([]Item, size) diff --git a/irc/history/history_test.go b/irc/history/history_test.go index 3dfa05fe..c5ba4594 100644 --- a/irc/history/history_test.go +++ b/irc/history/history_test.go @@ -25,7 +25,7 @@ func TestEmptyBuffer(t *testing.T) { Nick: "testnick", }) - since, complete := buf.Between(pastTime, time.Now()) + since, complete := buf.Between(pastTime, time.Now(), false, 0) if len(since) != 0 { t.Error("shouldn't be able to add to disabled buf") } @@ -37,13 +37,13 @@ func TestEmptyBuffer(t *testing.T) { if !buf.Enabled() { t.Error("the buffer of size 1 must be considered enabled") } - since, complete = buf.Between(pastTime, time.Now()) + since, complete = buf.Between(pastTime, time.Now(), false, 0) assertEqual(complete, true, t) assertEqual(len(since), 0, t) buf.Add(Item{ Nick: "testnick", }) - since, complete = buf.Between(pastTime, time.Now()) + since, complete = buf.Between(pastTime, time.Now(), false, 0) if len(since) != 1 { t.Error("should be able to store items in a nonempty buffer") } @@ -57,7 +57,7 @@ func TestEmptyBuffer(t *testing.T) { buf.Add(Item{ Nick: "testnick2", }) - since, complete = buf.Between(pastTime, time.Now()) + since, complete = buf.Between(pastTime, time.Now(), false, 0) if len(since) != 1 { t.Error("expect exactly 1 item") } @@ -68,7 +68,7 @@ func TestEmptyBuffer(t *testing.T) { t.Error("retrieved junk data") } matchAll := func(item Item) bool { return true } - assertEqual(toNicks(buf.Match(matchAll, 0)), []string{"testnick2"}, t) + assertEqual(toNicks(buf.Match(matchAll, false, 0)), []string{"testnick2"}, t) } func toNicks(items []Item) (result []string) { @@ -112,7 +112,7 @@ func TestBuffer(t *testing.T) { Time: easyParse("2006-01-03 15:04:05Z"), }) - since, complete := buf.Between(start, time.Now()) + since, complete := buf.Between(start, time.Now(), false, 0) assertEqual(complete, true, t) assertEqual(toNicks(since), []string{"testnick0", "testnick1", "testnick2"}, t) @@ -121,20 +121,20 @@ func TestBuffer(t *testing.T) { Nick: "testnick3", Time: easyParse("2006-01-04 15:04:05Z"), }) - since, complete = buf.Between(start, time.Now()) + since, complete = buf.Between(start, time.Now(), false, 0) assertEqual(complete, false, t) assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t) // now exclude the time of the discarded entry; results should be complete again - since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now()) + since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now(), false, 0) assertEqual(complete, true, t) assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t) - since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), easyParse("2006-01-03 00:00:00Z")) + since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), easyParse("2006-01-03 00:00:00Z"), false, 0) assertEqual(complete, true, t) assertEqual(toNicks(since), []string{"testnick1"}, t) // shrink the buffer, cutting off testnick1 buf.Resize(2) - since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now()) + since, complete = buf.Between(easyParse("2006-01-02 00:00:00Z"), time.Now(), false, 0) assertEqual(complete, false, t) assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t) @@ -151,7 +151,11 @@ func TestBuffer(t *testing.T) { Nick: "testnick6", Time: easyParse("2006-01-07 15:04:05Z"), }) - since, complete = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now()) + since, complete = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now(), false, 0) assertEqual(complete, true, t) assertEqual(toNicks(since), []string{"testnick2", "testnick3", "testnick4", "testnick5", "testnick6"}, t) + + // test ascending order + since, _ = buf.Between(easyParse("2006-01-03 00:00:00Z"), time.Now(), true, 2) + assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t) } diff --git a/irc/responsebuffer.go b/irc/responsebuffer.go index 716f9bb4..3fccca01 100644 --- a/irc/responsebuffer.go +++ b/irc/responsebuffer.go @@ -14,7 +14,7 @@ import ( const ( // https://ircv3.net/specs/extensions/labeled-response.html - batchType = "draft/labeled-response" + defaultBatchType = "draft/labeled-response" ) // ResponseBuffer - put simply - buffers messages and then outputs them to a given client. @@ -45,8 +45,10 @@ func NewResponseBuffer(target *Client) *ResponseBuffer { // Add adds a standard new message to our queue. func (rb *ResponseBuffer) Add(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) { if rb.finalized { - rb.target.server.logger.Error("message added to finalized ResponseBuffer, undefined behavior") + rb.target.server.logger.Error("internal", "message added to finalized ResponseBuffer, undefined behavior") debug.PrintStack() + // TODO(dan): send a NOTICE to the end user with a string representation of the message, + // for debugging purposes return } @@ -81,7 +83,15 @@ func (rb *ResponseBuffer) AddSplitMessageFromClient(msgid string, fromNickMask s } } -func (rb *ResponseBuffer) sendBatchStart(blocking bool) { +// InitializeBatch forcibly starts a batch of batch `batchType`. +// Normally, Send/Flush will decide automatically whether to start a batch +// of type draft/labeled-response. This allows changing the batch type +// and forcing the creation of a possibly empty batch. +func (rb *ResponseBuffer) InitializeBatch(batchType string, blocking bool) { + rb.sendBatchStart(batchType, blocking) +} + +func (rb *ResponseBuffer) sendBatchStart(batchType string, blocking bool) { if rb.batchID != "" { // batch already initialized return @@ -92,7 +102,9 @@ func (rb *ResponseBuffer) sendBatchStart(blocking bool) { rb.batchID = utils.GenerateSecretToken() message := ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "+"+rb.batchID, batchType) - message.Tags[caps.LabelTagName] = ircmsg.MakeTagValue(rb.Label) + if rb.Label != "" { + message.Tags[caps.LabelTagName] = ircmsg.MakeTagValue(rb.Label) + } rb.target.SendRawMessage(message, blocking) } @@ -125,6 +137,10 @@ func (rb *ResponseBuffer) Flush(blocking bool) error { // It sends the `BATCH +` message if the client supports it and it hasn't been sent already. // If `final` is true, it also sends `BATCH -` (if necessary). func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error { + if rb.finalized { + return nil + } + useLabel := rb.target.capabilities.Has(caps.LabeledResponse) && rb.Label != "" // use a batch if we have a label, and we either currently have multiple messages, // or we are doing a Flush() and we have to assume that there will be more messages @@ -132,10 +148,10 @@ func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error { useBatch := useLabel && (len(rb.messages) > 1 || !final) // if label but no batch, add label to first message - if useLabel && !useBatch && len(rb.messages) == 1 { + if useLabel && !useBatch && len(rb.messages) == 1 && rb.batchID == "" { rb.messages[0].Tags[caps.LabelTagName] = ircmsg.MakeTagValue(rb.Label) } else if useBatch { - rb.sendBatchStart(blocking) + rb.sendBatchStart(defaultBatchType, blocking) } // send each message out diff --git a/irc/server.go b/irc/server.go index b447a257..57aa5ac7 100644 --- a/irc/server.go +++ b/irc/server.go @@ -157,6 +157,9 @@ func (server *Server) setISupport() (err error) { isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen)) isupport.Add("CASEMAPPING", "ascii") isupport.Add("CHANMODES", strings.Join([]string{modes.Modes{modes.BanMask, modes.ExceptMask, modes.InviteMask}.String(), "", modes.Modes{modes.UserLimit, modes.Key}.String(), modes.Modes{modes.InviteOnly, modes.Moderated, modes.NoOutside, modes.OpOnlyTopic, modes.ChanRoleplaying, modes.Secret}.String()}, ",")) + if config.History.Enabled && config.History.ChathistoryMax > 0 { + isupport.Add("draft/CHATHISTORY", strconv.Itoa(config.History.ChathistoryMax)) + } isupport.Add("CHANNELLEN", strconv.Itoa(config.Limits.ChannelLen)) isupport.Add("CHANTYPES", "#") isupport.Add("ELIST", "U") diff --git a/oragono.yaml b/oragono.yaml index 4646c412..a63e9475 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -482,6 +482,10 @@ fakelag: # message history tracking, for the RESUME extension and possibly other uses in future history: # should we store messages for later playback? + # the current implementation stores messages in RAM only; they do not persist + # across server restarts. however, you should disable this unless you understand + # how it interacts with the GDPR and/or any data privacy laws that apply + # in your country and the countries of your users. enabled: true # how many channel-specific events (messages, joins, parts) should be tracked per channel? @@ -492,3 +496,7 @@ history: # number of messages to automatically play back on channel join (0 to disable): autoreplay-on-join: 0 + + # maximum number of CHATHISTORY messages that can be + # requested at once (0 disables support for CHATHISTORY) + chathistory-maxmessages: 100