commit 7835d39

Hubert Hirtz  ·  2021-05-18 19:26:03 +0000 UTC
parent cdab4b7
Fix races conditions

Refactor:
- Split out reads/writes from irc.Session to irc.ChanInOut,
- Message handling is now manual, messages must be passed to
  irc.Session.HandleMessage for its state to change,
- Remove data-race-prone App.addLineNow (called from both the main
  eventLoop and irc loops) and add App.addStatusLine (to be called from
  the main event loop) and App.queueStatusLine (to be called from other
  goroutines).  These two functions now write to both the current buffer
  and the home buffer,
- add a irc.Typings.List function that locks the list of typings before
  accessing it.

Changes as I went through the whole code...
- CAP handling is fixed (especially CAP DEL and CAP ACK),
- irc.Session now handles PREFIX,
- unhandled messages are now shown, except for some rare cases where
  it's completely useless to show them.
9 files changed,  +583, -799
M app.go
M app.go
+241, -190
  1@@ -79,7 +79,7 @@ func NewApp(cfg Config) (app *App, err error) {
  2 func (app *App) Close() {
  3 	app.win.Close()
  4 	if app.s != nil {
  5-		app.s.Stop()
  6+		app.s.Close()
  7 	}
  8 }
  9 
 10@@ -109,21 +109,8 @@ func (app *App) eventLoop() {
 11 
 12 		app.handleEvents(evs)
 13 		if !app.pasting {
 14-			app.draw()
 15-		}
 16-	}
 17-}
 18-
 19-// handleEvents handles a batch of events.
 20-func (app *App) handleEvents(evs []event) {
 21-	for _, ev := range evs {
 22-		switch ev.src {
 23-		case uiEvent:
 24-			app.handleUIEvent(ev.content.(tcell.Event))
 25-		case ircEvent:
 26-			app.handleIRCEvent(ev.content.(irc.Event))
 27-		default:
 28-			panic("unreachable")
 29+			app.setStatus()
 30+			app.win.Draw()
 31 		}
 32 	}
 33 }
 34@@ -131,15 +118,48 @@ func (app *App) handleEvents(evs []event) {
 35 // ircLoop maintains a connection to the IRC server by connecting and then
 36 // forwarding IRC events to app.events repeatedly.
 37 func (app *App) ircLoop() {
 38+	var auth irc.SASLClient
 39+	if app.cfg.Password != nil {
 40+		auth = &irc.SASLPlain{
 41+			Username: app.cfg.User,
 42+			Password: *app.cfg.Password,
 43+		}
 44+	}
 45+	params := irc.SessionParams{
 46+		Nickname: app.cfg.Nick,
 47+		Username: app.cfg.User,
 48+		RealName: app.cfg.Real,
 49+		Auth:     auth,
 50+	}
 51 	for !app.win.ShouldExit() {
 52-		app.connect()
 53-		for ev := range app.s.Poll() {
 54+		conn := app.connect()
 55+		in, out := irc.ChanInOut(conn)
 56+		if app.cfg.Debug {
 57+			out = app.debugOutputMessages(out)
 58+		}
 59+		session := irc.NewSession(out, params)
 60+		app.events <- event{
 61+			src:     ircEvent,
 62+			content: session,
 63+		}
 64+		for msg := range in {
 65+			if app.cfg.Debug {
 66+				app.queueStatusLine(ui.Line{
 67+					At:   time.Now(),
 68+					Head: "IN --",
 69+					Body: msg.String(),
 70+				})
 71+			}
 72 			app.events <- event{
 73 				src:     ircEvent,
 74-				content: ev,
 75+				content: msg,
 76 			}
 77 		}
 78-		app.addLineNow(Home, ui.Line{
 79+		app.events <- event{
 80+			src:     ircEvent,
 81+			content: nil,
 82+		}
 83+		app.queueStatusLine(ui.Line{
 84 			Head:      "!!",
 85 			HeadColor: ui.ColorRed,
 86 			Body:      "Connection lost",
 87@@ -147,17 +167,17 @@ func (app *App) ircLoop() {
 88 	}
 89 }
 90 
 91-func (app *App) connect() {
 92+func (app *App) connect() net.Conn {
 93 	for {
 94-		app.addLineNow(Home, ui.Line{
 95+		app.queueStatusLine(ui.Line{
 96 			Head: "--",
 97 			Body: fmt.Sprintf("Connecting to %s...", app.cfg.Addr),
 98 		})
 99-		err := app.tryConnect()
100+		conn, err := app.tryConnect()
101 		if err == nil {
102-			break
103+			return conn
104 		}
105-		app.addLineNow(Home, ui.Line{
106+		app.queueStatusLine(ui.Line{
107 			Head:      "!!",
108 			HeadColor: ui.ColorRed,
109 			Body:      fmt.Sprintf("Connection failed: %v", err),
110@@ -166,7 +186,7 @@ func (app *App) connect() {
111 	}
112 }
113 
114-func (app *App) tryConnect() (err error) {
115+func (app *App) tryConnect() (conn net.Conn, err error) {
116 	addr := app.cfg.Addr
117 	colonIdx := strings.LastIndexByte(addr, ':')
118 	bracketIdx := strings.LastIndexByte(addr, ']')
119@@ -180,7 +200,7 @@ func (app *App) tryConnect() (err error) {
120 		}
121 	}
122 
123-	conn, err := net.Dial("tcp", addr)
124+	conn, err = net.Dial("tcp", addr)
125 	if err != nil {
126 		return
127 	}
128@@ -193,28 +213,24 @@ func (app *App) tryConnect() (err error) {
129 		})
130 	}
131 
132-	var auth irc.SASLClient
133-	if app.cfg.Password != nil {
134-		auth = &irc.SASLPlain{
135-			Username: app.cfg.User,
136-			Password: *app.cfg.Password,
137-		}
138-	}
139-	app.s, err = irc.NewSession(conn, irc.SessionParams{
140-		Nickname: app.cfg.Nick,
141-		Username: app.cfg.User,
142-		RealName: app.cfg.Real,
143-		Auth:     auth,
144-		Debug:    app.cfg.Debug,
145-	})
146-	if err != nil {
147-		conn.Close()
148-		return
149-	}
150-
151 	return
152 }
153 
154+func (app *App) debugOutputMessages(out chan<- irc.Message) chan<- irc.Message {
155+	debugOut := make(chan irc.Message, cap(out))
156+	go func() {
157+		for msg := range debugOut {
158+			app.queueStatusLine(ui.Line{
159+				At:   time.Now(),
160+				Head: "OUT --",
161+				Body: msg.String(),
162+			})
163+			out <- msg
164+		}
165+	}()
166+	return debugOut
167+}
168+
169 // uiLoop retrieves events from the UI and forwards them to app.events for
170 // handling in app.eventLoop().
171 func (app *App) uiLoop() {
172@@ -230,118 +246,34 @@ func (app *App) uiLoop() {
173 	}
174 }
175 
176-func (app *App) handleIRCEvent(ev irc.Event) {
177-	switch ev := ev.(type) {
178-	case irc.RawMessageEvent:
179-		head := "IN --"
180-		if ev.Outgoing {
181-			head = "OUT --"
182-		} else if !ev.IsValid {
183-			head = "IN ??"
184-		}
185-		app.win.AddLine(Home, false, ui.Line{
186-			At:   time.Now(),
187-			Head: head,
188-			Body: ev.Message,
189-		})
190-	case irc.ErrorEvent:
191-		var severity string
192-		switch ev.Severity {
193-		case irc.SeverityNote:
194-			severity = "Note"
195-		case irc.SeverityWarn:
196-			severity = "Warning"
197-		case irc.SeverityFail:
198-			severity = "Error"
199-		}
200-		app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
201-			At:        time.Now(),
202-			Head:      "!!",
203-			HeadColor: ui.ColorRed,
204-			Body:      fmt.Sprintf("%s (code %s): %s", severity, ev.Code, ev.Message),
205-		})
206-	case irc.RegisteredEvent:
207-		body := "Connected to the server"
208-		if app.s.Nick() != app.cfg.Nick {
209-			body += " as " + app.s.Nick()
210-		}
211-		app.win.AddLine(Home, false, ui.Line{
212-			At:   time.Now(),
213-			Head: "--",
214-			Body: body,
215-		})
216-	case irc.SelfNickEvent:
217-		app.win.AddLine(app.win.CurrentBuffer(), true, ui.Line{
218-			At:        ev.Time,
219-			Head:      "--",
220-			Body:      fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, app.s.Nick()),
221-			Highlight: true,
222-		})
223-	case irc.UserNickEvent:
224-		for _, c := range app.s.ChannelsSharedWith(ev.User.Name) {
225-			app.win.AddLine(c, false, ui.Line{
226-				At:        ev.Time,
227-				Head:      "--",
228-				Body:      fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.User.Name),
229-				Mergeable: true,
230-			})
231-		}
232-	case irc.SelfJoinEvent:
233-		app.win.AddBuffer(ev.Channel)
234-		app.s.RequestHistory(ev.Channel, time.Now())
235-	case irc.UserJoinEvent:
236-		app.win.AddLine(ev.Channel, false, ui.Line{
237-			At:        time.Now(),
238-			Head:      "--",
239-			Body:      fmt.Sprintf("\x033+\x0314%s\x03", ev.User.Name),
240-			Mergeable: true,
241-		})
242-	case irc.SelfPartEvent:
243-		app.win.RemoveBuffer(ev.Channel)
244-	case irc.UserPartEvent:
245-		app.win.AddLine(ev.Channel, false, ui.Line{
246-			At:        ev.Time,
247-			Head:      "--",
248-			Body:      fmt.Sprintf("\x034-\x0314%s\x03", ev.User.Name),
249-			Mergeable: true,
250-		})
251-	case irc.UserQuitEvent:
252-		for _, c := range ev.Channels {
253-			app.win.AddLine(c, false, ui.Line{
254-				At:        ev.Time,
255-				Head:      "--",
256-				Body:      fmt.Sprintf("\x034-\x0314%s\x03", ev.User.Name),
257-				Mergeable: true,
258-			})
259-		}
260-	case irc.TopicChangeEvent:
261-		app.win.AddLine(ev.Channel, false, ui.Line{
262-			At:   ev.Time,
263-			Head: "--",
264-			Body: fmt.Sprintf("\x0314Topic changed to: %s\x03", ev.Topic),
265-		})
266-	case irc.MessageEvent:
267-		buffer, line, hlNotification := app.formatMessage(ev)
268-		app.win.AddLine(buffer, hlNotification, line)
269-		if hlNotification {
270-			app.notifyHighlight(buffer, ev.User.Name, ev.Content)
271-		}
272-		if !ev.TargetIsChannel && app.s.NickCf() != app.s.Casemap(ev.User.Name) {
273-			app.lastQuery = ev.User.Name
274-		}
275-	case irc.HistoryEvent:
276-		var lines []ui.Line
277-		for _, m := range ev.Messages {
278-			switch m := m.(type) {
279-			case irc.MessageEvent:
280-				_, line, _ := app.formatMessage(m)
281-				lines = append(lines, line)
282-			default:
283-			}
284+// handleEvents handles a batch of events.
285+func (app *App) handleEvents(evs []event) {
286+	for _, ev := range evs {
287+		switch ev.src {
288+		case uiEvent:
289+			app.handleUIEvent(ev.content)
290+		case ircEvent:
291+			app.handleIRCEvent(ev.content)
292+		default:
293+			panic("unreachable")
294 		}
295-		app.win.AddLines(ev.Target, lines)
296-	case error:
297-		panic(ev)
298+	}
299+}
300+
301+func (app *App) handleUIEvent(ev interface{}) {
302+	switch ev := ev.(type) {
303+	case *tcell.EventResize:
304+		app.win.Resize()
305+	case *tcell.EventPaste:
306+		app.pasting = ev.Start()
307+	case *tcell.EventMouse:
308+		app.handleMouseEvent(ev)
309+	case *tcell.EventKey:
310+		app.handleKeyEvent(ev)
311+	case ui.Line:
312+		app.addStatusLine(ev)
313+	default:
314+		return
315 	}
316 }
317 
318@@ -484,24 +416,6 @@ func (app *App) handleKeyEvent(ev *tcell.EventKey) {
319 	}
320 }
321 
322-func (app *App) handleUIEvent(ev tcell.Event) {
323-	switch ev := ev.(type) {
324-	case *tcell.EventResize:
325-		app.win.Resize()
326-	case *tcell.EventPaste:
327-		app.pasting = ev.Start()
328-	case *tcell.EventMouse:
329-		app.handleMouseEvent(ev)
330-	case *tcell.EventKey:
331-		app.handleKeyEvent(ev)
332-	default:
333-		return
334-	}
335-	if !app.pasting {
336-		app.draw()
337-	}
338-}
339-
340 // requestHistory is a wrapper around irc.Session.RequestHistory to only request
341 // history when needed.
342 func (app *App) requestHistory() {
343@@ -510,22 +424,159 @@ func (app *App) requestHistory() {
344 	}
345 	buffer := app.win.CurrentBuffer()
346 	if app.win.IsAtTop() && buffer != Home {
347-		at := time.Now()
348-		if t := app.win.CurrentBufferOldestTime(); t != nil {
349-			at = *t
350+		t := time.Now()
351+		if oldest := app.win.CurrentBufferOldestTime(); oldest != nil {
352+			t = *oldest
353 		}
354-		app.s.RequestHistory(buffer, at)
355+		app.s.NewHistoryRequest(buffer).
356+			WithLimit(100).
357+			Before(t)
358 	}
359 }
360 
361+func (app *App) handleIRCEvent(ev interface{}) {
362+	if ev == nil {
363+		app.s.Close()
364+		app.s = nil
365+		return
366+	}
367+	if s, ok := ev.(*irc.Session); ok {
368+		app.s = s
369+		return
370+	}
371+
372+	msg := ev.(irc.Message)
373+
374+	// Mutate IRC state
375+	ev = app.s.HandleMessage(msg)
376+
377+	// Mutate UI state
378+	switch ev := ev.(type) {
379+	case irc.RegisteredEvent:
380+		body := "Connected to the server"
381+		if app.s.Nick() != app.cfg.Nick {
382+			body += " as " + app.s.Nick()
383+		}
384+		app.win.AddLine(Home, false, ui.Line{
385+			At:   msg.TimeOrNow(),
386+			Head: "--",
387+			Body: body,
388+		})
389+	case irc.SelfNickEvent:
390+		app.win.AddLine(app.win.CurrentBuffer(), true, ui.Line{
391+			At:        msg.TimeOrNow(),
392+			Head:      "--",
393+			Body:      fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, app.s.Nick()),
394+			Highlight: true,
395+		})
396+	case irc.UserNickEvent:
397+		for _, c := range app.s.ChannelsSharedWith(ev.User) {
398+			app.win.AddLine(c, false, ui.Line{
399+				At:        msg.TimeOrNow(),
400+				Head:      "--",
401+				Body:      fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.User),
402+				Mergeable: true,
403+			})
404+		}
405+	case irc.SelfJoinEvent:
406+		app.win.AddBuffer(ev.Channel)
407+		app.s.NewHistoryRequest(ev.Channel).
408+			WithLimit(200).
409+			Before(msg.TimeOrNow())
410+	case irc.UserJoinEvent:
411+		app.win.AddLine(ev.Channel, false, ui.Line{
412+			At:        msg.TimeOrNow(),
413+			Head:      "--",
414+			Body:      fmt.Sprintf("\x033+\x0314%s\x03", ev.User),
415+			Mergeable: true,
416+		})
417+	case irc.SelfPartEvent:
418+		app.win.RemoveBuffer(ev.Channel)
419+	case irc.UserPartEvent:
420+		app.win.AddLine(ev.Channel, false, ui.Line{
421+			At:        msg.TimeOrNow(),
422+			Head:      "--",
423+			Body:      fmt.Sprintf("\x034-\x0314%s\x03", ev.User),
424+			Mergeable: true,
425+		})
426+	case irc.UserQuitEvent:
427+		for _, c := range ev.Channels {
428+			app.win.AddLine(c, false, ui.Line{
429+				At:        msg.TimeOrNow(),
430+				Head:      "--",
431+				Body:      fmt.Sprintf("\x034-\x0314%s\x03", ev.User),
432+				Mergeable: true,
433+			})
434+		}
435+	case irc.TopicChangeEvent:
436+		app.win.AddLine(ev.Channel, false, ui.Line{
437+			At:   msg.TimeOrNow(),
438+			Head: "--",
439+			Body: fmt.Sprintf("\x0314Topic changed to: %s\x03", ev.Topic),
440+		})
441+	case irc.MessageEvent:
442+		buffer, line, hlNotification := app.formatMessage(ev)
443+		app.win.AddLine(buffer, hlNotification, line)
444+		if hlNotification {
445+			app.notifyHighlight(buffer, ev.User, ev.Content)
446+		}
447+		if !app.s.IsChannel(msg.Params[0]) && !app.s.IsMe(ev.User) {
448+			app.lastQuery = msg.Prefix.Name
449+		}
450+	case irc.HistoryEvent:
451+		var lines []ui.Line
452+		for _, m := range ev.Messages {
453+			switch ev := m.(type) {
454+			case irc.MessageEvent:
455+				_, line, _ := app.formatMessage(ev)
456+				lines = append(lines, line)
457+			}
458+		}
459+		app.win.AddLines(ev.Target, lines)
460+	case irc.ErrorEvent:
461+		if isBlackListed(msg.Command) {
462+			break
463+		}
464+		var head string
465+		var body string
466+		switch ev.Severity {
467+		case irc.SeverityFail:
468+			head = "--"
469+			body = fmt.Sprintf("Error (code %s): %s", ev.Code, ev.Message)
470+		case irc.SeverityWarn:
471+			head = "--"
472+			body = fmt.Sprintf("Warning (code %s): %s", ev.Code, ev.Message)
473+		case irc.SeverityNote:
474+			head = ev.Code + " --"
475+			body = ev.Message
476+		default:
477+			panic("unreachable")
478+		}
479+		app.addStatusLine(ui.Line{
480+			At:   msg.TimeOrNow(),
481+			Head: head,
482+			Body: body,
483+		})
484+	}
485+}
486+
487+func isBlackListed(command string) bool {
488+	switch command {
489+	case "002", "003", "004", "422":
490+		// useless connection messages
491+		return true
492+	}
493+	return false
494+}
495+
496 // isHighlight reports whether the given message content is a highlight.
497 func (app *App) isHighlight(content string) bool {
498-	contentCf := strings.ToLower(content)
499+	contentCf := app.s.Casemap(content)
500 	if app.highlights == nil {
501 		return strings.Contains(contentCf, app.s.NickCf())
502 	}
503 	for _, h := range app.highlights {
504-		if strings.Contains(contentCf, h) {
505+		if strings.Contains(contentCf, app.s.Casemap(h)) {
506 			return true
507 		}
508 	}
509@@ -556,7 +607,7 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
510 	output, err := cmd.CombinedOutput()
511 	if err != nil {
512 		body := fmt.Sprintf("Failed to invoke on-highlight command: %v. Output: %q", err, string(output))
513-		app.win.AddLine(Home, false, ui.Line{
514+		app.addStatusLine(ui.Line{
515 			At:        time.Now(),
516 			Head:      "!!",
517 			HeadColor: ui.ColorRed,
518@@ -615,7 +666,7 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
519 // - the UI line,
520 // - whether senpai must trigger the "on-highlight" command.
521 func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line, hlNotification bool) {
522-	isFromSelf := app.s.NickCf() == app.s.Casemap(ev.User.Name)
523+	isFromSelf := app.s.IsMe(ev.User)
524 	isHighlight := app.isHighlight(ev.Content)
525 	isAction := strings.HasPrefix(ev.Content, "\x01ACTION")
526 	isQuery := !ev.TargetIsChannel && ev.Command == "PRIVMSG"
527@@ -632,7 +683,7 @@ func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line,
528 	hlLine := ev.TargetIsChannel && isHighlight && !isFromSelf
529 	hlNotification = (isHighlight || isQuery) && !isFromSelf
530 
531-	head := ev.User.Name
532+	head := ev.User
533 	headColor := ui.ColorWhite
534 	if isFromSelf && isQuery {
535 		head = "\u2192 " + ev.Target
536@@ -645,14 +696,14 @@ func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line,
537 
538 	body := strings.TrimSuffix(ev.Content, "\x01")
539 	if isNotice && isAction {
540-		c := ircColorSequence(ui.IdentColor(ev.User.Name))
541-		body = fmt.Sprintf("(%s%s\x0F:%s)", c, ev.User.Name, body[7:])
542+		c := ircColorSequence(ui.IdentColor(ev.User))
543+		body = fmt.Sprintf("(%s%s\x0F:%s)", c, ev.User, body[7:])
544 	} else if isAction {
545-		c := ircColorSequence(ui.IdentColor(ev.User.Name))
546-		body = fmt.Sprintf("%s%s\x0F%s", c, ev.User.Name, body[7:])
547+		c := ircColorSequence(ui.IdentColor(ev.User))
548+		body = fmt.Sprintf("%s%s\x0F%s", c, ev.User, body[7:])
549 	} else if isNotice {
550-		c := ircColorSequence(ui.IdentColor(ev.User.Name))
551-		body = fmt.Sprintf("(%s%s\x0F: %s)", c, ev.User.Name, body)
552+		c := ircColorSequence(ui.IdentColor(ev.User))
553+		body = fmt.Sprintf("(%s%s\x0F: %s)", c, ev.User, body)
554 	}
555 
556 	line = ui.Line{
+17, -20
 1@@ -56,17 +56,24 @@ func main() {
 2 	if password != "" {
 3 		auth = &irc.SASLPlain{Username: nick, Password: password}
 4 	}
 5-	cli, err := irc.NewSession(conn, irc.SessionParams{
 6+
 7+	in, out := irc.ChanInOut(conn)
 8+	debugOut := make(chan irc.Message, 64)
 9+	go func() {
10+		for msg := range debugOut {
11+			fmt.Fprintf(t, "C  > S: %s\n", msg.String())
12+			out <- msg
13+		}
14+		close(out)
15+	}()
16+
17+	cli := irc.NewSession(debugOut, irc.SessionParams{
18 		Nickname: nick,
19 		Username: nick,
20 		RealName: nick,
21 		Auth:     auth,
22-		Debug:    true,
23 	})
24-	if err != nil {
25-		panic(fmt.Sprintf("Failed to connect to %s: %v", address, err))
26-	}
27-	defer cli.Stop()
28+	defer cli.Close()
29 
30 	go func() {
31 		for {
32@@ -76,22 +83,12 @@ func main() {
33 			}
34 			cli.SendRaw(line)
35 		}
36-		cli.Stop()
37+		cli.Close()
38 	}()
39 
40-	for ev := range cli.Poll() {
41-		switch ev := ev.(type) {
42-		case irc.RawMessageEvent:
43-			if ev.Outgoing {
44-				fmt.Fprintf(t, "C  > S: %s\n", ev.Message)
45-			} else {
46-				fmt.Fprintf(t, "C <  S: %s\n", ev.Message)
47-			}
48-		case error:
49-			panic(ev)
50-		default:
51-			fmt.Fprintf(t, "=EVENT: %T%+v\n", ev, ev)
52-		}
53+	for msg := range in {
54+		cli.HandleMessage(msg)
55+		fmt.Fprintf(t, "C <  S: %s\n", msg.String())
56 	}
57 	t.SetPrompt("")
58 	fmt.Fprintln(t, "Disconnected")
+5, -5
 1@@ -131,7 +131,7 @@ func commandDo(app *App, buffer string, args []string) (err error) {
 2 	app.s.PrivMsg(buffer, args[0])
 3 	if !app.s.HasCapability("echo-message") {
 4 		buffer, line, _ := app.formatMessage(irc.MessageEvent{
 5-			User:            &irc.Prefix{Name: app.s.Nick()},
 6+			User:            app.s.Nick(),
 7 			Target:          buffer,
 8 			TargetIsChannel: true,
 9 			Command:         "PRIVMSG",
10@@ -215,7 +215,7 @@ func commandDoMe(app *App, buffer string, args []string) (err error) {
11 	app.s.PrivMsg(buffer, content)
12 	if !app.s.HasCapability("echo-message") {
13 		buffer, line, _ := app.formatMessage(irc.MessageEvent{
14-			User:            &irc.Prefix{Name: app.s.Nick()},
15+			User:            app.s.Nick(),
16 			Target:          buffer,
17 			TargetIsChannel: true,
18 			Command:         "PRIVMSG",
19@@ -233,7 +233,7 @@ func commandDoMsg(app *App, buffer string, args []string) (err error) {
20 	app.s.PrivMsg(target, content)
21 	if !app.s.HasCapability("echo-message") {
22 		buffer, line, _ := app.formatMessage(irc.MessageEvent{
23-			User:            &irc.Prefix{Name: app.s.Nick()},
24+			User:            app.s.Nick(),
25 			Target:          target,
26 			TargetIsChannel: true,
27 			Command:         "PRIVMSG",
28@@ -327,7 +327,7 @@ func commandDoR(app *App, buffer string, args []string) (err error) {
29 	app.s.PrivMsg(app.lastQuery, args[0])
30 	if !app.s.HasCapability("echo-message") {
31 		buffer, line, _ := app.formatMessage(irc.MessageEvent{
32-			User:            &irc.Prefix{Name: app.s.Nick()},
33+			User:            app.s.Nick(),
34 			Target:          app.lastQuery,
35 			TargetIsChannel: true,
36 			Command:         "PRIVMSG",
37@@ -355,7 +355,7 @@ func commandDoTopic(app *App, buffer string, args []string) (err error) {
38 			Body: body,
39 		})
40 	} else {
41-		app.s.SetTopic(buffer, args[0])
42+		app.s.ChangeTopic(buffer, args[0])
43 	}
44 	return
45 }
+40, -0
 1@@ -0,0 +1,40 @@
 2+package irc
 3+
 4+import (
 5+	"bufio"
 6+	"fmt"
 7+	"net"
 8+)
 9+
10+const chanCapacity = 64
11+
12+func ChanInOut(conn net.Conn) (in <-chan Message, out chan<- Message) {
13+	in_ := make(chan Message, chanCapacity)
14+	out_ := make(chan Message, chanCapacity)
15+
16+	go func() {
17+		r := bufio.NewScanner(conn)
18+		for r.Scan() {
19+			line := r.Text()
20+			msg, err := ParseMessage(line)
21+			if err != nil {
22+				continue
23+			}
24+			in_ <- msg
25+		}
26+		close(in_)
27+	}()
28+
29+	go func() {
30+		for msg := range out_ {
31+			// TODO send messages by batches
32+			_, err := fmt.Fprintf(conn, "%s\r\n", msg.String())
33+			if err != nil {
34+				break
35+			}
36+		}
37+		_ = conn.Close()
38+	}()
39+
40+	return in_, out_
41+}
+6, -29
 1@@ -1,17 +1,9 @@
 2 package irc
 3 
 4-import (
 5-	"time"
 6-)
 7+import "time"
 8 
 9 type Event interface{}
10 
11-type RawMessageEvent struct {
12-	Message  string
13-	Outgoing bool
14-	IsValid  bool
15-}
16-
17 type ErrorEvent struct {
18 	Severity Severity
19 	Code     string
20@@ -22,13 +14,11 @@ type RegisteredEvent struct{}
21 
22 type SelfNickEvent struct {
23 	FormerNick string
24-	Time       time.Time
25 }
26 
27 type UserNickEvent struct {
28-	User       *Prefix
29+	User       string
30 	FormerNick string
31-	Time       time.Time
32 }
33 
34 type SelfJoinEvent struct {
35@@ -36,9 +26,8 @@ type SelfJoinEvent struct {
36 }
37 
38 type UserJoinEvent struct {
39-	User    *Prefix
40+	User    string
41 	Channel string
42-	Time    time.Time
43 }
44 
45 type SelfPartEvent struct {
46@@ -46,26 +35,22 @@ type SelfPartEvent struct {
47 }
48 
49 type UserPartEvent struct {
50-	User    *Prefix
51+	User    string
52 	Channel string
53-	Time    time.Time
54 }
55 
56 type UserQuitEvent struct {
57-	User     *Prefix
58+	User     string
59 	Channels []string
60-	Time     time.Time
61 }
62 
63 type TopicChangeEvent struct {
64-	User    *Prefix
65 	Channel string
66 	Topic   string
67-	Time    time.Time
68 }
69 
70 type MessageEvent struct {
71-	User            *Prefix
72+	User            string
73 	Target          string
74 	TargetIsChannel bool
75 	Command         string
76@@ -73,14 +58,6 @@ type MessageEvent struct {
77 	Time            time.Time
78 }
79 
80-type TagEvent struct {
81-	User            *Prefix
82-	Target          string
83-	TargetIsChannel bool
84-	Typing          int
85-	Time            time.Time
86-}
87-
88 type HistoryEvent struct {
89 	Target   string
90 	Messages []Event
R irc/states.go => irc/session.go
+225, -544
   1@@ -1,21 +1,17 @@
   2 package irc
   3 
   4 import (
   5-	"bufio"
   6 	"bytes"
   7 	"encoding/base64"
   8 	"errors"
   9 	"fmt"
  10-	"net"
  11 	"strconv"
  12 	"strings"
  13-	"sync/atomic"
  14 	"time"
  15+	"unicode"
  16 	"unicode/utf8"
  17 )
  18 
  19-const writeDeadline = 10 * time.Second
  20-
  21 type SASLClient interface {
  22 	Handshake() (mech string)
  23 	Respond(challenge string) (res string, err error)
  24@@ -74,62 +70,6 @@ const (
  25 	TypingDone
  26 )
  27 
  28-// action contains the arguments of a user action.
  29-//
  30-// To keep connection reads and writes in a single coroutine, the library
  31-// interface functions like Join("#channel") or PrivMsg("target", "message")
  32-// don't interact with the IRC session directly.  Instead, they push an action
  33-// in the action channel.  This action is then processed by the correct
  34-// coroutine.
  35-type action interface{}
  36-
  37-type (
  38-	actionSendRaw struct {
  39-		raw string
  40-	}
  41-
  42-	actionChangeNick struct {
  43-		Nick string
  44-	}
  45-	actionChangeMode struct {
  46-		Channel string
  47-		Flags   string
  48-		Args    []string
  49-	}
  50-
  51-	actionJoin struct {
  52-		Channel string
  53-	}
  54-	actionPart struct {
  55-		Channel string
  56-		Reason  string
  57-	}
  58-	actionSetTopic struct {
  59-		Channel string
  60-		Topic   string
  61-	}
  62-	actionQuit struct {
  63-		Reason string
  64-	}
  65-
  66-	actionPrivMsg struct {
  67-		Target  string
  68-		Content string
  69-	}
  70-
  71-	actionTyping struct {
  72-		Channel string
  73-	}
  74-	actionTypingStop struct {
  75-		Channel string
  76-	}
  77-
  78-	actionRequestHistory struct {
  79-		Target string
  80-		Before time.Time
  81-	}
  82-)
  83-
  84 // User is a known IRC user (we share a channel with it).
  85 type User struct {
  86 	Name    *Prefix // the nick, user and hostname of the user if known.
  87@@ -155,20 +95,11 @@ type SessionParams struct {
  88 	RealName string
  89 
  90 	Auth SASLClient
  91-
  92-	Debug bool // whether the Session should report all messages it sends and receive.
  93 }
  94 
  95-// Session is an IRC session/connection/whatever.
  96 type Session struct {
  97-	conn net.Conn
  98-	msgs chan Message // incoming messages.
  99-	acts chan action  // user actions.
 100-	evts chan Event   // events sent to the user.
 101-
 102-	debug bool
 103-
 104-	running      atomic.Value // bool
 105+	out          chan<- Message
 106+	closed       bool
 107 	registered   bool
 108 	typings      *Typings             // incoming typing notifications.
 109 	typingStamps map[string]time.Time // user typing instants.
 110@@ -185,9 +116,12 @@ type Session struct {
 111 	enabledCaps   map[string]struct{}
 112 
 113 	// ISUPPORT features
 114-	casemap   func(string) string
 115-	chantypes string
 116-	linelen   int
 117+	casemap       func(string) string
 118+	chantypes     string
 119+	linelen       int
 120+	historyLimit  int
 121+	prefixSymbols string
 122+	prefixModes   string
 123 
 124 	users     map[string]*User        // known users.
 125 	channels  map[string]Channel      // joined channels.
 126@@ -195,18 +129,9 @@ type Session struct {
 127 	chReqs    map[string]struct{}     // set of targets for which history is currently requested.
 128 }
 129 
 130-// NewSession starts an IRC session from the given connection and session
 131-// parameters.
 132-//
 133-// It returns an error when the paramaters are invalid, or when it cannot write
 134-// to the connection.
 135-func NewSession(conn net.Conn, params SessionParams) (*Session, error) {
 136+func NewSession(out chan<- Message, params SessionParams) *Session {
 137 	s := &Session{
 138-		conn:          conn,
 139-		msgs:          make(chan Message, 64),
 140-		acts:          make(chan action, 64),
 141-		evts:          make(chan Event, 64),
 142-		debug:         params.Debug,
 143+		out:           out,
 144 		typings:       NewTypings(),
 145 		typingStamps:  map[string]time.Time{},
 146 		nick:          params.Nickname,
 147@@ -219,66 +144,28 @@ func NewSession(conn net.Conn, params SessionParams) (*Session, error) {
 148 		casemap:       CasemapRFC1459,
 149 		chantypes:     "#&",
 150 		linelen:       512,
 151+		historyLimit:  100,
 152+		prefixSymbols: "@+",
 153+		prefixModes:   "ov",
 154 		users:         map[string]*User{},
 155 		channels:      map[string]Channel{},
 156 		chBatches:     map[string]HistoryEvent{},
 157 		chReqs:        map[string]struct{}{},
 158 	}
 159 
 160-	s.running.Store(true)
 161-
 162-	go func() {
 163-		r := bufio.NewScanner(conn)
 164+	s.out <- NewMessage("CAP", "LS", "302")
 165+	s.out <- NewMessage("NICK", s.nick)
 166+	s.out <- NewMessage("USER", s.user, "0", "*", s.real)
 167 
 168-		for r.Scan() {
 169-			line := r.Text()
 170-			msg, err := ParseMessage(line)
 171-			if err != nil {
 172-				continue
 173-			}
 174-			valid := msg.IsValid()
 175-			if s.debug {
 176-				s.evts <- RawMessageEvent{Message: line, IsValid: valid}
 177-			}
 178-			if valid {
 179-				s.msgs <- msg
 180-			}
 181-		}
 182-
 183-		s.Stop()
 184-	}()
 185-
 186-	err := s.send("CAP LS 302\r\nNICK %s\r\nUSER %s 0 * :%s\r\n", s.nick, s.user, s.real)
 187-	if err != nil {
 188-		return nil, err
 189-	}
 190-
 191-	go s.run()
 192-
 193-	return s, nil
 194-}
 195-
 196-// Running reports whether we are still connected to the server.
 197-func (s *Session) Running() bool {
 198-	return s.running.Load().(bool)
 199+	return s
 200 }
 201 
 202-// Stop stops the session and closes the connection.
 203-func (s *Session) Stop() {
 204-	if !s.Running() {
 205+func (s *Session) Close() {
 206+	if s.closed {
 207 		return
 208 	}
 209-	s.running.Store(false)
 210-	_ = s.conn.Close()
 211-	close(s.acts)
 212-	close(s.evts)
 213-	close(s.msgs)
 214-	s.typings.Stop()
 215-}
 216-
 217-// Poll returns the event channel where incoming events are reported.
 218-func (s *Session) Poll() (events <-chan Event) {
 219-	return s.evts
 220+	s.closed = true
 221+	close(s.out)
 222 }
 223 
 224 // HasCapability reports whether the given capability has been negociated
 225@@ -297,6 +184,10 @@ func (s *Session) NickCf() string {
 226 	return s.nickCf
 227 }
 228 
 229+func (s *Session) IsMe(nick string) bool {
 230+	return s.nickCf == s.casemap(nick)
 231+}
 232+
 233 func (s *Session) IsChannel(name string) bool {
 234 	return strings.IndexAny(name, s.chantypes) == 0
 235 }
 236@@ -332,11 +223,14 @@ func (s *Session) Names(channel string) []Member {
 237 
 238 // Typings returns the list of nickname who are currently typing.
 239 func (s *Session) Typings(target string) []string {
 240-	targetCf := s.Casemap(target)
 241-	var res []string
 242-	for t := range s.typings.targets {
 243-		if targetCf == t.Target && s.Casemap(t.Name) != s.NickCf() {
 244-			res = append(res, s.users[t.Name].Name.Name)
 245+	targetCf := s.casemap(target)
 246+	res := s.typings.List(targetCf)
 247+	for i := 0; i < len(res); i++ {
 248+		if s.IsMe(res[i]) {
 249+			res = append(res[:i], res[i+1:]...)
 250+			i--
 251+		} else if u, ok := s.users[res[i]]; ok {
 252+			res[i] = u.Name.Name
 253 		}
 254 	}
 255 	return res
 256@@ -368,50 +262,34 @@ func (s *Session) Topic(channel string) (topic string, who *Prefix, at time.Time
 257 	return
 258 }
 259 
 260-// SendRaw sends its given argument verbatim to the server.
 261 func (s *Session) SendRaw(raw string) {
 262-	s.acts <- actionSendRaw{raw}
 263-}
 264-
 265-func (s *Session) sendRaw(act actionSendRaw) (err error) {
 266-	err = s.send("%s\r\n", act.raw)
 267-	return
 268+	s.out <- NewMessage(raw)
 269 }
 270 
 271 func (s *Session) Join(channel string) {
 272-	s.acts <- actionJoin{channel}
 273-}
 274-
 275-func (s *Session) join(act actionJoin) (err error) {
 276-	err = s.send("JOIN %s\r\n", act.Channel)
 277-	return
 278+	// TODO support keys
 279+	s.out <- NewMessage("JOIN", channel)
 280 }
 281 
 282 func (s *Session) Part(channel, reason string) {
 283-	s.acts <- actionPart{channel, reason}
 284+	s.out <- NewMessage("PART", channel, reason)
 285 }
 286 
 287-func (s *Session) part(act actionPart) (err error) {
 288-	err = s.send("PART %s :%s\r\n", act.Channel, act.Reason)
 289-	return
 290+func (s *Session) ChangeTopic(channel, topic string) {
 291+	s.out <- NewMessage("TOPIC", channel, topic)
 292 }
 293 
 294-func (s *Session) SetTopic(channel, topic string) {
 295-	s.acts <- actionSetTopic{channel, topic}
 296-}
 297-
 298-func (s *Session) setTopic(act actionSetTopic) (err error) {
 299-	err = s.send("TOPIC %s :%s\r\n", act.Channel, act.Topic)
 300-	return
 301+func (s *Session) Quit(reason string) {
 302+	s.out <- NewMessage("QUIT", reason)
 303 }
 304 
 305-func (s *Session) Quit(reason string) {
 306-	s.acts <- actionQuit{reason}
 307+func (s *Session) ChangeNick(nick string) {
 308+	s.out <- NewMessage("NICK", nick)
 309 }
 310 
 311-func (s *Session) quit(act actionQuit) (err error) {
 312-	err = s.send("QUIT :%s\r\n", act.Reason)
 313-	return
 314+func (s *Session) ChangeMode(channel, flags string, args []string) {
 315+	args = append([]string{channel, flags}, args...)
 316+	s.out <- NewMessage("MODE", args...)
 317 }
 318 
 319 func splitChunks(s string, chunkLen int) (chunks []string) {
 320@@ -433,34 +311,7 @@ func splitChunks(s string, chunkLen int) (chunks []string) {
 321 	return
 322 }
 323 
 324-func (s *Session) ChangeNick(nick string) {
 325-	s.acts <- actionChangeNick{nick}
 326-}
 327-
 328-func (s *Session) changeNick(act actionChangeNick) (err error) {
 329-	err = s.send("NICK %s\r\n", act.Nick)
 330-	return
 331-}
 332-
 333-func (s *Session) ChangeMode(channel string, flags string, args []string) {
 334-	s.acts <- actionChangeMode{channel, flags, args}
 335-}
 336-
 337-func (s *Session) changeMode(act actionChangeMode) (err error) {
 338-	if strings.IndexAny(act.Channel, s.chantypes) == 0 {
 339-		err = s.send("MODE %s %s %s\r\n",
 340-			act.Channel, act.Flags, strings.Join(act.Args, " "))
 341-	} else {
 342-		err = s.send("MODE %s %s\r\n", act.Channel, act.Flags)
 343-	}
 344-	return
 345-}
 346-
 347 func (s *Session) PrivMsg(target, content string) {
 348-	s.acts <- actionPrivMsg{target, content}
 349-}
 350-
 351-func (s *Session) privMsg(act actionPrivMsg) (err error) {
 352 	hostLen := len(s.host)
 353 	if hostLen == 0 {
 354 		hostLen = len("255.255.255.255")
 355@@ -470,173 +321,115 @@ func (s *Session) privMsg(act actionPrivMsg) (err error) {
 356 		len(s.nick) -
 357 		len(s.user) -
 358 		hostLen -
 359-		len(act.Target)
 360-	chunks := splitChunks(act.Content, maxMessageLen)
 361+		len(target)
 362+	chunks := splitChunks(content, maxMessageLen)
 363 	for _, chunk := range chunks {
 364-		err = s.send("PRIVMSG %s :%s\r\n", act.Target, chunk)
 365-		if err != nil {
 366-			return
 367-		}
 368+		s.out <- NewMessage("PRIVMSG", target, chunk)
 369 	}
 370-	target := s.Casemap(act.Target)
 371-	delete(s.typingStamps, target)
 372-	return
 373-}
 374-
 375-func (s *Session) Typing(channel string) {
 376-	s.acts <- actionTyping{channel}
 377+	targetCf := s.Casemap(target)
 378+	delete(s.typingStamps, targetCf)
 379 }
 380 
 381-func (s *Session) typing(act actionTyping) (err error) {
 382-	if _, ok := s.enabledCaps["message-tags"]; !ok {
 383+func (s *Session) Typing(target string) {
 384+	if !s.HasCapability("message-tags") {
 385 		return
 386 	}
 387-
 388-	to := s.Casemap(act.Channel)
 389+	targetCf := s.casemap(target)
 390 	now := time.Now()
 391-
 392-	if t, ok := s.typingStamps[to]; ok && now.Sub(t).Seconds() < 3.0 {
 393+	if t, ok := s.typingStamps[targetCf]; ok && now.Sub(t).Seconds() < 3.0 {
 394 		return
 395 	}
 396-
 397-	s.typingStamps[to] = now
 398-
 399-	err = s.send("@+typing=active TAGMSG %s\r\n", act.Channel)
 400-	return
 401-}
 402-
 403-func (s *Session) TypingStop(channel string) {
 404-	s.acts <- actionTypingStop{channel}
 405+	s.typingStamps[targetCf] = now
 406+	s.out <- NewMessage("TAGMSG", target).WithTag("+typing", "active")
 407 }
 408 
 409-func (s *Session) typingStop(act actionTypingStop) (err error) {
 410-	if _, ok := s.enabledCaps["message-tags"]; !ok {
 411+func (s *Session) TypingStop(target string) {
 412+	if !s.HasCapability("message-tags") {
 413 		return
 414 	}
 415+	s.out <- NewMessage("TAGMSG", target).WithTag("+typing", "done")
 416+}
 417 
 418-	err = s.send("@+typing=done TAGMSG %s\r\n", act.Channel)
 419-	return
 420+type HistoryRequest struct {
 421+	s       *Session
 422+	target  string
 423+	command string
 424+	bounds  []string
 425+	limit   int
 426 }
 427 
 428-func (s *Session) RequestHistory(target string, before time.Time) {
 429-	s.acts <- actionRequestHistory{target, before}
 430+func formatTimestamp(t time.Time) string {
 431+	return fmt.Sprintf("timestamp=%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
 432+		t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond()/1e6)
 433 }
 434 
 435-func (s *Session) requestHistory(act actionRequestHistory) (err error) {
 436-	if _, ok := s.enabledCaps["draft/chathistory"]; !ok {
 437-		return
 438+func (r *HistoryRequest) WithLimit(limit int) *HistoryRequest {
 439+	if limit < r.s.historyLimit {
 440+		r.limit = limit
 441+	} else {
 442+		r.limit = r.s.historyLimit
 443 	}
 444+	return r
 445+}
 446 
 447-	target := s.Casemap(act.Target)
 448-	if _, ok := s.chReqs[target]; ok {
 449+func (r *HistoryRequest) doRequest() {
 450+	if !r.s.HasCapability("draft/chathistory") {
 451 		return
 452 	}
 453-	s.chReqs[target] = struct{}{}
 454 
 455-	t := act.Before.UTC().Add(1 * time.Second)
 456-	err = s.send("CHATHISTORY BEFORE %s timestamp=%04d-%02d-%02dT%02d:%02d:%02d.%03dZ 100\r\n", act.Target, t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond()/1e6)
 457+	targetCf := r.s.casemap(r.target)
 458+	if _, ok := r.s.chReqs[targetCf]; ok {
 459+		return
 460+	}
 461+	r.s.chReqs[targetCf] = struct{}{}
 462 
 463-	return
 464+	args := make([]string, 0, len(r.bounds)+3)
 465+	args = append(args, r.command)
 466+	args = append(args, r.target)
 467+	args = append(args, r.bounds...)
 468+	args = append(args, strconv.Itoa(r.limit))
 469+	r.s.out <- NewMessage("CHATHISTORY", args...)
 470 }
 471 
 472-func (s *Session) run() {
 473-	for s.Running() {
 474-		var err error
 475+func (r *HistoryRequest) Before(t time.Time) {
 476+	r.command = "BEFORE"
 477+	r.bounds = []string{formatTimestamp(t)}
 478+	r.doRequest()
 479+}
 480 
 481-		select {
 482-		case act, ok := <-s.acts:
 483-			if !ok {
 484-				break
 485-			}
 486-			switch act := act.(type) {
 487-			case actionSendRaw:
 488-				err = s.sendRaw(act)
 489-			case actionChangeNick:
 490-				err = s.changeNick(act)
 491-			case actionChangeMode:
 492-				err = s.changeMode(act)
 493-			case actionJoin:
 494-				err = s.join(act)
 495-			case actionPart:
 496-				err = s.part(act)
 497-			case actionSetTopic:
 498-				err = s.setTopic(act)
 499-			case actionQuit:
 500-				err = s.quit(act)
 501-			case actionPrivMsg:
 502-				err = s.privMsg(act)
 503-			case actionTyping:
 504-				err = s.typing(act)
 505-			case actionTypingStop:
 506-				err = s.typingStop(act)
 507-			case actionRequestHistory:
 508-				err = s.requestHistory(act)
 509-			}
 510-		case msg, ok := <-s.msgs:
 511-			if !ok {
 512-				break
 513-			}
 514-			if s.registered {
 515-				err = s.handle(msg)
 516-			} else {
 517-				err = s.handleStart(msg)
 518-			}
 519-		case t, ok := <-s.typings.Stops():
 520-			if !ok {
 521-				break
 522-			}
 523-			u, ok := s.users[t.Name]
 524-			if !ok {
 525-				break
 526-			}
 527-			c, ok := s.channels[t.Target]
 528-			if !ok {
 529-				break
 530-			}
 531-			s.evts <- TagEvent{
 532-				User:   u.Name,
 533-				Target: c.Name,
 534-				Typing: TypingDone,
 535-				Time:   time.Now(),
 536-			}
 537-		}
 538+func (s *Session) NewHistoryRequest(target string) *HistoryRequest {
 539+	return &HistoryRequest{
 540+		s:      s,
 541+		target: target,
 542+		limit:  s.historyLimit,
 543+	}
 544+}
 545 
 546-		if err != nil {
 547-			s.evts <- err
 548-		}
 549+func (s *Session) HandleMessage(msg Message) Event {
 550+	if s.registered {
 551+		return s.handleRegistered(msg)
 552+	} else {
 553+		return s.handleUnregistered(msg)
 554 	}
 555 }
 556 
 557-func (s *Session) handleStart(msg Message) (err error) {
 558+func (s *Session) handleUnregistered(msg Message) Event {
 559 	switch msg.Command {
 560 	case "AUTHENTICATE":
 561 		if s.auth != nil {
 562-			var res string
 563-
 564-			res, err = s.auth.Respond(msg.Params[0])
 565+			res, err := s.auth.Respond(msg.Params[0])
 566 			if err != nil {
 567-				err = s.send("AUTHENTICATE *\r\n")
 568-				return
 569-			}
 570-
 571-			err = s.send("AUTHENTICATE %s\r\n", res)
 572-			if err != nil {
 573-				return
 574+				s.out <- NewMessage("AUTHENTICATE", "*")
 575+			} else {
 576+				s.out <- NewMessage("AUTHENTICATE", res)
 577 			}
 578 		}
 579 	case rplLoggedin:
 580-		err = s.send("CAP END\r\n")
 581-		if err != nil {
 582-			return
 583-		}
 584-
 585+		s.out <- NewMessage("CAP", "END")
 586 		s.acct = msg.Params[2]
 587 		s.host = ParsePrefix(msg.Params[1]).Host
 588 	case errNicklocked, errSaslfail, errSasltoolong, errSaslaborted, errSaslalready, rplSaslmechs:
 589-		err = s.send("CAP END\r\n")
 590-		if err != nil {
 591-			return
 592-		}
 593+		s.out <- NewMessage("CAP", "END")
 594 	case "CAP":
 595 		switch msg.Params[1] {
 596 		case "LS":
 597@@ -652,57 +445,44 @@ func (s *Session) handleStart(msg Message) (err error) {
 598 			}
 599 
 600 			for _, c := range ParseCaps(ls) {
 601-				if c.Enable {
 602-					s.availableCaps[c.Name] = c.Value
 603-				} else {
 604-					delete(s.availableCaps, c.Name)
 605-				}
 606+				s.availableCaps[c.Name] = c.Value
 607 			}
 608 
 609 			if !willContinue {
 610-				var req strings.Builder
 611-
 612 				for c := range s.availableCaps {
 613 					if _, ok := SupportedCapabilities[c]; !ok {
 614 						continue
 615 					}
 616-
 617-					_, _ = fmt.Fprintf(&req, "CAP REQ %s\r\n", c)
 618+					s.out <- NewMessage("CAP", "REQ", c)
 619 				}
 620 
 621 				_, ok := s.availableCaps["sasl"]
 622 				if s.auth == nil || !ok {
 623-					_, _ = fmt.Fprintf(&req, "CAP END\r\n")
 624-				}
 625-
 626-				err = s.send(req.String())
 627-				if err != nil {
 628-					return
 629+					s.out <- NewMessage("CAP", "END")
 630 				}
 631 			}
 632 		default:
 633-			s.handle(msg)
 634+			return s.handleRegistered(msg)
 635 		}
 636 	case errNicknameinuse:
 637-		err = s.send("NICK %s_\r\n", msg.Params[1])
 638-		if err != nil {
 639-			return
 640-		}
 641+		s.out <- NewMessage("NICK", msg.Params[1]+"_")
 642+	case rplSaslsuccess:
 643+		// do nothing
 644 	default:
 645-		err = s.handle(msg)
 646+		return s.handleRegistered(msg)
 647 	}
 648-
 649-	return
 650+	return nil
 651 }
 652 
 653-func (s *Session) handle(msg Message) (err error) {
 654+func (s *Session) handleRegistered(msg Message) Event {
 655 	if id, ok := msg.Tags["batch"]; ok {
 656 		if b, ok := s.chBatches[id]; ok {
 657+			ev := s.newMessageEvent(msg)
 658 			s.chBatches[id] = HistoryEvent{
 659 				Target:   b.Target,
 660-				Messages: append(b.Messages, s.privmsgToEvent(msg)),
 661+				Messages: append(b.Messages, ev),
 662 			}
 663-			return
 664+			return nil
 665 		}
 666 	}
 667 
 668@@ -714,14 +494,10 @@ func (s *Session) handle(msg Message) (err error) {
 669 		s.users[s.nickCf] = &User{Name: &Prefix{
 670 			Name: s.nick, User: s.user, Host: s.host,
 671 		}}
 672-		s.evts <- RegisteredEvent{}
 673-
 674 		if s.host == "" {
 675-			err = s.send("WHO %s\r\n", s.nick)
 676-			if err != nil {
 677-				return
 678-			}
 679+			s.out <- NewMessage("WHO", s.nick)
 680 		}
 681+		return RegisteredEvent{}
 682 	case rplIsupport:
 683 		s.updateFeatures(msg.Params[1 : len(msg.Params)-1])
 684 	case rplWhoreply:
 685@@ -731,106 +507,49 @@ func (s *Session) handle(msg Message) (err error) {
 686 	case "CAP":
 687 		switch msg.Params[1] {
 688 		case "ACK":
 689-			for _, c := range strings.Split(msg.Params[2], " ") {
 690-				s.enabledCaps[c] = struct{}{}
 691+			for _, c := range ParseCaps(msg.Params[2]) {
 692+				if c.Enable {
 693+					s.enabledCaps[c.Name] = struct{}{}
 694+				} else {
 695+					delete(s.enabledCaps, c.Name)
 696+				}
 697 
 698-				if s.auth != nil && c == "sasl" {
 699+				if s.auth != nil && c.Name == "sasl" {
 700 					h := s.auth.Handshake()
 701-					err = s.send("AUTHENTICATE %s\r\n", h)
 702-					if err != nil {
 703-						return
 704-					}
 705-				} else if len(s.channels) != 0 && c == "multi-prefix" {
 706+					s.out <- NewMessage("AUTHENTICATE", h)
 707+				} else if len(s.channels) != 0 && c.Name == "multi-prefix" {
 708 					// TODO merge NAMES commands
 709-					var sb strings.Builder
 710-					sb.Grow(512)
 711-					for _, c := range s.channels {
 712-						sb.WriteString("NAMES ")
 713-						sb.WriteString(c.Name)
 714-						sb.WriteString("\r\n")
 715-					}
 716-					err = s.send(sb.String())
 717-					if err != nil {
 718-						return
 719+					for channel := range s.channels {
 720+						s.out <- NewMessage("NAMES", channel)
 721 					}
 722 				}
 723 			}
 724 		case "NAK":
 725-			for _, c := range strings.Split(msg.Params[2], " ") {
 726-				delete(s.enabledCaps, c)
 727-			}
 728+			// do nothing
 729 		case "NEW":
 730-			diff := ParseCaps(msg.Params[2])
 731-
 732-			for _, c := range diff {
 733-				if c.Enable {
 734-					s.availableCaps[c.Name] = c.Value
 735-				} else {
 736-					delete(s.availableCaps, c.Name)
 737-				}
 738-			}
 739-
 740-			var req strings.Builder
 741-
 742-			for _, c := range diff {
 743+			for _, c := range ParseCaps(msg.Params[2]) {
 744+				s.availableCaps[c.Name] = c.Value
 745 				_, ok := SupportedCapabilities[c.Name]
 746-				if !c.Enable || !ok {
 747+				if !ok {
 748 					continue
 749 				}
 750-
 751-				_, _ = fmt.Fprintf(&req, "CAP REQ %s\r\n", c.Name)
 752+				s.out <- NewMessage("CAP", "REQ", c.Name)
 753 			}
 754 
 755 			_, ok := s.availableCaps["sasl"]
 756 			if s.acct == "" && ok {
 757 				// TODO authenticate
 758 			}
 759-
 760-			err = s.send(req.String())
 761-			if err != nil {
 762-				return
 763-			}
 764 		case "DEL":
 765-			diff := ParseCaps(msg.Params[2])
 766-
 767-			for i := range diff {
 768-				diff[i].Enable = !diff[i].Enable
 769-			}
 770-
 771-			for _, c := range diff {
 772-				if c.Enable {
 773-					s.availableCaps[c.Name] = c.Value
 774-				} else {
 775-					delete(s.availableCaps, c.Name)
 776-				}
 777-			}
 778-
 779-			var req strings.Builder
 780-
 781-			for _, c := range diff {
 782-				_, ok := SupportedCapabilities[c.Name]
 783-				if !c.Enable || !ok {
 784-					continue
 785-				}
 786-
 787-				_, _ = fmt.Fprintf(&req, "CAP REQ %s\r\n", c.Name)
 788-			}
 789-
 790-			_, ok := s.availableCaps["sasl"]
 791-			if s.acct == "" && ok {
 792-				// TODO authenticate
 793-			}
 794-
 795-			err = s.send(req.String())
 796-			if err != nil {
 797-				return
 798+			for _, c := range ParseCaps(msg.Params[2]) {
 799+				delete(s.availableCaps, c.Name)
 800+				delete(s.enabledCaps, c.Name)
 801 			}
 802 		}
 803 	case "JOIN":
 804 		nickCf := s.Casemap(msg.Prefix.Name)
 805 		channelCf := s.Casemap(msg.Params[0])
 806-
 807-		if nickCf == s.nickCf {
 808+		if s.IsMe(msg.Prefix.Name) {
 809 			s.channels[channelCf] = Channel{
 810 				Name:    msg.Params[0],
 811 				Members: map[*User]string{},
 812@@ -840,61 +559,56 @@ func (s *Session) handle(msg Message) (err error) {
 813 				s.users[nickCf] = &User{Name: msg.Prefix.Copy()}
 814 			}
 815 			c.Members[s.users[nickCf]] = ""
 816-			t := msg.TimeOrNow()
 817-
 818-			s.evts <- UserJoinEvent{
 819-				User:    msg.Prefix.Copy(),
 820+			return UserJoinEvent{
 821+				User:    msg.Prefix.Name,
 822 				Channel: c.Name,
 823-				Time:    t,
 824 			}
 825 		}
 826 	case "PART":
 827 		nickCf := s.Casemap(msg.Prefix.Name)
 828 		channelCf := s.Casemap(msg.Params[0])
 829-
 830-		if nickCf == s.nickCf {
 831+		if s.IsMe(msg.Prefix.Name) {
 832 			if c, ok := s.channels[channelCf]; ok {
 833 				delete(s.channels, channelCf)
 834 				for u := range c.Members {
 835 					s.cleanUser(u)
 836 				}
 837-				s.evts <- SelfPartEvent{Channel: c.Name}
 838+				return SelfPartEvent{
 839+					Channel: c.Name,
 840+				}
 841 			}
 842 		} else if c, ok := s.channels[channelCf]; ok {
 843 			if u, ok := s.users[nickCf]; ok {
 844 				delete(c.Members, u)
 845 				s.cleanUser(u)
 846 				s.typings.Done(channelCf, nickCf)
 847-
 848-				s.evts <- UserPartEvent{
 849-					User:    msg.Prefix.Copy(),
 850+				return UserPartEvent{
 851+					User:    u.Name.Name,
 852 					Channel: c.Name,
 853-					Time:    msg.TimeOrNow(),
 854 				}
 855 			}
 856 		}
 857 	case "KICK":
 858-		channelCf := s.Casemap(msg.Params[0])
 859 		nickCf := s.Casemap(msg.Params[1])
 860-
 861-		if nickCf == s.nickCf {
 862+		channelCf := s.Casemap(msg.Params[0])
 863+		if s.IsMe(msg.Prefix.Name) {
 864 			if c, ok := s.channels[channelCf]; ok {
 865 				delete(s.channels, channelCf)
 866 				for u := range c.Members {
 867 					s.cleanUser(u)
 868 				}
 869-				s.evts <- SelfPartEvent{Channel: c.Name}
 870+				return SelfPartEvent{
 871+					Channel: c.Name,
 872+				}
 873 			}
 874 		} else if c, ok := s.channels[channelCf]; ok {
 875 			if u, ok := s.users[nickCf]; ok {
 876 				delete(c.Members, u)
 877 				s.cleanUser(u)
 878 				s.typings.Done(channelCf, nickCf)
 879-
 880-				s.evts <- UserPartEvent{
 881-					User:    u.Name.Copy(),
 882+				return UserPartEvent{
 883+					User:    u.Name.Name,
 884 					Channel: c.Name,
 885-					Time:    msg.TimeOrNow(),
 886 				}
 887 			}
 888 		}
 889@@ -911,11 +625,9 @@ func (s *Session) handle(msg Message) (err error) {
 890 					s.typings.Done(channelCf, nickCf)
 891 				}
 892 			}
 893-
 894-			s.evts <- UserQuitEvent{
 895-				User:     msg.Prefix.Copy(),
 896+			return UserQuitEvent{
 897+				User:     u.Name.Name,
 898 				Channels: channels,
 899-				Time:     msg.TimeOrNow(),
 900 			}
 901 		}
 902 	case rplNamreply:
 903@@ -924,8 +636,7 @@ func (s *Session) handle(msg Message) (err error) {
 904 		if c, ok := s.channels[channelCf]; ok {
 905 			c.Secret = msg.Params[1] == "@"
 906 
 907-			// TODO compute CHANTYPES
 908-			for _, name := range ParseNameReply(msg.Params[3], "~&@%+") {
 909+			for _, name := range ParseNameReply(msg.Params[3], s.prefixSymbols) {
 910 				nickCf := s.Casemap(name.Name.Name)
 911 
 912 				if _, ok := s.users[nickCf]; !ok {
 913@@ -941,7 +652,9 @@ func (s *Session) handle(msg Message) (err error) {
 914 		if c, ok := s.channels[channelCf]; ok && !c.complete {
 915 			c.complete = true
 916 			s.channels[channelCf] = c
 917-			s.evts <- SelfJoinEvent{Channel: c.Name}
 918+			return SelfJoinEvent{
 919+				Channel: c.Name,
 920+			}
 921 		}
 922 	case rplTopic:
 923 		channelCf := s.Casemap(msg.Params[1])
 924@@ -970,51 +683,34 @@ func (s *Session) handle(msg Message) (err error) {
 925 			c.TopicWho = msg.Prefix.Copy()
 926 			c.TopicTime = msg.TimeOrNow()
 927 			s.channels[channelCf] = c
 928-			s.evts <- TopicChangeEvent{
 929-				User:    msg.Prefix.Copy(),
 930+			return TopicChangeEvent{
 931 				Channel: c.Name,
 932 				Topic:   c.Topic,
 933-				Time:    c.TopicTime,
 934 			}
 935 		}
 936 	case "PRIVMSG", "NOTICE":
 937-		s.evts <- s.privmsgToEvent(msg)
 938+		targetCf := s.casemap(msg.Params[0])
 939+		nickCf := s.casemap(msg.Prefix.Name)
 940+		s.typings.Done(targetCf, nickCf)
 941+		return s.newMessageEvent(msg)
 942 	case "TAGMSG":
 943 		nickCf := s.Casemap(msg.Prefix.Name)
 944 		targetCf := s.Casemap(msg.Params[0])
 945 
 946-		if nickCf == s.nickCf {
 947+		if s.IsMe(msg.Prefix.Name) {
 948 			// TAGMSG from self
 949 			break
 950 		}
 951 
 952-		typing := TypingUnspec
 953 		if t, ok := msg.Tags["+typing"]; ok {
 954 			if t == "active" {
 955-				typing = TypingActive
 956 				s.typings.Active(targetCf, nickCf)
 957 			} else if t == "paused" {
 958-				typing = TypingPaused
 959-				s.typings.Active(targetCf, nickCf)
 960+				s.typings.Done(targetCf, nickCf)
 961 			} else if t == "done" {
 962-				typing = TypingDone
 963 				s.typings.Done(targetCf, nickCf)
 964 			}
 965-		} else {
 966-			break
 967-		}
 968-
 969-		ev := TagEvent{
 970-			User:   msg.Prefix.Copy(), // TODO correctly casemap
 971-			Target: msg.Params[0],     // TODO correctly casemap
 972-			Typing: typing,
 973-			Time:   msg.TimeOrNow(),
 974 		}
 975-		if c, ok := s.channels[targetCf]; ok {
 976-			ev.Target = c.Name
 977-			ev.TargetIsChannel = true
 978-		}
 979-		s.evts <- ev
 980 	case "BATCH":
 981 		batchStart := msg.Params[0][0] == '+'
 982 		id := msg.Params[0][1:]
 983@@ -1022,90 +718,74 @@ func (s *Session) handle(msg Message) (err error) {
 984 		if batchStart && msg.Params[1] == "chathistory" {
 985 			s.chBatches[id] = HistoryEvent{Target: msg.Params[2]}
 986 		} else if b, ok := s.chBatches[id]; ok {
 987-			s.evts <- b
 988 			delete(s.chBatches, id)
 989 			delete(s.chReqs, s.Casemap(b.Target))
 990+			return b
 991 		}
 992 	case "NICK":
 993 		nickCf := s.Casemap(msg.Prefix.Name)
 994 		newNick := msg.Params[0]
 995 		newNickCf := s.Casemap(newNick)
 996-		t := msg.TimeOrNow()
 997 
 998-		var u *Prefix
 999 		if formerUser, ok := s.users[nickCf]; ok {
1000 			formerUser.Name.Name = newNick
1001 			delete(s.users, nickCf)
1002 			s.users[newNickCf] = formerUser
1003-			u = formerUser.Name.Copy()
1004 		} else {
1005 			break
1006 		}
1007 
1008-		if nickCf == s.nickCf {
1009-			s.evts <- SelfNickEvent{
1010-				FormerNick: s.nick,
1011-				Time:       t,
1012-			}
1013+		if s.IsMe(msg.Prefix.Name) {
1014 			s.nick = newNick
1015 			s.nickCf = newNickCf
1016+			return SelfNickEvent{
1017+				FormerNick: msg.Prefix.Name,
1018+			}
1019 		} else {
1020-			s.evts <- UserNickEvent{
1021-				User:       u,
1022+			return UserNickEvent{
1023+				User:       msg.Params[0],
1024 				FormerNick: msg.Prefix.Name,
1025-				Time:       t,
1026 			}
1027 		}
1028+	case "PING":
1029+		s.out <- NewMessage("PONG", msg.Params[0])
1030+	case "ERROR":
1031+		s.Close()
1032 	case "FAIL":
1033-		s.evts <- ErrorEvent{
1034+		return ErrorEvent{
1035 			Severity: SeverityFail,
1036 			Code:     msg.Params[1],
1037-			Message:  msg.Params[len(msg.Params)-1],
1038+			Message:  strings.Join(msg.Params[2:], " "),
1039 		}
1040 	case "WARN":
1041-		s.evts <- ErrorEvent{
1042+		return ErrorEvent{
1043 			Severity: SeverityWarn,
1044 			Code:     msg.Params[1],
1045-			Message:  msg.Params[len(msg.Params)-1],
1046+			Message:  strings.Join(msg.Params[2:], " "),
1047 		}
1048 	case "NOTE":
1049-		s.evts <- ErrorEvent{
1050+		return ErrorEvent{
1051 			Severity: SeverityNote,
1052 			Code:     msg.Params[1],
1053-			Message:  msg.Params[len(msg.Params)-1],
1054-		}
1055-	case "PING":
1056-		err = s.send("PONG :%s\r\n", msg.Params[0])
1057-		if err != nil {
1058-			return
1059-		}
1060-	case "ERROR":
1061-		err = errors.New("connection terminated")
1062-		if len(msg.Params) > 0 {
1063-			err = fmt.Errorf("connection terminated: %s", msg.Params[0])
1064+			Message:  strings.Join(msg.Params[2:], " "),
1065 		}
1066-		_ = s.conn.Close()
1067 	default:
1068-		// reply handling
1069-		if ReplySeverity(msg.Command) == SeverityFail {
1070-			s.evts <- ErrorEvent{
1071-				Severity: SeverityFail,
1072+		if msg.IsReply() {
1073+			return ErrorEvent{
1074+				Severity: ReplySeverity(msg.Command),
1075 				Code:     msg.Command,
1076-				Message:  msg.Params[len(msg.Params)-1],
1077+				Message:  strings.Join(msg.Params[1:], " "),
1078 			}
1079 		}
1080 	}
1081-
1082-	return
1083+	return nil
1084 }
1085 
1086-func (s *Session) privmsgToEvent(msg Message) (ev MessageEvent) {
1087+func (s *Session) newMessageEvent(msg Message) MessageEvent {
1088 	targetCf := s.Casemap(msg.Params[0])
1089-
1090-	s.typings.Done(targetCf, s.Casemap(msg.Prefix.Name))
1091-	ev = MessageEvent{
1092-		User:    msg.Prefix.Copy(), // TODO correctly casemap
1093-		Target:  msg.Params[0],     // TODO correctly casemap
1094+	ev := MessageEvent{
1095+		User:    msg.Prefix.Name, // TODO correctly casemap
1096+		Target:  msg.Params[0],   // TODO correctly casemap
1097 		Command: msg.Command,
1098 		Content: msg.Params[1],
1099 		Time:    msg.TimeOrNow(),
1100@@ -1114,8 +794,7 @@ func (s *Session) privmsgToEvent(msg Message) (ev MessageEvent) {
1101 		ev.Target = c.Name
1102 		ev.TargetIsChannel = true
1103 	}
1104-
1105-	return
1106+	return ev
1107 }
1108 
1109 func (s *Session) cleanUser(parted *User) {
1110@@ -1157,6 +836,7 @@ func (s *Session) updateFeatures(features []string) {
1111 			continue
1112 		}
1113 
1114+	Switch:
1115 		switch key {
1116 		case "CASEMAPPING":
1117 			switch value {
1118@@ -1167,31 +847,32 @@ func (s *Session) updateFeatures(features []string) {
1119 			}
1120 		case "CHANTYPES":
1121 			s.chantypes = value
1122+		case "CHATHISTORY":
1123+			historyLimit, err := strconv.Atoi(value)
1124+			if err == nil {
1125+				s.historyLimit = historyLimit
1126+			}
1127 		case "LINELEN":
1128 			linelen, err := strconv.Atoi(value)
1129 			if err == nil && linelen != 0 {
1130 				s.linelen = linelen
1131 			}
1132-		}
1133-	}
1134-}
1135-
1136-func (s *Session) send(format string, args ...interface{}) (err error) {
1137-	msg := fmt.Sprintf(format, args...)
1138-
1139-	s.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
1140-	_, err = s.conn.Write([]byte(msg))
1141-
1142-	if s.debug {
1143-		for _, line := range strings.Split(msg, "\r\n") {
1144-			if line != "" {
1145-				s.evts <- RawMessageEvent{
1146-					Message:  line,
1147-					Outgoing: true,
1148+		case "PREFIX":
1149+			if value == "" {
1150+				s.prefixModes = ""
1151+				s.prefixSymbols = ""
1152+			}
1153+			if len(value)%2 != 0 {
1154+				break Switch
1155+			}
1156+			for i := 0; i < len(value); i++ {
1157+				if unicode.MaxASCII < value[i] {
1158+					break Switch
1159 				}
1160 			}
1161+			numPrefixes := len(value)/2 - 1
1162+			s.prefixModes = value[1 : numPrefixes+1]
1163+			s.prefixSymbols = value[numPrefixes+2:]
1164 		}
1165 	}
1166-
1167-	return
1168 }
+21, -3
 1@@ -229,6 +229,10 @@ type Message struct {
 2 	Params  []string
 3 }
 4 
 5+func NewMessage(command string, params ...string) Message {
 6+	return Message{Command: command, Params: params}
 7+}
 8+
 9 // ParseMessage parses the message from the given string, which must be trimmed
10 // of "\r\n" beforehand.
11 func ParseMessage(line string) (msg Message, err error) {
12@@ -282,6 +286,14 @@ func ParseMessage(line string) (msg Message, err error) {
13 	return
14 }
15 
16+func (msg Message) WithTag(key, value string) Message {
17+	if msg.Tags == nil {
18+		msg.Tags = map[string]string{}
19+	}
20+	msg.Tags[key] = escapeTagValue(value)
21+	return msg
22+}
23+
24 // IsReply reports whether the message command is a server reply.
25 func (msg *Message) IsReply() bool {
26 	if len(msg.Command) != 3 {
27@@ -326,9 +338,15 @@ func (msg *Message) String() string {
28 			sb.WriteRune(' ')
29 			sb.WriteString(p)
30 		}
31-		sb.WriteRune(' ')
32-		sb.WriteRune(':')
33-		sb.WriteString(msg.Params[len(msg.Params)-1])
34+		lastParam := msg.Params[len(msg.Params)-1]
35+		if !strings.ContainsRune(lastParam, ' ') && !strings.HasPrefix(lastParam, ":") {
36+			sb.WriteRune(' ')
37+			sb.WriteString(lastParam)
38+		} else {
39+			sb.WriteRune(' ')
40+			sb.WriteRune(':')
41+			sb.WriteString(lastParam)
42+		}
43 	}
44 
45 	return sb.String()
+13, -0
 1@@ -73,3 +73,16 @@ func (ts *Typings) Done(target, name string) {
 2 	delete(ts.targets, Typing{target, name})
 3 	ts.l.Unlock()
 4 }
 5+
 6+func (ts *Typings) List(target string) []string {
 7+	ts.l.Lock()
 8+	defer ts.l.Unlock()
 9+
10+	var res []string
11+	for t := range ts.targets {
12+		if target == t.Target {
13+			res = append(res, t.Name)
14+		}
15+	}
16+	return res
17+}
+15, -8
 1@@ -22,28 +22,35 @@ var homeMessages = []string{
 2 func (app *App) initWindow() {
 3 	hmIdx := rand.Intn(len(homeMessages))
 4 	app.win.AddBuffer(Home)
 5-	app.addLineNow("", ui.Line{
 6+	app.win.AddLine(Home, false, ui.Line{
 7 		Head: "--",
 8 		Body: homeMessages[hmIdx],
 9+		At:   time.Now(),
10 	})
11 }
12 
13-func (app *App) addLineNow(buffer string, line ui.Line) {
14+func (app *App) queueStatusLine(line ui.Line) {
15 	if line.At.IsZero() {
16 		line.At = time.Now()
17 	}
18-	app.win.AddLine(buffer, false, line)
19-	app.draw()
20+	app.events <- event{
21+		src:     uiEvent,
22+		content: line,
23+	}
24 }
25 
26-func (app *App) draw() {
27-	if app.s != nil {
28-		app.setStatus()
29+func (app *App) addStatusLine(line ui.Line) {
30+	buffer := app.win.CurrentBuffer()
31+	if buffer != Home {
32+		app.win.AddLine(Home, false, line)
33 	}
34-	app.win.Draw()
35+	app.win.AddLine(buffer, false, line)
36 }
37 
38 func (app *App) setStatus() {
39+	if app.s == nil {
40+		return
41+	}
42 	ts := app.s.Typings(app.win.CurrentBuffer())
43 	status := ""
44 	if 3 < len(ts) {