diff --git a/downstream.go b/downstream.go index 427c9e6..058b276 100644 --- a/downstream.go +++ b/downstream.go @@ -2030,6 +2030,10 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error { if err := parseMessageParams(msg, nil, &target, &boundsStr[0], &boundsStr[1], &limitStr); err != nil { return err } + case "TARGETS": + if err := parseMessageParams(msg, nil, &boundsStr[0], &boundsStr[1], &limitStr); err != nil { + return err + } default: // TODO: support LATEST, AROUND return ircError{&irc.Message{ @@ -2092,6 +2096,40 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error { } else { history, err = store.LoadBeforeTime(uc.network, entity, bounds[0], bounds[1], limit) } + case "TARGETS": + // TODO: support TARGETS in multi-upstream mode + targets, err := store.ListTargets(uc.network, bounds[0], bounds[1], limit) + if err != nil { + dc.logger.Printf("failed fetching targets for chathistory: %v", target, err) + return ircError{&irc.Message{ + Command: "FAIL", + Params: []string{"CHATHISTORY", "MESSAGE_ERROR", subcommand, "Failed to retrieve targets"}, + }} + } + + batchRef := "history-targets" + dc.SendMessage(&irc.Message{ + Prefix: dc.srv.prefix(), + Command: "BATCH", + Params: []string{"+" + batchRef, "draft/chathistory-targets"}, + }) + + for _, target := range targets { + dc.SendMessage(&irc.Message{ + Tags: irc.Tags{"batch": irc.TagValue(batchRef)}, + Prefix: dc.srv.prefix(), + Command: "CHATHISTORY", + Params: []string{"TARGETS", target.Name, target.LatestMessage.UTC().Format(serverTimeLayout)}, + }) + } + + dc.SendMessage(&irc.Message{ + Prefix: dc.srv.prefix(), + Command: "BATCH", + Params: []string{"-" + batchRef}, + }) + + return nil } if err != nil { dc.logger.Printf("failed fetching %q messages for chathistory: %v", target, err) diff --git a/msgstore.go b/msgstore.go index 8e83f0a..5bf4b2b 100644 --- a/msgstore.go +++ b/msgstore.go @@ -21,11 +21,20 @@ type messageStore interface { Append(network *network, entity string, msg *irc.Message) (id string, err error) } +type chatHistoryTarget struct { + Name string + LatestMessage time.Time +} + // chatHistoryMessageStore is a message store that supports chat history // operations. type chatHistoryMessageStore interface { messageStore + // ListTargets lists channels and nicknames by time of the latest message. + // It returns up to limit targets, starting from start and ending on end, + // both excluded. end may be before or after start. + ListTargets(network *network, start, end time.Time, limit int) ([]chatHistoryTarget, error) // LoadBeforeTime loads up to limit messages before start down to end. The // returned messages must be between and excluding the provided bounds. // end is before start. diff --git a/msgstore_fs.go b/msgstore_fs.go index a2017bf..d17859d 100644 --- a/msgstore_fs.go +++ b/msgstore_fs.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "time" @@ -393,11 +394,6 @@ func (ms *fsMessageStore) LoadAfterTime(network *network, entity string, start t return history, nil } -func truncateDay(t time.Time) time.Time { - year, month, day := t.Date() - return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) -} - func (ms *fsMessageStore) LoadLatestID(network *network, entity, id string, limit int) ([]*irc.Message, error) { var afterTime time.Time var afterOffset int64 @@ -441,3 +437,91 @@ func (ms *fsMessageStore) LoadLatestID(network *network, entity, id string, limi return history[remaining:], nil } + +func (ms *fsMessageStore) ListTargets(network *network, start, end time.Time, limit int) ([]chatHistoryTarget, error) { + rootPath := filepath.Join(ms.root, escapeFilename.Replace(network.GetName())) + root, err := os.Open(rootPath) + if err != nil { + return nil, err + } + + // The returned targets are escaped, and there is no way to un-escape + // TODO: switch to ReadDir (Go 1.16+) + targetNames, err := root.Readdirnames(0) + root.Close() + if err != nil { + return nil, err + } + + var targets []chatHistoryTarget + for _, target := range targetNames { + // target is already escaped here + targetPath := filepath.Join(rootPath, target) + targetDir, err := os.Open(targetPath) + if err != nil { + return nil, err + } + + entries, err := targetDir.Readdir(0) + targetDir.Close() + if err != nil { + return nil, err + } + + // We use mtime here, which may give imprecise or incorrect results + var t time.Time + for _, entry := range entries { + if entry.ModTime().After(t) { + t = entry.ModTime() + } + } + + // The timestamps we get from logs have second granularity + t = truncateSecond(t) + + // Filter out targets that don't fullfil the time bounds + if !isTimeBetween(t, start, end) { + continue + } + + targets = append(targets, chatHistoryTarget{ + Name: target, + LatestMessage: t, + }) + } + + // Sort targets by latest message time, backwards or forwards depending on + // the order of the time bounds + sort.Slice(targets, func(i, j int) bool { + t1, t2 := targets[i].LatestMessage, targets[j].LatestMessage + if start.Before(end) { + return t1.Before(t2) + } else { + return !t1.Before(t2) + } + }) + + // Truncate the result if necessary + if len(targets) > limit { + targets = targets[:limit] + } + + return targets, nil +} + +func truncateDay(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +func truncateSecond(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, t.Hour(), t.Minute(), t.Second(), 0, t.Location()) +} + +func isTimeBetween(t, start, end time.Time) bool { + if end.Before(start) { + end, start = start, end + } + return start.Before(t) && t.Before(end) +}