source: code/trunk/logger.go@ 347

Last change on this file since 347 was 319, checked in by delthas, 5 years ago

Add support for downstream CHATHISTORY

This adds support for the WIP (at the time of this commit)
draft/chathistory extension, based on the draft at [1] and the
additional comments at [2].

This gets the history by parsing the chat logs, and is therefore only
enabled when the logs are enabled and the log path is configured.

Getting the history only from the logs adds some restrictions:

  • we cannot get history by msgid (those are not logged)
  • we cannot get the users masks (maybe they could be inferred from the JOIN etc, but it is not worth the effort and would not work every time)

The regular soju network history is not sent to clients that support
draft/chathistory, so that they can fetch what they need by manually
calling CHATHISTORY.

The only supported command is BEFORE for now, because that is the only
required command for an app that offers an "infinite history scrollback"
feature.

Regarding implementation, rather than reading the file from the end in
reverse, we simply start from the beginning of each log file, store each
PRIVMSG into a ring, then add the last lines of that ring into the
history we'll return later. The message parsing implementation must be
kept somewhat fast because an app could potentially request thousands of
messages in several files. Here we are using simple sscanf and indexOf
rather than regexps.

In case some log files do not contain any message (for example because
the user had not joined a channel at that time), we try up to a 100 days
of empty log files before giving up.

[1]: https://github.com/prawnsalad/ircv3-specifications/pull/3/files
[2]: https://github.com/ircv3/ircv3-specifications/pull/393/files#r350210018

File size: 4.9 KB
Line 
1package soju
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "gopkg.in/irc.v3"
12)
13
14type messageLogger struct {
15 network *network
16 entity string
17
18 path string
19 file *os.File
20}
21
22func newMessageLogger(network *network, entity string) *messageLogger {
23 return &messageLogger{
24 network: network,
25 entity: entity,
26 }
27}
28
29func logPath(network *network, entity string, t time.Time) string {
30 user := network.user
31 srv := user.srv
32
33 // TODO: handle/forbid network/entity names with illegal path characters
34 year, month, day := t.Date()
35 filename := fmt.Sprintf("%04d-%02d-%02d.log", year, month, day)
36 return filepath.Join(srv.LogPath, user.Username, network.GetName(), entity, filename)
37}
38
39func (ml *messageLogger) Append(msg *irc.Message) error {
40 s := formatMessage(msg)
41 if s == "" {
42 return nil
43 }
44
45 var t time.Time
46 if tag, ok := msg.Tags["time"]; ok {
47 var err error
48 t, err = time.Parse(serverTimeLayout, string(tag))
49 if err != nil {
50 return fmt.Errorf("failed to parse message time tag: %v", err)
51 }
52 t = t.In(time.Local)
53 } else {
54 t = time.Now()
55 }
56
57 // TODO: enforce maximum open file handles (LRU cache of file handles)
58 // TODO: handle non-monotonic clock behaviour
59 path := logPath(ml.network, ml.entity, t)
60 if ml.path != path {
61 if ml.file != nil {
62 ml.file.Close()
63 }
64
65 dir := filepath.Dir(path)
66 if err := os.MkdirAll(dir, 0700); err != nil {
67 return fmt.Errorf("failed to create logs directory %q: %v", dir, err)
68 }
69
70 f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600)
71 if err != nil {
72 return fmt.Errorf("failed to open log file %q: %v", path, err)
73 }
74
75 ml.path = path
76 ml.file = f
77 }
78
79 _, err := fmt.Fprintf(ml.file, "[%02d:%02d:%02d] %s\n", t.Hour(), t.Minute(), t.Second(), s)
80 if err != nil {
81 return fmt.Errorf("failed to log message to %q: %v", ml.path, err)
82 }
83 return nil
84}
85
86func (ml *messageLogger) Close() error {
87 if ml.file == nil {
88 return nil
89 }
90 return ml.file.Close()
91}
92
93// formatMessage formats a message log line. It assumes a well-formed IRC
94// message.
95func formatMessage(msg *irc.Message) string {
96 switch strings.ToUpper(msg.Command) {
97 case "NICK":
98 return fmt.Sprintf("*** %s is now known as %s", msg.Prefix.Name, msg.Params[0])
99 case "JOIN":
100 return fmt.Sprintf("*** Joins: %s (%s@%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host)
101 case "PART":
102 var reason string
103 if len(msg.Params) > 1 {
104 reason = msg.Params[1]
105 }
106 return fmt.Sprintf("*** Parts: %s (%s@%s) (%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host, reason)
107 case "KICK":
108 nick := msg.Params[1]
109 var reason string
110 if len(msg.Params) > 2 {
111 reason = msg.Params[2]
112 }
113 return fmt.Sprintf("*** %s was kicked by %s (%s)", nick, msg.Prefix.Name, reason)
114 case "QUIT":
115 var reason string
116 if len(msg.Params) > 0 {
117 reason = msg.Params[0]
118 }
119 return fmt.Sprintf("*** Quits: %s (%s@%s) (%s)", msg.Prefix.Name, msg.Prefix.User, msg.Prefix.Host, reason)
120 case "TOPIC":
121 var topic string
122 if len(msg.Params) > 1 {
123 topic = msg.Params[1]
124 }
125 return fmt.Sprintf("*** %s changes topic to '%s'", msg.Prefix.Name, topic)
126 case "MODE":
127 return fmt.Sprintf("*** %s sets mode: %s", msg.Prefix.Name, strings.Join(msg.Params[1:], " "))
128 case "NOTICE":
129 return fmt.Sprintf("-%s- %s", msg.Prefix.Name, msg.Params[1])
130 case "PRIVMSG":
131 return fmt.Sprintf("<%s> %s", msg.Prefix.Name, msg.Params[1])
132 default:
133 return ""
134 }
135}
136
137func parseMessagesBefore(network *network, entity string, timestamp time.Time, limit int) ([]*irc.Message, error) {
138 year, month, day := timestamp.Date()
139 path := logPath(network, entity, timestamp)
140 f, err := os.Open(path)
141 if err != nil {
142 if os.IsNotExist(err) {
143 return nil, nil
144 }
145 return nil, err
146 }
147 defer f.Close()
148
149 historyRing := make([]*irc.Message, limit)
150 cur := 0
151
152 sc := bufio.NewScanner(f)
153 for sc.Scan() {
154 line := sc.Text()
155 var hour, minute, second int
156 _, err := fmt.Sscanf(line, "[%02d:%02d:%02d] ", &hour, &minute, &second)
157 if err != nil {
158 return nil, err
159 }
160 message := line[11:]
161 // TODO: support NOTICE
162 if !strings.HasPrefix(message, "<") {
163 continue
164 }
165 i := strings.Index(message, "> ")
166 if i == -1 {
167 continue
168 }
169 t := time.Date(year, month, day, hour, minute, second, 0, time.Local)
170 if !t.Before(timestamp) {
171 break
172 }
173
174 sender := message[1:i]
175 text := message[i+2:]
176 historyRing[cur%limit] = &irc.Message{
177 Tags: map[string]irc.TagValue{
178 "time": irc.TagValue(t.UTC().Format(serverTimeLayout)),
179 },
180 Prefix: &irc.Prefix{
181 Name: sender,
182 },
183 Command: "PRIVMSG",
184 Params: []string{entity, text},
185 }
186 cur++
187 }
188 if sc.Err() != nil {
189 return nil, sc.Err()
190 }
191
192 n := limit
193 if cur < limit {
194 n = cur
195 }
196 start := (cur - n + limit) % limit
197
198 if start+n <= limit { // ring doesnt wrap
199 return historyRing[start : start+n], nil
200 } else { // ring wraps
201 history := make([]*irc.Message, n)
202 r := copy(history, historyRing[start:])
203 copy(history[r:], historyRing[:n-r])
204 return history, nil
205 }
206}
Note: See TracBrowser for help on using the repository browser.