Merge pull request #344 from slingamn/history.4

add CHATHISTORY and HISTORY implementations
This commit is contained in:
Daniel Oaks 2019-02-05 22:16:45 +10:00 committed by GitHub
commit 71a33890b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 428 additions and 48 deletions

View File

@ -578,7 +578,7 @@ func (channel *Channel) resumeAndAnnounce(newClient, oldClient *Client) {
} }
func (channel *Channel) replayHistoryForResume(newClient *Client, after time.Time, before time.Time) { 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) rb := NewResponseBuffer(newClient)
channel.replayHistoryItems(rb, items) channel.replayHistoryItems(rb, items)
if !complete && !newClient.resumeDetails.HistoryIncomplete { if !complete && !newClient.resumeDetails.HistoryIncomplete {

View File

@ -479,7 +479,7 @@ func (client *Client) TryResume() {
privmsgMatcher := func(item history.Item) bool { privmsgMatcher := func(item history.Item) bool {
return item.Type == history.Privmsg || item.Type == history.Notice 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() lastDiscarded := oldClient.history.LastDiscarded()
if lastDiscarded.Before(oldestLostMessage) { if lastDiscarded.Before(oldestLostMessage) {
oldestLostMessage = lastDiscarded oldestLostMessage = lastDiscarded
@ -541,28 +541,39 @@ func (client *Client) tryResumeChannels() {
// replay direct PRIVSMG history // replay direct PRIVSMG history
if !details.Timestamp.IsZero() { if !details.Timestamp.IsZero() {
now := time.Now() now := time.Now()
nick := client.Nick() items, complete := client.history.Between(details.Timestamp, now, false, 0)
items, complete := client.history.Between(details.Timestamp, now) rb := NewResponseBuffer(client)
for _, item := range items { client.replayPrivmsgHistory(rb, items, complete)
var command string rb.Send(true)
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"))
}
} }
details.OldClient.destroy(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 // copy applicable state from oldClient to client as part of a resume
func (client *Client) copyResumeData(oldClient *Client) { func (client *Client) copyResumeData(oldClient *Client) {
oldClient.stateMutex.RLock() oldClient.stateMutex.RLock()

View File

@ -92,6 +92,10 @@ func init() {
usablePreReg: true, usablePreReg: true,
minParams: 1, minParams: 1,
}, },
"CHATHISTORY": {
handler: chathistoryHandler,
minParams: 3,
},
"DEBUG": { "DEBUG": {
handler: debugHandler, handler: debugHandler,
minParams: 1, minParams: 1,
@ -110,6 +114,10 @@ func init() {
handler: helpHandler, handler: helpHandler,
minParams: 0, minParams: 0,
}, },
"HISTORY": {
handler: historyHandler,
minParams: 1,
},
"INFO": { "INFO": {
handler: infoHandler, handler: infoHandler,
}, },

View File

@ -322,6 +322,7 @@ type Config struct {
ChannelLength int `yaml:"channel-length"` ChannelLength int `yaml:"channel-length"`
ClientLength int `yaml:"client-length"` ClientLength int `yaml:"client-length"`
AutoreplayOnJoin int `yaml:"autoreplay-on-join"` AutoreplayOnJoin int `yaml:"autoreplay-on-join"`
ChathistoryMax int `yaml:"chathistory-maxmessages"`
} }
Filename string Filename string

View File

@ -41,6 +41,7 @@ var (
errResumeTokenAlreadySet = errors.New("Client was already assigned a resume token") errResumeTokenAlreadySet = errors.New("Client was already assigned a resume token")
errInvalidUsername = errors.New("Invalid username") errInvalidUsername = errors.New("Invalid username")
errFeatureDisabled = errors.New("That feature is disabled") errFeatureDisabled = errors.New("That feature is disabled")
errInvalidParams = errors.New("Invalid parameters")
) )
// Socket Errors // Socket Errors

View File

@ -522,6 +522,233 @@ func capHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Respo
return false return false
} }
// CHATHISTORY <target> <preposition> <query> [<limit>]
// e.g., CHATHISTORY #ircv3 AFTER id=ytNBbt565yt4r3err3 10
// CHATHISTORY <target> BETWEEN <query> <query> <direction> [<limit>]
// 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 <subcmd> // DEBUG <subcmd>
func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { func debugHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool {
param := strings.ToUpper(msg.Params[0]) param := strings.ToUpper(msg.Params[0])
@ -779,6 +1006,58 @@ Get an explanation of <argument>, or "index" for a list of help topics.`), rb)
return false return false
} }
// HISTORY <target> [<limit>]
// 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 // INFO
func infoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { 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. // we do the below so that the human-readable lines in info can be translated.

View File

@ -130,6 +130,13 @@ http://ircv3.net/specs/core/capability-negotiation-3.2.html`,
text: `CHANSERV <subcommand> [params] text: `CHANSERV <subcommand> [params]
ChanServ controls channel registrations.`, 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": { "cs": {
text: `CS <subcommand> [params] text: `CS <subcommand> [params]
@ -187,6 +194,14 @@ Get an explanation of <argument>, or "index" for a list of help topics.`,
text: `HELPOP <argument> text: `HELPOP <argument>
Get an explanation of <argument>, or "index" for a list of help topics.`, Get an explanation of <argument>, or "index" for a list of help topics.`,
},
"history": {
text: `HISTORY <target> [limit]
Replay message history. <target> 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": { "hostserv": {
text: `HOSTSERV <command> [params] text: `HOSTSERV <command> [params]

View File

@ -36,6 +36,13 @@ type Item struct {
Msgid string 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) type Predicate func(item Item) (matches bool)
// Buffer is a ring buffer holding message/event history for a channel or user // 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 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 { for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i] 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 // 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 // because some of that period was discarded. A zero value of `before` is considered
// higher than all other times. // 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() { if !list.Enabled() {
return 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 (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. // 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; // `predicate` MAY be a closure that maintains its own state across invocations;
// it MUST NOT acquire any locks or otherwise do anything weird. // 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() { if !list.Enabled() {
return return
} }
@ -154,28 +165,42 @@ func (list *Buffer) Match(predicate Predicate, limit int) (results []Item) {
list.RLock() list.RLock()
defer list.RUnlock() defer list.RUnlock()
return list.matchInternal(predicate, limit) return list.matchInternal(predicate, ascending, limit)
} }
// you must be holding the read lock to call this // 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 { if list.start == -1 {
return 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 { for {
if predicate(list.buffer[pos]) { if predicate(list.buffer[pos]) {
results = append(results, 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 break
} }
pos = list.prev(pos) if ascending {
pos = list.next(pos)
} else {
pos = list.prev(pos)
}
} }
// TODO sort by time instead? // TODO sort by time instead?
reverse(results) if !ascending {
Reverse(results)
}
return return
} }
@ -183,7 +208,7 @@ func (list *Buffer) matchInternal(predicate Predicate, limit int) (results []Ite
// it returns all items. // it returns all items.
func (list *Buffer) Latest(limit int) (results []Item) { func (list *Buffer) Latest(limit int) (results []Item) {
matchAll := func(item Item) bool { return true } 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 // 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 // Resize shrinks or expands the buffer
func (list *Buffer) Resize(size int) { func (list *Buffer) Resize(size int) {
newbuffer := make([]Item, size) newbuffer := make([]Item, size)

View File

@ -25,7 +25,7 @@ func TestEmptyBuffer(t *testing.T) {
Nick: "testnick", Nick: "testnick",
}) })
since, complete := buf.Between(pastTime, time.Now()) since, complete := buf.Between(pastTime, time.Now(), false, 0)
if len(since) != 0 { if len(since) != 0 {
t.Error("shouldn't be able to add to disabled buf") t.Error("shouldn't be able to add to disabled buf")
} }
@ -37,13 +37,13 @@ func TestEmptyBuffer(t *testing.T) {
if !buf.Enabled() { if !buf.Enabled() {
t.Error("the buffer of size 1 must be considered 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(complete, true, t)
assertEqual(len(since), 0, t) assertEqual(len(since), 0, t)
buf.Add(Item{ buf.Add(Item{
Nick: "testnick", Nick: "testnick",
}) })
since, complete = buf.Between(pastTime, time.Now()) since, complete = buf.Between(pastTime, time.Now(), false, 0)
if len(since) != 1 { if len(since) != 1 {
t.Error("should be able to store items in a nonempty buffer") t.Error("should be able to store items in a nonempty buffer")
} }
@ -57,7 +57,7 @@ func TestEmptyBuffer(t *testing.T) {
buf.Add(Item{ buf.Add(Item{
Nick: "testnick2", Nick: "testnick2",
}) })
since, complete = buf.Between(pastTime, time.Now()) since, complete = buf.Between(pastTime, time.Now(), false, 0)
if len(since) != 1 { if len(since) != 1 {
t.Error("expect exactly 1 item") t.Error("expect exactly 1 item")
} }
@ -68,7 +68,7 @@ func TestEmptyBuffer(t *testing.T) {
t.Error("retrieved junk data") t.Error("retrieved junk data")
} }
matchAll := func(item Item) bool { return true } 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) { func toNicks(items []Item) (result []string) {
@ -112,7 +112,7 @@ func TestBuffer(t *testing.T) {
Time: easyParse("2006-01-03 15:04:05Z"), 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(complete, true, t)
assertEqual(toNicks(since), []string{"testnick0", "testnick1", "testnick2"}, t) assertEqual(toNicks(since), []string{"testnick0", "testnick1", "testnick2"}, t)
@ -121,20 +121,20 @@ func TestBuffer(t *testing.T) {
Nick: "testnick3", Nick: "testnick3",
Time: easyParse("2006-01-04 15:04:05Z"), 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(complete, false, t)
assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t) assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, t)
// now exclude the time of the discarded entry; results should be complete again // 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(complete, true, t)
assertEqual(toNicks(since), []string{"testnick1", "testnick2", "testnick3"}, 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(complete, true, t)
assertEqual(toNicks(since), []string{"testnick1"}, t) assertEqual(toNicks(since), []string{"testnick1"}, t)
// shrink the buffer, cutting off testnick1 // shrink the buffer, cutting off testnick1
buf.Resize(2) 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(complete, false, t)
assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t) assertEqual(toNicks(since), []string{"testnick2", "testnick3"}, t)
@ -151,7 +151,11 @@ func TestBuffer(t *testing.T) {
Nick: "testnick6", Nick: "testnick6",
Time: easyParse("2006-01-07 15:04:05Z"), 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(complete, true, t)
assertEqual(toNicks(since), []string{"testnick2", "testnick3", "testnick4", "testnick5", "testnick6"}, 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)
} }

View File

@ -14,7 +14,7 @@ import (
const ( const (
// https://ircv3.net/specs/extensions/labeled-response.html // 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. // 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. // Add adds a standard new message to our queue.
func (rb *ResponseBuffer) Add(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) { func (rb *ResponseBuffer) Add(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) {
if rb.finalized { 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() debug.PrintStack()
// TODO(dan): send a NOTICE to the end user with a string representation of the message,
// for debugging purposes
return 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 != "" { if rb.batchID != "" {
// batch already initialized // batch already initialized
return return
@ -92,7 +102,9 @@ func (rb *ResponseBuffer) sendBatchStart(blocking bool) {
rb.batchID = utils.GenerateSecretToken() rb.batchID = utils.GenerateSecretToken()
message := ircmsg.MakeMessage(nil, rb.target.server.name, "BATCH", "+"+rb.batchID, batchType) 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) 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. // 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). // If `final` is true, it also sends `BATCH -` (if necessary).
func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error { func (rb *ResponseBuffer) flushInternal(final bool, blocking bool) error {
if rb.finalized {
return nil
}
useLabel := rb.target.capabilities.Has(caps.LabeledResponse) && rb.Label != "" useLabel := rb.target.capabilities.Has(caps.LabeledResponse) && rb.Label != ""
// use a batch if we have a label, and we either currently have multiple messages, // 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 // 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) useBatch := useLabel && (len(rb.messages) > 1 || !final)
// if label but no batch, add label to first message // 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) rb.messages[0].Tags[caps.LabelTagName] = ircmsg.MakeTagValue(rb.Label)
} else if useBatch { } else if useBatch {
rb.sendBatchStart(blocking) rb.sendBatchStart(defaultBatchType, blocking)
} }
// send each message out // send each message out

View File

@ -157,6 +157,9 @@ func (server *Server) setISupport() (err error) {
isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen)) isupport.Add("AWAYLEN", strconv.Itoa(config.Limits.AwayLen))
isupport.Add("CASEMAPPING", "ascii") 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()}, ",")) 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("CHANNELLEN", strconv.Itoa(config.Limits.ChannelLen))
isupport.Add("CHANTYPES", "#") isupport.Add("CHANTYPES", "#")
isupport.Add("ELIST", "U") isupport.Add("ELIST", "U")

View File

@ -482,6 +482,10 @@ fakelag:
# message history tracking, for the RESUME extension and possibly other uses in future # message history tracking, for the RESUME extension and possibly other uses in future
history: history:
# should we store messages for later playback? # 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 enabled: true
# how many channel-specific events (messages, joins, parts) should be tracked per channel? # 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): # number of messages to automatically play back on channel join (0 to disable):
autoreplay-on-join: 0 autoreplay-on-join: 0
# maximum number of CHATHISTORY messages that can be
# requested at once (0 disables support for CHATHISTORY)
chathistory-maxmessages: 100