Index: trunk/downstream.go
===================================================================
--- trunk/downstream.go	(revision 318)
+++ trunk/downstream.go	(revision 319)
@@ -43,4 +43,11 @@
 			"Not enough parameters",
 		},
+	}}
+}
+
+func newChatHistoryError(subcommand string, target string) ircError {
+	return ircError{&irc.Message{
+		Command: "FAIL",
+		Params:  []string{"CHATHISTORY", "MESSAGE_ERROR", subcommand, target, "Messages could not be retrieved"},
 	}}
 }
@@ -107,4 +114,7 @@
 	for k, v := range permanentDownstreamCaps {
 		dc.supportedCaps[k] = v
+	}
+	if srv.LogPath != "" {
+		dc.supportedCaps["draft/chathistory"] = ""
 	}
 	return dc
@@ -786,4 +796,5 @@
 	})
 	// TODO: RPL_ISUPPORT
+	// TODO: send CHATHISTORY in RPL_ISUPPORT when implemented
 	dc.SendMessage(&irc.Message{
 		Prefix:  dc.srv.prefix(),
@@ -826,4 +837,7 @@
 
 func (dc *downstreamConn) sendNetworkHistory(net *network) {
+	if dc.caps["draft/chathistory"] {
+		return
+	}
 	for target, history := range net.history {
 		if ch, ok := net.channels[target]; ok && ch.Detached {
@@ -1511,4 +1525,104 @@
 			Params:  []string{upstreamUser, upstreamChannel},
 		})
+	case "CHATHISTORY":
+		var subcommand string
+		if err := parseMessageParams(msg, &subcommand); err != nil {
+			return err
+		}
+		var target, criteria, limitStr string
+		if err := parseMessageParams(msg, nil, &target, &criteria, &limitStr); err != nil {
+			return ircError{&irc.Message{
+				Command: "FAIL",
+				Params:  []string{"CHATHISTORY", "NEED_MORE_PARAMS", subcommand, "Missing parameters"},
+			}}
+		}
+
+		if dc.srv.LogPath == "" {
+			return ircError{&irc.Message{
+				Command: irc.ERR_UNKNOWNCOMMAND,
+				Params:  []string{dc.nick, subcommand, "Unknown command"},
+			}}
+		}
+
+		uc, entity, err := dc.unmarshalEntity(target)
+		if err != nil {
+			return err
+		}
+
+		// TODO: support msgid criteria
+		criteriaParts := strings.SplitN(criteria, "=", 2)
+		if len(criteriaParts) != 2 || criteriaParts[0] != "timestamp" {
+			return ircError{&irc.Message{
+				Command: "FAIL",
+				Params:  []string{"CHATHISTORY", "UNKNOWN_CRITERIA", criteria, "Unknown criteria"},
+			}}
+		}
+
+		timestamp, err := time.Parse(serverTimeLayout, criteriaParts[1])
+		if err != nil {
+			return ircError{&irc.Message{
+				Command: "FAIL",
+				Params:  []string{"CHATHISTORY", "INVALID_CRITERIA", criteria, "Invalid criteria"},
+			}}
+		}
+
+		limit, err := strconv.Atoi(limitStr)
+		if err != nil || limit < 0 || limit > dc.srv.HistoryLimit {
+			return ircError{&irc.Message{
+				Command: "FAIL",
+				Params:  []string{"CHATHISTORY", "INVALID_LIMIT", limitStr, "Invalid limit"},
+			}}
+		}
+
+		switch subcommand {
+		case "BEFORE":
+			batchRef := "history"
+			dc.SendMessage(&irc.Message{
+				Prefix:  dc.srv.prefix(),
+				Command: "BATCH",
+				Params:  []string{"+" + batchRef, "chathistory", target},
+			})
+
+			history := make([]*irc.Message, limit)
+			remaining := limit
+
+			tries := 0
+			for remaining > 0 {
+				buf, err := parseMessagesBefore(uc.network, entity, timestamp, remaining)
+				if err != nil {
+					dc.logger.Printf("failed parsing log messages for chathistory: %v", err)
+					return newChatHistoryError(subcommand, target)
+				}
+				if len(buf) == 0 {
+					tries++
+					if tries >= 100 {
+						break
+					}
+				} else {
+					tries = 0
+				}
+				copy(history[remaining-len(buf):], buf)
+				remaining -= len(buf)
+				year, month, day := timestamp.Date()
+				timestamp = time.Date(year, month, day, 0, 0, 0, 0, timestamp.Location()).Add(-1)
+			}
+
+			for _, m := range history[remaining:] {
+				m.Tags["batch"] = irc.TagValue(batchRef)
+				dc.SendMessage(dc.marshalMessage(m, uc.network))
+			}
+
+			dc.SendMessage(&irc.Message{
+				Prefix:  dc.srv.prefix(),
+				Command: "BATCH",
+				Params:  []string{"-" + batchRef},
+			})
+		default:
+			// TODO: support AFTER, LATEST, BETWEEN
+			return ircError{&irc.Message{
+				Command: "FAIL",
+				Params:  []string{"CHATHISTORY", "UNKNOWN_COMMAND", subcommand, "Unknown command"},
+			}}
+		}
 	default:
 		dc.logger.Printf("unhandled message: %v", msg)
Index: trunk/logger.go
===================================================================
--- trunk/logger.go	(revision 318)
+++ trunk/logger.go	(revision 319)
@@ -2,4 +2,5 @@
 
 import (
+	"bufio"
 	"fmt"
 	"os"
@@ -133,2 +134,73 @@
 	}
 }
+
+func parseMessagesBefore(network *network, entity string, timestamp time.Time, limit int) ([]*irc.Message, error) {
+	year, month, day := timestamp.Date()
+	path := logPath(network, entity, timestamp)
+	f, err := os.Open(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	defer f.Close()
+
+	historyRing := make([]*irc.Message, limit)
+	cur := 0
+
+	sc := bufio.NewScanner(f)
+	for sc.Scan() {
+		line := sc.Text()
+		var hour, minute, second int
+		_, err := fmt.Sscanf(line, "[%02d:%02d:%02d] ", &hour, &minute, &second)
+		if err != nil {
+			return nil, err
+		}
+		message := line[11:]
+		// TODO: support NOTICE
+		if !strings.HasPrefix(message, "<") {
+			continue
+		}
+		i := strings.Index(message, "> ")
+		if i == -1 {
+			continue
+		}
+		t := time.Date(year, month, day, hour, minute, second, 0, time.Local)
+		if !t.Before(timestamp) {
+			break
+		}
+
+		sender := message[1:i]
+		text := message[i+2:]
+		historyRing[cur%limit] = &irc.Message{
+			Tags: map[string]irc.TagValue{
+				"time": irc.TagValue(t.UTC().Format(serverTimeLayout)),
+			},
+			Prefix: &irc.Prefix{
+				Name: sender,
+			},
+			Command: "PRIVMSG",
+			Params:  []string{entity, text},
+		}
+		cur++
+	}
+	if sc.Err() != nil {
+		return nil, sc.Err()
+	}
+
+	n := limit
+	if cur < limit {
+		n = cur
+	}
+	start := (cur - n + limit) % limit
+
+	if start+n <= limit { // ring doesnt wrap
+		return historyRing[start : start+n], nil
+	} else { // ring wraps
+		history := make([]*irc.Message, n)
+		r := copy(history, historyRing[start:])
+		copy(history[r:], historyRing[:n-r])
+		return history, nil
+	}
+}
Index: trunk/server.go
===================================================================
--- trunk/server.go	(revision 318)
+++ trunk/server.go	(revision 319)
@@ -39,9 +39,10 @@
 
 type Server struct {
-	Hostname string
-	Logger   Logger
-	RingCap  int
-	LogPath  string
-	Debug    bool
+	Hostname     string
+	Logger       Logger
+	RingCap      int
+	HistoryLimit int
+	LogPath      string
+	Debug        bool
 
 	db *DB
@@ -53,8 +54,9 @@
 func NewServer(db *DB) *Server {
 	return &Server{
-		Logger:  log.New(log.Writer(), "", log.LstdFlags),
-		RingCap: 4096,
-		users:   make(map[string]*user),
-		db:      db,
+		Logger:       log.New(log.Writer(), "", log.LstdFlags),
+		RingCap:      4096,
+		HistoryLimit: 1000,
+		users:        make(map[string]*user),
+		db:           db,
 	}
 }
