diff --git a/gencapdefs.py b/gencapdefs.py index 3cacebda..31045839 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -165,6 +165,12 @@ CAPDEFS = [ url="https://github.com/ircv3/ircv3-specifications/pull/362", standard="Proposed IRCv3", ), + CapDef( + identifier="ZNCPlayback", + name="znc.in/playback", + url="https://wiki.znc.in/Playback", + standard="ZNC vendor", + ), ] def validate_defs(): diff --git a/irc/caps/defs.go b/irc/caps/defs.go index c261a6e4..2d086227 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -7,7 +7,7 @@ package caps const ( // number of recognized capabilities: - numCapabs = 25 + numCapabs = 26 // length of the uint64 array that represents the bitset: bitsetLen = 1 ) @@ -112,6 +112,10 @@ const ( // EventPlayback is the Proposed IRCv3 capability named "draft/event-playback": // https://github.com/ircv3/ircv3-specifications/pull/362 EventPlayback Capability = iota + + // ZNCPlayback is the ZNC vendor capability named "znc.in/playback": + // https://wiki.znc.in/Playback + ZNCPlayback Capability = iota ) // `capabilityNames[capab]` is the string name of the capability `capab` @@ -142,5 +146,6 @@ var ( "oragono.io/bnc", "znc.in/self-message", "draft/event-playback", + "znc.in/playback", } ) diff --git a/irc/channel.go b/irc/channel.go index 48e51cef..8d814838 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -620,24 +620,40 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp // TODO #259 can be implemented as Flush(false) (i.e., nonblocking) while holding joinPartMutex rb.Flush(true) - var replayLimit int - customReplayLimit := client.AccountSettings().AutoreplayLines - if customReplayLimit != nil { - replayLimit = *customReplayLimit - maxLimit := channel.server.Config().History.ChathistoryMax - if maxLimit < replayLimit { - replayLimit = maxLimit - } + // autoreplay any messages as necessary + config := channel.server.Config() + var items []history.Item + if rb.session.zncPlaybackTimes != nil && (rb.session.zncPlaybackTimes.targets == nil || rb.session.zncPlaybackTimes.targets[chcfname]) { + items, _ = channel.history.Between(rb.session.zncPlaybackTimes.after, rb.session.zncPlaybackTimes.before, false, config.History.ChathistoryMax) } else { - replayLimit = channel.server.Config().History.AutoreplayOnJoin - } - if 0 < replayLimit { - // TODO don't replay the client's own JOIN line? - items := channel.history.Latest(replayLimit) - if 0 < len(items) { - channel.replayHistoryItems(rb, items, true) - rb.Flush(true) + var replayLimit int + customReplayLimit := client.AccountSettings().AutoreplayLines + if customReplayLimit != nil { + replayLimit = *customReplayLimit + maxLimit := channel.server.Config().History.ChathistoryMax + if maxLimit < replayLimit { + replayLimit = maxLimit + } + } else { + replayLimit = channel.server.Config().History.AutoreplayOnJoin } + if 0 < replayLimit { + items = channel.history.Latest(replayLimit) + } + } + // remove the client's own JOIN line from the replay + numItems := len(items) + for i := len(items) - 1; 0 <= i; i-- { + if items[i].Message.Msgid == message.Msgid { + // zero'ed items will not be replayed because their `Type` field is not recognized + items[i] = history.Item{} + numItems-- + break + } + } + if 0 < numItems { + channel.replayHistoryItems(rb, items, true) + rb.Flush(true) } } diff --git a/irc/client.go b/irc/client.go index 3beb80d6..527cdd0b 100644 --- a/irc/client.go +++ b/irc/client.go @@ -113,6 +113,8 @@ type Session struct { maxlenRest uint32 capState caps.State capVersion caps.Version + + zncPlaybackTimes *zncPlaybackTimes } // sets the session quit message, if there isn't one already diff --git a/irc/commands.go b/irc/commands.go index c505721b..84b24ecb 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -321,6 +321,10 @@ func init() { handler: whowasHandler, minParams: 1, }, + "ZNC": { + handler: zncHandler, + minParams: 1, + }, } initializeServices() diff --git a/irc/handlers.go b/irc/handlers.go index 038d296a..801cd8c6 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -2001,12 +2001,16 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R } channel.SendSplitMessage(msg.Command, lowestPrefix, clientOnlyTags, client, splitMsg, rb) } else { - if service, isService := OragonoServices[strings.ToLower(targetString)]; isService { - // NOTICE and TAGMSG to services are ignored - if histType == history.Privmsg { + // NOTICE and TAGMSG to services are ignored + if histType == history.Privmsg { + lowercaseTarget := strings.ToLower(targetString) + if service, isService := OragonoServices[lowercaseTarget]; isService { servicePrivmsgHandler(service, server, client, message, rb) + continue + } else if _, isZNC := zncHandlers[lowercaseTarget]; isZNC { + zncPrivmsgHandler(client, lowercaseTarget, message, rb) + continue } - continue } user := server.clients.Get(targetString) @@ -2746,3 +2750,9 @@ func whowasHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re } return false } + +// ZNC [params] +func zncHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + zncModuleHandler(client, msg.Params[0], msg.Params[1:], rb) + return false +} diff --git a/irc/help.go b/irc/help.go index 38bb62d5..07e48bc4 100644 --- a/irc/help.go +++ b/irc/help.go @@ -540,6 +540,13 @@ Returns information for the given user(s).`, Returns historical information on the last user with the given nickname.`, }, + "znc": { + text: `ZNC [params] + +Used to emulate features of the ZNC bouncer. This command is not intended +for direct use by end users.`, + duplicate: true, + }, // Informational "modes": { diff --git a/irc/misc_test.go b/irc/misc_test.go new file mode 100644 index 00000000..73806ff6 --- /dev/null +++ b/irc/misc_test.go @@ -0,0 +1,17 @@ +// Copyright (c) 2019 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "testing" + "time" +) + +func TestZncTimestampParser(t *testing.T) { + assertEqual(zncWireTimeToTime("1558338348.988"), time.Unix(1558338348, 988000000), t) + assertEqual(zncWireTimeToTime("1558338348.9"), time.Unix(1558338348, 900000000), t) + assertEqual(zncWireTimeToTime("1558338348"), time.Unix(1558338348, 0), t) + assertEqual(zncWireTimeToTime(".988"), time.Unix(0, 988000000), t) + assertEqual(zncWireTimeToTime("garbage"), time.Unix(0, 0), t) +} diff --git a/irc/znc.go b/irc/znc.go new file mode 100644 index 00000000..fecc7555 --- /dev/null +++ b/irc/znc.go @@ -0,0 +1,96 @@ +// Copyright (c) 2019 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +type zncCommandHandler func(client *Client, command string, params []string, rb *ResponseBuffer) + +var zncHandlers = map[string]zncCommandHandler{ + "*playback": zncPlaybackHandler, +} + +func zncPrivmsgHandler(client *Client, command string, privmsg string, rb *ResponseBuffer) { + zncModuleHandler(client, command, strings.Fields(privmsg), rb) +} + +func zncModuleHandler(client *Client, command string, params []string, rb *ResponseBuffer) { + command = strings.ToLower(command) + if subHandler, ok := zncHandlers[command]; ok { + subHandler(client, command, params, rb) + } else { + rb.Add(nil, "*status!znc@znc.in", "NOTICE", rb.target.Nick(), fmt.Sprintf(client.t("No such module [%s]"), command)) + } +} + +// "number of seconds (floating point for millisecond precision) elapsed since January 1, 1970" +func zncWireTimeToTime(str string) (result time.Time) { + var secondsPortion, fracPortion string + dot := strings.IndexByte(str, '.') + if dot == -1 { + secondsPortion = str + } else { + secondsPortion = str[:dot] + fracPortion = str[dot:] + } + seconds, _ := strconv.ParseInt(secondsPortion, 10, 64) + fraction, _ := strconv.ParseFloat(fracPortion, 64) + return time.Unix(seconds, int64(fraction*1000000000)) +} + +type zncPlaybackTimes struct { + after time.Time + before time.Time + targets map[string]bool // nil for "*" (everything), otherwise the channel names +} + +// https://wiki.znc.in/Playback +// PRIVMSG *playback :play [lower_bound] [upper_bound] +// e.g., PRIVMSG *playback :play * 1558374442 +func zncPlaybackHandler(client *Client, command string, params []string, rb *ResponseBuffer) { + if len(params) < 2 { + return + } else if strings.ToLower(params[0]) != "play" { + return + } + targetString := params[1] + + var after, before time.Time + if 2 < len(params) { + after = zncWireTimeToTime(params[2]) + } + if 3 < len(params) { + before = zncWireTimeToTime(params[3]) + } + + var targets map[string]bool + + // OK: the user's PMs get played back immediately on receiving this, + // then we save the timestamps in the session to handle replay on future channel joins + config := client.server.Config() + if params[1] == "*" { + items, _ := client.history.Between(after, before, false, config.History.ChathistoryMax) + client.replayPrivmsgHistory(rb, items, true) + } else { + for _, targetName := range strings.Split(targetString, ",") { + if cfTarget, err := CasefoldChannel(targetName); err == nil { + if targets == nil { + targets = make(map[string]bool) + } + targets[cfTarget] = true + } + } + } + + rb.session.zncPlaybackTimes = &zncPlaybackTimes{ + after: after, + before: before, + targets: targets, + } +}