summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHubert Hirtz <hubert@hirtz.pm>2021-05-18 21:26:03 +0200
committerHubert Hirtz <hubert@hirtz.pm>2021-05-20 22:45:48 +0200
commit7835d39dda5f7a1dde3056de3dd8a84913b94c14 (patch)
treecb989997170121baee7d227b7e909a624c2bdbf2
parentAdd /mode command (diff)
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.
-rw-r--r--app.go431
-rw-r--r--cmd/test/main.go37
-rw-r--r--commands.go10
-rw-r--r--irc/channel.go40
-rw-r--r--irc/events.go35
-rw-r--r--irc/session.go (renamed from irc/states.go)769
-rw-r--r--irc/tokens.go24
-rw-r--r--irc/typing.go13
-rw-r--r--window.go23
9 files changed, 583 insertions, 799 deletions
diff --git a/app.go b/app.go
index 8e8231f..ef93e51 100644
--- a/app.go
+++ b/app.go
@@ -79,7 +79,7 @@ func NewApp(cfg Config) (app *App, err error) {
func (app *App) Close() {
app.win.Close()
if app.s != nil {
- app.s.Stop()
+ app.s.Close()
}
}
@@ -109,21 +109,8 @@ func (app *App) eventLoop() {
app.handleEvents(evs)
if !app.pasting {
- app.draw()
- }
- }
-}
-
-// handleEvents handles a batch of events.
-func (app *App) handleEvents(evs []event) {
- for _, ev := range evs {
- switch ev.src {
- case uiEvent:
- app.handleUIEvent(ev.content.(tcell.Event))
- case ircEvent:
- app.handleIRCEvent(ev.content.(irc.Event))
- default:
- panic("unreachable")
+ app.setStatus()
+ app.win.Draw()
}
}
}
@@ -131,15 +118,48 @@ func (app *App) handleEvents(evs []event) {
// ircLoop maintains a connection to the IRC server by connecting and then
// forwarding IRC events to app.events repeatedly.
func (app *App) ircLoop() {
+ var auth irc.SASLClient
+ if app.cfg.Password != nil {
+ auth = &irc.SASLPlain{
+ Username: app.cfg.User,
+ Password: *app.cfg.Password,
+ }
+ }
+ params := irc.SessionParams{
+ Nickname: app.cfg.Nick,
+ Username: app.cfg.User,
+ RealName: app.cfg.Real,
+ Auth: auth,
+ }
for !app.win.ShouldExit() {
- app.connect()
- for ev := range app.s.Poll() {
+ conn := app.connect()
+ in, out := irc.ChanInOut(conn)
+ if app.cfg.Debug {
+ out = app.debugOutputMessages(out)
+ }
+ session := irc.NewSession(out, params)
+ app.events <- event{
+ src: ircEvent,
+ content: session,
+ }
+ for msg := range in {
+ if app.cfg.Debug {
+ app.queueStatusLine(ui.Line{
+ At: time.Now(),
+ Head: "IN --",
+ Body: msg.String(),
+ })
+ }
app.events <- event{
src: ircEvent,
- content: ev,
+ content: msg,
}
}
- app.addLineNow(Home, ui.Line{
+ app.events <- event{
+ src: ircEvent,
+ content: nil,
+ }
+ app.queueStatusLine(ui.Line{
Head: "!!",
HeadColor: ui.ColorRed,
Body: "Connection lost",
@@ -147,17 +167,17 @@ func (app *App) ircLoop() {
}
}
-func (app *App) connect() {
+func (app *App) connect() net.Conn {
for {
- app.addLineNow(Home, ui.Line{
+ app.queueStatusLine(ui.Line{
Head: "--",
Body: fmt.Sprintf("Connecting to %s...", app.cfg.Addr),
})
- err := app.tryConnect()
+ conn, err := app.tryConnect()
if err == nil {
- break
+ return conn
}
- app.addLineNow(Home, ui.Line{
+ app.queueStatusLine(ui.Line{
Head: "!!",
HeadColor: ui.ColorRed,
Body: fmt.Sprintf("Connection failed: %v", err),
@@ -166,7 +186,7 @@ func (app *App) connect() {
}
}
-func (app *App) tryConnect() (err error) {
+func (app *App) tryConnect() (conn net.Conn, err error) {
addr := app.cfg.Addr
colonIdx := strings.LastIndexByte(addr, ':')
bracketIdx := strings.LastIndexByte(addr, ']')
@@ -180,7 +200,7 @@ func (app *App) tryConnect() (err error) {
}
}
- conn, err := net.Dial("tcp", addr)
+ conn, err = net.Dial("tcp", addr)
if err != nil {
return
}
@@ -193,28 +213,24 @@ func (app *App) tryConnect() (err error) {
})
}
- var auth irc.SASLClient
- if app.cfg.Password != nil {
- auth = &irc.SASLPlain{
- Username: app.cfg.User,
- Password: *app.cfg.Password,
- }
- }
- app.s, err = irc.NewSession(conn, irc.SessionParams{
- Nickname: app.cfg.Nick,
- Username: app.cfg.User,
- RealName: app.cfg.Real,
- Auth: auth,
- Debug: app.cfg.Debug,
- })
- if err != nil {
- conn.Close()
- return
- }
-
return
}
+func (app *App) debugOutputMessages(out chan<- irc.Message) chan<- irc.Message {
+ debugOut := make(chan irc.Message, cap(out))
+ go func() {
+ for msg := range debugOut {
+ app.queueStatusLine(ui.Line{
+ At: time.Now(),
+ Head: "OUT --",
+ Body: msg.String(),
+ })
+ out <- msg
+ }
+ }()
+ return debugOut
+}
+
// uiLoop retrieves events from the UI and forwards them to app.events for
// handling in app.eventLoop().
func (app *App) uiLoop() {
@@ -230,118 +246,34 @@ func (app *App) uiLoop() {
}
}
-func (app *App) handleIRCEvent(ev irc.Event) {
- switch ev := ev.(type) {
- case irc.RawMessageEvent:
- head := "IN --"
- if ev.Outgoing {
- head = "OUT --"
- } else if !ev.IsValid {
- head = "IN ??"
- }
- app.win.AddLine(Home, false, ui.Line{
- At: time.Now(),
- Head: head,
- Body: ev.Message,
- })
- case irc.ErrorEvent:
- var severity string
- switch ev.Severity {
- case irc.SeverityNote:
- severity = "Note"
- case irc.SeverityWarn:
- severity = "Warning"
- case irc.SeverityFail:
- severity = "Error"
- }
- app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
- At: time.Now(),
- Head: "!!",
- HeadColor: ui.ColorRed,
- Body: fmt.Sprintf("%s (code %s): %s", severity, ev.Code, ev.Message),
- })
- case irc.RegisteredEvent:
- body := "Connected to the server"
- if app.s.Nick() != app.cfg.Nick {
- body += " as " + app.s.Nick()
- }
- app.win.AddLine(Home, false, ui.Line{
- At: time.Now(),
- Head: "--",
- Body: body,
- })
- case irc.SelfNickEvent:
- app.win.AddLine(app.win.CurrentBuffer(), true, ui.Line{
- At: ev.Time,
- Head: "--",
- Body: fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, app.s.Nick()),
- Highlight: true,
- })
- case irc.UserNickEvent:
- for _, c := range app.s.ChannelsSharedWith(ev.User.Name) {
- app.win.AddLine(c, false, ui.Line{
- At: ev.Time,
- Head: "--",
- Body: fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.User.Name),
- Mergeable: true,
- })
- }
- case irc.SelfJoinEvent:
- app.win.AddBuffer(ev.Channel)
- app.s.RequestHistory(ev.Channel, time.Now())
- case irc.UserJoinEvent:
- app.win.AddLine(ev.Channel, false, ui.Line{
- At: time.Now(),
- Head: "--",
- Body: fmt.Sprintf("\x033+\x0314%s\x03", ev.User.Name),
- Mergeable: true,
- })
- case irc.SelfPartEvent:
- app.win.RemoveBuffer(ev.Channel)
- case irc.UserPartEvent:
- app.win.AddLine(ev.Channel, false, ui.Line{
- At: ev.Time,
- Head: "--",
- Body: fmt.Sprintf("\x034-\x0314%s\x03", ev.User.Name),
- Mergeable: true,
- })
- case irc.UserQuitEvent:
- for _, c := range ev.Channels {
- app.win.AddLine(c, false, ui.Line{
- At: ev.Time,
- Head: "--",
- Body: fmt.Sprintf("\x034-\x0314%s\x03", ev.User.Name),
- Mergeable: true,
- })
- }
- case irc.TopicChangeEvent:
- app.win.AddLine(ev.Channel, false, ui.Line{
- At: ev.Time,
- Head: "--",
- Body: fmt.Sprintf("\x0314Topic changed to: %s\x03", ev.Topic),
- })
- case irc.MessageEvent:
- buffer, line, hlNotification := app.formatMessage(ev)
- app.win.AddLine(buffer, hlNotification, line)
- if hlNotification {
- app.notifyHighlight(buffer, ev.User.Name, ev.Content)
- }
- if !ev.TargetIsChannel && app.s.NickCf() != app.s.Casemap(ev.User.Name) {
- app.lastQuery = ev.User.Name
- }
- case irc.HistoryEvent:
- var lines []ui.Line
- for _, m := range ev.Messages {
- switch m := m.(type) {
- case irc.MessageEvent:
- _, line, _ := app.formatMessage(m)
- lines = append(lines, line)
- default:
- }
+// handleEvents handles a batch of events.
+func (app *App) handleEvents(evs []event) {
+ for _, ev := range evs {
+ switch ev.src {
+ case uiEvent:
+ app.handleUIEvent(ev.content)
+ case ircEvent:
+ app.handleIRCEvent(ev.content)
+ default:
+ panic("unreachable")
}
- app.win.AddLines(ev.Target, lines)
- case error:
- panic(ev)
+ }
+}
+
+func (app *App) handleUIEvent(ev interface{}) {
+ switch ev := ev.(type) {
+ case *tcell.EventResize:
+ app.win.Resize()
+ case *tcell.EventPaste:
+ app.pasting = ev.Start()
+ case *tcell.EventMouse:
+ app.handleMouseEvent(ev)
+ case *tcell.EventKey:
+ app.handleKeyEvent(ev)
+ case ui.Line:
+ app.addStatusLine(ev)
+ default:
+ return
}
}
@@ -484,24 +416,6 @@ func (app *App) handleKeyEvent(ev *tcell.EventKey) {
}
}
-func (app *App) handleUIEvent(ev tcell.Event) {
- switch ev := ev.(type) {
- case *tcell.EventResize:
- app.win.Resize()
- case *tcell.EventPaste:
- app.pasting = ev.Start()
- case *tcell.EventMouse:
- app.handleMouseEvent(ev)
- case *tcell.EventKey:
- app.handleKeyEvent(ev)
- default:
- return
- }
- if !app.pasting {
- app.draw()
- }
-}
-
// requestHistory is a wrapper around irc.Session.RequestHistory to only request
// history when needed.
func (app *App) requestHistory() {
@@ -510,22 +424,159 @@ func (app *App) requestHistory() {
}
buffer := app.win.CurrentBuffer()
if app.win.IsAtTop() && buffer != Home {
- at := time.Now()
- if t := app.win.CurrentBufferOldestTime(); t != nil {
- at = *t
+ t := time.Now()
+ if oldest := app.win.CurrentBufferOldestTime(); oldest != nil {
+ t = *oldest
}
- app.s.RequestHistory(buffer, at)
+ app.s.NewHistoryRequest(buffer).
+ WithLimit(100).
+ Before(t)
}
}
+func (app *App) handleIRCEvent(ev interface{}) {
+ if ev == nil {
+ app.s.Close()
+ app.s = nil
+ return
+ }
+ if s, ok := ev.(*irc.Session); ok {
+ app.s = s
+ return
+ }
+
+ msg := ev.(irc.Message)
+
+ // Mutate IRC state
+ ev = app.s.HandleMessage(msg)
+
+ // Mutate UI state
+ switch ev := ev.(type) {
+ case irc.RegisteredEvent:
+ body := "Connected to the server"
+ if app.s.Nick() != app.cfg.Nick {
+ body += " as " + app.s.Nick()
+ }
+ app.win.AddLine(Home, false, ui.Line{
+ At: msg.TimeOrNow(),
+ Head: "--",
+ Body: body,
+ })
+ case irc.SelfNickEvent:
+ app.win.AddLine(app.win.CurrentBuffer(), true, ui.Line{
+ At: msg.TimeOrNow(),
+ Head: "--",
+ Body: fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, app.s.Nick()),
+ Highlight: true,
+ })
+ case irc.UserNickEvent:
+ for _, c := range app.s.ChannelsSharedWith(ev.User) {
+ app.win.AddLine(c, false, ui.Line{
+ At: msg.TimeOrNow(),
+ Head: "--",
+ Body: fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.User),
+ Mergeable: true,
+ })
+ }
+ case irc.SelfJoinEvent:
+ app.win.AddBuffer(ev.Channel)
+ app.s.NewHistoryRequest(ev.Channel).
+ WithLimit(200).
+ Before(msg.TimeOrNow())
+ case irc.UserJoinEvent:
+ app.win.AddLine(ev.Channel, false, ui.Line{
+ At: msg.TimeOrNow(),
+ Head: "--",
+ Body: fmt.Sprintf("\x033+\x0314%s\x03", ev.User),
+ Mergeable: true,
+ })
+ case irc.SelfPartEvent:
+ app.win.RemoveBuffer(ev.Channel)
+ case irc.UserPartEvent:
+ app.win.AddLine(ev.Channel, false, ui.Line{
+ At: msg.TimeOrNow(),
+ Head: "--",
+ Body: fmt.Sprintf("\x034-\x0314%s\x03", ev.User),
+ Mergeable: true,
+ })
+ case irc.UserQuitEvent:
+ for _, c := range ev.Channels {
+ app.win.AddLine(c, false, ui.Line{
+ At: msg.TimeOrNow(),
+ Head: "--",
+ Body: fmt.Sprintf("\x034-\x0314%s\x03", ev.User),
+ Mergeable: true,
+ })
+ }
+ case irc.TopicChangeEvent:
+ app.win.AddLine(ev.Channel, false, ui.Line{
+ At: msg.TimeOrNow(),
+ Head: "--",
+ Body: fmt.Sprintf("\x0314Topic changed to: %s\x03", ev.Topic),
+ })
+ case irc.MessageEvent:
+ buffer, line, hlNotification := app.formatMessage(ev)
+ app.win.AddLine(buffer, hlNotification, line)
+ if hlNotification {
+ app.notifyHighlight(buffer, ev.User, ev.Content)
+ }
+ if !app.s.IsChannel(msg.Params[0]) && !app.s.IsMe(ev.User) {
+ app.lastQuery = msg.Prefix.Name
+ }
+ case irc.HistoryEvent:
+ var lines []ui.Line
+ for _, m := range ev.Messages {
+ switch ev := m.(type) {
+ case irc.MessageEvent:
+ _, line, _ := app.formatMessage(ev)
+ lines = append(lines, line)
+ }
+ }
+ app.win.AddLines(ev.Target, lines)
+ case irc.ErrorEvent:
+ if isBlackListed(msg.Command) {
+ break
+ }
+ var head string
+ var body string
+ switch ev.Severity {
+ case irc.SeverityFail:
+ head = "--"
+ body = fmt.Sprintf("Error (code %s): %s", ev.Code, ev.Message)
+ case irc.SeverityWarn:
+ head = "--"
+ body = fmt.Sprintf("Warning (code %s): %s", ev.Code, ev.Message)
+ case irc.SeverityNote:
+ head = ev.Code + " --"
+ body = ev.Message
+ default:
+ panic("unreachable")
+ }
+ app.addStatusLine(ui.Line{
+ At: msg.TimeOrNow(),
+ Head: head,
+ Body: body,
+ })
+ }
+}
+
+func isBlackListed(command string) bool {
+ switch command {
+ case "002", "003", "004", "422":
+ // useless connection messages
+ return true
+ }
+ return false
+}
+
// isHighlight reports whether the given message content is a highlight.
func (app *App) isHighlight(content string) bool {
- contentCf := strings.ToLower(content)
+ contentCf := app.s.Casemap(content)
if app.highlights == nil {
return strings.Contains(contentCf, app.s.NickCf())
}
for _, h := range app.highlights {
- if strings.Contains(contentCf, h) {
+ if strings.Contains(contentCf, app.s.Casemap(h)) {
return true
}
}
@@ -556,7 +607,7 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
output, err := cmd.CombinedOutput()
if err != nil {
body := fmt.Sprintf("Failed to invoke on-highlight command: %v. Output: %q", err, string(output))
- app.win.AddLine(Home, false, ui.Line{
+ app.addStatusLine(ui.Line{
At: time.Now(),
Head: "!!",
HeadColor: ui.ColorRed,
@@ -615,7 +666,7 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
// - the UI line,
// - whether senpai must trigger the "on-highlight" command.
func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line, hlNotification bool) {
- isFromSelf := app.s.NickCf() == app.s.Casemap(ev.User.Name)
+ isFromSelf := app.s.IsMe(ev.User)
isHighlight := app.isHighlight(ev.Content)
isAction := strings.HasPrefix(ev.Content, "\x01ACTION")
isQuery := !ev.TargetIsChannel && ev.Command == "PRIVMSG"
@@ -632,7 +683,7 @@ func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line,
hlLine := ev.TargetIsChannel && isHighlight && !isFromSelf
hlNotification = (isHighlight || isQuery) && !isFromSelf
- head := ev.User.Name
+ head := ev.User
headColor := ui.ColorWhite
if isFromSelf && isQuery {
head = "\u2192 " + ev.Target
@@ -645,14 +696,14 @@ func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line,
body := strings.TrimSuffix(ev.Content, "\x01")
if isNotice && isAction {
- c := ircColorSequence(ui.IdentColor(ev.User.Name))
- body = fmt.Sprintf("(%s%s\x0F:%s)", c, ev.User.Name, body[7:])
+ c := ircColorSequence(ui.IdentColor(ev.User))
+ body = fmt.Sprintf("(%s%s\x0F:%s)", c, ev.User, body[7:])
} else if isAction {
- c := ircColorSequence(ui.IdentColor(ev.User.Name))
- body = fmt.Sprintf("%s%s\x0F%s", c, ev.User.Name, body[7:])
+ c := ircColorSequence(ui.IdentColor(ev.User))
+ body = fmt.Sprintf("%s%s\x0F%s", c, ev.User, body[7:])
} else if isNotice {
- c := ircColorSequence(ui.IdentColor(ev.User.Name))
- body = fmt.Sprintf("(%s%s\x0F: %s)", c, ev.User.Name, body)
+ c := ircColorSequence(ui.IdentColor(ev.User))
+ body = fmt.Sprintf("(%s%s\x0F: %s)", c, ev.User, body)
}
line = ui.Line{
diff --git a/cmd/test/main.go b/cmd/test/main.go
index 6ed7f4e..804937f 100644
--- a/cmd/test/main.go
+++ b/cmd/test/main.go
@@ -56,17 +56,24 @@ func main() {
if password != "" {
auth = &irc.SASLPlain{Username: nick, Password: password}
}
- cli, err := irc.NewSession(conn, irc.SessionParams{
+
+ in, out := irc.ChanInOut(conn)
+ debugOut := make(chan irc.Message, 64)
+ go func() {
+ for msg := range debugOut {
+ fmt.Fprintf(t, "C > S: %s\n", msg.String())
+ out <- msg
+ }
+ close(out)
+ }()
+
+ cli := irc.NewSession(debugOut, irc.SessionParams{
Nickname: nick,
Username: nick,
RealName: nick,
Auth: auth,
- Debug: true,
})
- if err != nil {
- panic(fmt.Sprintf("Failed to connect to %s: %v", address, err))
- }
- defer cli.Stop()
+ defer cli.Close()
go func() {
for {
@@ -76,22 +83,12 @@ func main() {
}
cli.SendRaw(line)
}
- cli.Stop()
+ cli.Close()
}()
- for ev := range cli.Poll() {
- switch ev := ev.(type) {
- case irc.RawMessageEvent:
- if ev.Outgoing {
- fmt.Fprintf(t, "C > S: %s\n", ev.Message)
- } else {
- fmt.Fprintf(t, "C < S: %s\n", ev.Message)
- }
- case error:
- panic(ev)
- default:
- fmt.Fprintf(t, "=EVENT: %T%+v\n", ev, ev)
- }
+ for msg := range in {
+ cli.HandleMessage(msg)
+ fmt.Fprintf(t, "C < S: %s\n", msg.String())
}
t.SetPrompt("")
fmt.Fprintln(t, "Disconnected")
diff --git a/commands.go b/commands.go
index ee4274c..aab9b92 100644
--- a/commands.go
+++ b/commands.go
@@ -131,7 +131,7 @@ func commandDo(app *App, buffer string, args []string) (err error) {
app.s.PrivMsg(buffer, args[0])
if !app.s.HasCapability("echo-message") {
buffer, line, _ := app.formatMessage(irc.MessageEvent{
- User: &irc.Prefix{Name: app.s.Nick()},
+ User: app.s.Nick(),
Target: buffer,
TargetIsChannel: true,
Command: "PRIVMSG",
@@ -215,7 +215,7 @@ func commandDoMe(app *App, buffer string, args []string) (err error) {
app.s.PrivMsg(buffer, content)
if !app.s.HasCapability("echo-message") {
buffer, line, _ := app.formatMessage(irc.MessageEvent{
- User: &irc.Prefix{Name: app.s.Nick()},
+ User: app.s.Nick(),
Target: buffer,
TargetIsChannel: true,
Command: "PRIVMSG",
@@ -233,7 +233,7 @@ func commandDoMsg(app *App, buffer string, args []string) (err error) {
app.s.PrivMsg(target, content)
if !app.s.HasCapability("echo-message") {
buffer, line, _ := app.formatMessage(irc.MessageEvent{
- User: &irc.Prefix{Name: app.s.Nick()},
+ User: app.s.Nick(),
Target: target,
TargetIsChannel: true,
Command: "PRIVMSG",
@@ -327,7 +327,7 @@ func commandDoR(app *App, buffer string, args []string) (err error) {
app.s.PrivMsg(app.lastQuery, args[0])
if !app.s.HasCapability("echo-message") {
buffer, line, _ := app.formatMessage(irc.MessageEvent{
- User: &irc.Prefix{Name: app.s.Nick()},
+ User: app.s.Nick(),
Target: app.lastQuery,
TargetIsChannel: true,
Command: "PRIVMSG",
@@ -355,7 +355,7 @@ func commandDoTopic(app *App, buffer string, args []string) (err error) {
Body: body,
})
} else {
- app.s.SetTopic(buffer, args[0])
+ app.s.ChangeTopic(buffer, args[0])
}
return
}
diff --git a/irc/channel.go b/irc/channel.go
new file mode 100644
index 0000000..c4a9e6f
--- /dev/null
+++ b/irc/channel.go
@@ -0,0 +1,40 @@
+package irc
+
+import (
+ "bufio"
+ "fmt"
+ "net"
+)
+
+const chanCapacity = 64
+
+func ChanInOut(conn net.Conn) (in <-chan Message, out chan<- Message) {
+ in_ := make(chan Message, chanCapacity)
+ out_ := make(chan Message, chanCapacity)
+
+ go func() {
+ r := bufio.NewScanner(conn)
+ for r.Scan() {
+ line := r.Text()
+ msg, err := ParseMessage(line)
+ if err != nil {
+ continue
+ }
+ in_ <- msg
+ }
+ close(in_)
+ }()
+
+ go func() {
+ for msg := range out_ {
+ // TODO send messages by batches
+ _, err := fmt.Fprintf(conn, "%s\r\n", msg.String())
+ if err != nil {
+ break
+ }
+ }
+ _ = conn.Close()
+ }()
+
+ return in_, out_
+}
diff --git a/irc/events.go b/irc/events.go
index a241232..bdd6914 100644
--- a/irc/events.go
+++ b/irc/events.go
@@ -1,17 +1,9 @@
package irc
-import (
- "time"
-)
+import "time"
type Event interface{}
-type RawMessageEvent struct {
- Message string
- Outgoing bool
- IsValid bool
-}
-
type ErrorEvent struct {
Severity Severity
Code string
@@ -22,13 +14,11 @@ type RegisteredEvent struct{}
type SelfNickEvent struct {
FormerNick string
- Time time.Time
}
type UserNickEvent struct {
- User *Prefix
+ User string
FormerNick string
- Time time.Time
}
type SelfJoinEvent struct {
@@ -36,9 +26,8 @@ type SelfJoinEvent struct {
}
type UserJoinEvent struct {
- User *Prefix
+ User string
Channel string
- Time time.Time
}
type SelfPartEvent struct {
@@ -46,26 +35,22 @@ type SelfPartEvent struct {
}
type UserPartEvent struct {
- User *Prefix
+ User string
Channel string
- Time time.Time
}
type UserQuitEvent struct {
- User *Prefix
+ User string
Channels []string
- Time time.Time
}
type TopicChangeEvent struct {
- User *Prefix
Channel string
Topic string
- Time time.Time
}
type MessageEvent struct {
- User *Prefix
+ User string
Target string
TargetIsChannel bool
Command string
@@ -73,14 +58,6 @@ type MessageEvent struct {
Time time.Time
}
-type TagEvent struct {
- User *Prefix
- Target string
- TargetIsChannel bool
- Typing int
- Time time.Time
-}
-
type HistoryEvent struct {
Target string
Messages []Event
diff --git a/irc/states.go b/irc/session.go
index b2a129a..69d29aa 100644
--- a/irc/states.go
+++ b/irc/session.go
@@ -1,21 +1,17 @@
package irc
import (
- "bufio"
"bytes"
"encoding/base64"
"errors"
"fmt"
- "net"
"strconv"
"strings"
- "sync/atomic"
"time"
+ "unicode"
"unicode/utf8"
)
-const writeDeadline = 10 * time.Second
-
type SASLClient interface {
Handshake() (mech string)
Respond(challenge string) (res string, err error)
@@ -74,62 +70,6 @@ const (
TypingDone
)
-// action contains the arguments of a user action.
-//
-// To keep connection reads and writes in a single coroutine, the library
-// interface functions like Join("#channel") or PrivMsg("target", "message")
-// don't interact with the IRC session directly. Instead, they push an action
-// in the action channel. This action is then processed by the correct
-// coroutine.
-type action interface{}
-
-type (
- actionSendRaw struct {
- raw string
- }
-
- actionChangeNick struct {
- Nick string
- }
- actionChangeMode struct {
- Channel string
- Flags string
- Args []string
- }
-
- actionJoin struct {
- Channel string
- }
- actionPart struct {
- Channel string
- Reason string
- }
- actionSetTopic struct {
- Channel string
- Topic string
- }
- actionQuit struct {
- Reason string
- }
-
- actionPrivMsg struct {
- Target string
- Content string
- }
-
- actionTyping struct {
- Channel string
- }
- actionTypingStop struct {
- Channel string
- }
-
- actionRequestHistory struct {
- Target string
- Before time.Time
- }
-)
-
// User is a known IRC user (we share a channel with it).
type User struct {
Name *Prefix // the nick, user and hostname of the user if known.
@@ -155,20 +95,11 @@ type SessionParams struct {
RealName string
Auth SASLClient
-
- Debug bool // whether the Session should report all messages it sends and receive.
}
-// Session is an IRC session/connection/whatever.
type Session struct {
- conn net.Conn
- msgs chan Message // incoming messages.
- acts chan action // user actions.
- evts chan Event // events sent to the user.
-
- debug bool
-
- running atomic.Value // bool
+ out chan<- Message
+ closed bool
registered bool
typings *Typings // incoming typing notifications.
typingStamps map[string]time.Time // user typing instants.
@@ -185,9 +116,12 @@ type Session struct {
enabledCaps map[string]struct{}
// ISUPPORT features
- casemap func(string) string
- chantypes string
- linelen int
+ casemap func(string) string
+ chantypes string
+ linelen int
+ historyLimit int
+ prefixSymbols string
+ prefixModes string
users map[string]*User // known users.
channels map[string]Channel // joined channels.
@@ -195,18 +129,9 @@ type Session struct {
chReqs map[string]struct{} // set of targets for which history is currently requested.
}
-// NewSession starts an IRC session from the given connection and session
-// parameters.
-//
-// It returns an error when the paramaters are invalid, or when it cannot write
-// to the connection.
-func NewSession(conn net.Conn, params SessionParams) (*Session, error) {
+func NewSession(out chan<- Message, params SessionParams) *Session {
s := &Session{
- conn: conn,
- msgs: make(chan Message, 64),
- acts: make(chan action, 64),
- evts: make(chan Event, 64),
- debug: params.Debug,
+ out: out,
typings: NewTypings(),
typingStamps: map[string]time.Time{},
nick: params.Nickname,
@@ -219,66 +144,28 @@ func NewSession(conn net.Conn, params SessionParams) (*Session, error) {
casemap: CasemapRFC1459,
chantypes: "#&",
linelen: 512,
+ historyLimit: 100,
+ prefixSymbols: "@+",
+ prefixModes: "ov",
users: map[string]*User{},
channels: map[string]Channel{},
chBatches: map[string]HistoryEvent{},
chReqs: map[string]struct{}{},
}
- s.running.Store(true)
-
- go func() {
- r := bufio.NewScanner(conn)
+ s.out <- NewMessage("CAP", "LS", "302")
+ s.out <- NewMessage("NICK", s.nick)
+ s.out <- NewMessage("USER", s.user, "0", "*", s.real)
- for r.Scan() {
- line := r.Text()
- msg, err := ParseMessage(line)
- if err != nil {
- continue
- }
- valid := msg.IsValid()
- if s.debug {
- s.evts <- RawMessageEvent{Message: line, IsValid: valid}
- }
- if valid {
- s.msgs <- msg
- }
- }
-
- s.Stop()
- }()
-
- err := s.send("CAP LS 302\r\nNICK %s\r\nUSER %s 0 * :%s\r\n", s.nick, s.user, s.real)
- if err != nil {
- return nil, err
- }
-
- go s.run()
-
- return s, nil
-}
-
-// Running reports whether we are still connected to the server.
-func (s *Session) Running() bool {
- return s.running.Load().(bool)
+ return s
}
-// Stop stops the session and closes the connection.
-func (s *Session) Stop() {
- if !s.Running() {
+func (s *Session) Close() {
+ if s.closed {
return
}
- s.running.Store(false)
- _ = s.conn.Close()
- close(s.acts)
- close(s.evts)
- close(s.msgs)
- s.typings.Stop()
-}
-
-// Poll returns the event channel where incoming events are reported.
-func (s *Session) Poll() (events <-chan Event) {
- return s.evts
+ s.closed = true
+ close(s.out)
}
// HasCapability reports whether the given capability has been negociated
@@ -297,6 +184,10 @@ func (s *Session) NickCf() string {
return s.nickCf
}
+func (s *Session) IsMe(nick string) bool {
+ return s.nickCf == s.casemap(nick)
+}
+
func (s *Session) IsChannel(name string) bool {
return strings.IndexAny(name, s.chantypes) == 0
}
@@ -332,11 +223,14 @@ func (s *Session) Names(channel string) []Member {
// Typings returns the list of nickname who are currently typing.
func (s *Session) Typings(target string) []string {
- targetCf := s.Casemap(target)
- var res []string
- for t := range s.typings.targets {
- if targetCf == t.Target && s.Casemap(t.Name) != s.NickCf() {
- res = append(res, s.users[t.Name].Name.Name)
+ targetCf := s.casemap(target)
+ res := s.typings.List(targetCf)
+ for i := 0; i < len(res); i++ {
+ if s.IsMe(res[i]) {
+ res = append(res[:i], res[i+1:]...)
+ i--
+ } else if u, ok := s.users[res[i]]; ok {
+ res[i] = u.Name.Name
}
}
return res
@@ -368,50 +262,34 @@ func (s *Session) Topic(channel string) (topic string, who *Prefix, at time.Time
return
}
-// SendRaw sends its given argument verbatim to the server.
func (s *Session) SendRaw(raw string) {
- s.acts <- actionSendRaw{raw}
-}
-
-func (s *Session) sendRaw(act actionSendRaw) (err error) {
- err = s.send("%s\r\n", act.raw)
- return
+ s.out <- NewMessage(raw)
}
func (s *Session) Join(channel string) {
- s.acts <- actionJoin{channel}
-}
-
-func (s *Session) join(act actionJoin) (err error) {
- err = s.send("JOIN %s\r\n", act.Channel)
- return
+ // TODO support keys
+ s.out <- NewMessage("JOIN", channel)
}
func (s *Session) Part(channel, reason string) {
- s.acts <- actionPart{channel, reason}
+ s.out <- NewMessage("PART", channel, reason)
}
-func (s *Session) part(act actionPart) (err error) {
- err = s.send("PART %s :%s\r\n", act.Channel, act.Reason)
- return
+func (s *Session) ChangeTopic(channel, topic string) {
+ s.out <- NewMessage("TOPIC", channel, topic)
}
-func (s *Session) SetTopic(channel, topic string) {
- s.acts <- actionSetTopic{channel, topic}
-}
-
-func (s *Session) setTopic(act actionSetTopic) (err error) {
- err = s.send("TOPIC %s :%s\r\n", act.Channel, act.Topic)
- return
+func (s *Session) Quit(reason string) {
+ s.out <- NewMessage("QUIT", reason)
}
-func (s *Session) Quit(reason string) {
- s.acts <- actionQuit{reason}
+func (s *Session) ChangeNick(nick string) {
+ s.out <- NewMessage("NICK", nick)
}
-func (s *Session) quit(act actionQuit) (err error) {
- err = s.send("QUIT :%s\r\n", act.Reason)
- return
+func (s *Session) ChangeMode(channel, flags string, args []string) {
+ args = append([]string{channel, flags}, args...)
+ s.out <- NewMessage("MODE", args...)
}
func splitChunks(s string, chunkLen int) (chunks []string) {
@@ -433,34 +311,7 @@ func splitChunks(s string, chunkLen int) (chunks []string) {
return
}
-func (s *Session) ChangeNick(nick string) {
- s.acts <- actionChangeNick{nick}
-}
-
-func (s *Session) changeNick(act actionChangeNick) (err error) {
- err = s.send("NICK %s\r\n", act.Nick)
- return
-}
-
-func (s *Session) ChangeMode(channel string, flags string, args []string) {
- s.acts <- actionChangeMode{channel, flags, args}
-}
-
-func (s *Session) changeMode(act actionChangeMode) (err error) {
- if strings.IndexAny(act.Channel, s.chantypes) == 0 {
- err = s.send("MODE %s %s %s\r\n",
- act.Channel, act.Flags, strings.Join(act.Args, " "))
- } else {
- err = s.send("MODE %s %s\r\n", act.Channel, act.Flags)
- }
- return
-}
-
func (s *Session) PrivMsg(target, content string) {
- s.acts <- actionPrivMsg{target, content}
-}
-
-func (s *Session) privMsg(act actionPrivMsg) (err error) {
hostLen := len(s.host)
if hostLen == 0 {
hostLen = len("255.255.255.255")
@@ -470,173 +321,115 @@ func (s *Session) privMsg(act actionPrivMsg) (err error) {
len(s.nick) -
len(s.user) -
hostLen -
- len(act.Target)
- chunks := splitChunks(act.Content, maxMessageLen)
+ len(target)
+ chunks := splitChunks(content, maxMessageLen)
for _, chunk := range chunks {
- err = s.send("PRIVMSG %s :%s\r\n", act.Target, chunk)
- if err != nil {
- return
- }
+ s.out <- NewMessage("PRIVMSG", target, chunk)
}
- target := s.Casemap(act.Target)
- delete(s.typingStamps, target)
- return
-}
-
-func (s *Session) Typing(channel string) {
- s.acts <- actionTyping{channel}
+ targetCf := s.Casemap(target)
+ delete(s.typingStamps, targetCf)
}
-func (s *Session) typing(act actionTyping) (err error) {
- if _, ok := s.enabledCaps["message-tags"]; !ok {
+func (s *Session) Typing(target string) {
+ if !s.HasCapability("message-tags") {
return
}
-
- to := s.Casemap(act.Channel)
+ targetCf := s.casemap(target)
now := time.Now()
-
- if t, ok := s.typingStamps[to]; ok && now.Sub(t).Seconds() < 3.0 {
+ if t, ok := s.typingStamps[targetCf]; ok && now.Sub(t).Seconds() < 3.0 {
return
}
-
- s.typingStamps[to] = now
-
- err = s.send("@+typing=active TAGMSG %s\r\n", act.Channel)
- return
-}
-
-func (s *Session) TypingStop(channel string) {
- s.acts <- actionTypingStop{channel}
+ s.typingStamps[targetCf] = now
+ s.out <- NewMessage("TAGMSG", target).WithTag("+typing", "active")
}
-func (s *Session) typingStop(act actionTypingStop) (err error) {
- if _, ok := s.enabledCaps["message-tags"]; !ok {
+func (s *Session) TypingStop(target string) {
+ if !s.HasCapability("message-tags") {
return
}
+ s.out <- NewMessage("TAGMSG", target).WithTag("+typing", "done")
+}
- err = s.send("@+typing=done TAGMSG %s\r\n", act.Channel)
- return
+type HistoryRequest struct {
+ s *Session
+ target string
+ command string
+ bounds []string
+ limit int
}
-func (s *Session) RequestHistory(target string, before time.Time) {
- s.acts <- actionRequestHistory{target, before}
+func formatTimestamp(t time.Time) string {
+ return fmt.Sprintf("timestamp=%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
+ t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond()/1e6)
}
-func (s *Session) requestHistory(act actionRequestHistory) (err error) {
- if _, ok := s.enabledCaps["draft/chathistory"]; !ok {
- return
+func (r *HistoryRequest) WithLimit(limit int) *HistoryRequest {
+ if limit < r.s.historyLimit {
+ r.limit = limit
+ } else {
+ r.limit = r.s.historyLimit
}
+ return r
+}
- target := s.Casemap(act.Target)
- if _, ok := s.chReqs[target]; ok {
+func (r *HistoryRequest) doRequest() {
+ if !r.s.HasCapability("draft/chathistory") {
return
}
- s.chReqs[target] = struct{}{}
- t := act.Before.UTC().Add(1 * time.Second)
- 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)
+ targetCf := r.s.casemap(r.target)
+ if _, ok := r.s.chReqs[targetCf]; ok {
+ return
+ }
+ r.s.chReqs[targetCf] = struct{}{}
- return
+ args := make([]string, 0, len(r.bounds)+3)
+ args = append(args, r.command)
+ args = append(args, r.target)
+ args = append(args, r.bounds...)
+ args = append(args, strconv.Itoa(r.limit))
+ r.s.out <- NewMessage("CHATHISTORY", args...)
}
-func (s *Session) run() {
- for s.Running() {
- var err error
+func (r *HistoryRequest) Before(t time.Time) {
+ r.command = "BEFORE"
+ r.bounds = []string{formatTimestamp(t)}
+ r.doRequest()
+}
- select {
- case act, ok := <-s.acts:
- if !ok {
- break
- }
- switch act := act.(type) {
- case actionSendRaw:
- err = s.sendRaw(act)
- case actionChangeNick:
- err = s.changeNick(act)
- case actionChangeMode:
- err = s.changeMode(act)
- case actionJoin:
- err = s.join(act)
- case actionPart:
- err = s.part(act)
- case actionSetTopic:
- err = s.setTopic(act)
- case actionQuit:
- err = s.quit(act)
- case actionPrivMsg:
- err = s.privMsg(act)
- case actionTyping:
- err = s.typing(act)
- case actionTypingStop:
- err = s.typingStop(act)
- case actionRequestHistory:
- err = s.requestHistory(act)
- }
- case msg, ok := <-s.msgs:
- if !ok {
- break
- }
- if s.registered {
- err = s.handle(msg)
- } else {
- err = s.handleStart(msg)
- }
- case t, ok := <-s.typings.Stops():
- if !ok {
- break
- }
- u, ok := s.users[t.Name]
- if !ok {
- break
- }
- c, ok := s.channels[t.Target]
- if !ok {
- break
- }
- s.evts <- TagEvent{
- User: u.Name,
- Target: c.Name,
- Typing: TypingDone,
- Time: time.Now(),
- }
- }
+func (s *Session) NewHistoryRequest(target string) *HistoryRequest {
+ return &HistoryRequest{
+ s: s,
+ target: target,
+ limit: s.historyLimit,
+ }
+}
- if err != nil {
- s.evts <- err
- }
+func (s *Session) HandleMessage(msg Message) Event {
+ if s.registered {
+ return s.handleRegistered(msg)
+ } else {
+ return s.handleUnregistered(msg)
}
}
-func (s *Session) handleStart(msg Message) (err error) {
+func (s *Session) handleUnregistered(msg Message) Event {
switch msg.Command {
case "AUTHENTICATE":
if s.auth != nil {
- var res string
-
- res, err = s.auth.Respond(msg.Params[0])
+ res, err := s.auth.Respond(msg.Params[0])
if err != nil {
- err = s.send("AUTHENTICATE *\r\n")
- return
- }
-
- err = s.send("AUTHENTICATE %s\r\n", res)
- if err != nil {
- return
+ s.out <- NewMessage("AUTHENTICATE", "*")
+ } else {
+ s.out <- NewMessage("AUTHENTICATE", res)
}
}
case rplLoggedin:
- err = s.send("CAP END\r\n")
- if err != nil {
- return
- }
-
+ s.out <- NewMessage("CAP", "END")
s.acct = msg.Params[2]
s.host = ParsePrefix(msg.Params[1]).Host
case errNicklocked, errSaslfail, errSasltoolong, errSaslaborted, errSaslalready, rplSaslmechs:
- err = s.send("CAP END\r\n")
- if err != nil {
- return
- }
+ s.out <- NewMessage("CAP", "END")
case "CAP":
switch msg.Params[1] {
case "LS":
@@ -652,57 +445,44 @@ func (s *Session) handleStart(msg Message) (err error) {
}
for _, c := range ParseCaps(ls) {
- if c.Enable {
- s.availableCaps[c.Name] = c.Value
- } else {
- delete(s.availableCaps, c.Name)
- }
+ s.availableCaps[c.Name] = c.Value
}
if !willContinue {
- var req strings.Builder
-
for c := range s.availableCaps {
if _, ok := SupportedCapabilities[c]; !ok {
continue
}
-
- _, _ = fmt.Fprintf(&req, "CAP REQ %s\r\n", c)
+ s.out <- NewMessage("CAP", "REQ", c)
}
_, ok := s.availableCaps["sasl"]
if s.auth == nil || !ok {
- _, _ = fmt.Fprintf(&req, "CAP END\r\n")
- }
-
- err = s.send(req.String())
- if err != nil {
- return
+ s.out <- NewMessage("CAP", "END")
}
}
default:
- s.handle(msg)
+ return s.handleRegistered(msg)
}
case errNicknameinuse:
- err = s.send("NICK %s_\r\n", msg.Params[1])
- if err != nil {
- return
- }
+ s.out <- NewMessage("NICK", msg.Params[1]+"_")
+ case rplSaslsuccess:
+ // do nothing
default:
- err = s.handle(msg)
+ return s.handleRegistered(msg)
}
-
- return
+ return nil
}
-func (s *Session) handle(msg Message) (err error) {
+func (s *Session) handleRegistered(msg Message) Event {
if id, ok := msg.Tags["batch"]; ok {
if b, ok := s.chBatches[id]; ok {
+ ev := s.newMessageEvent(msg)
s.chBatches[id] = HistoryEvent{
Target: b.Target,
- Messages: append(b.Messages, s.privmsgToEvent(msg)),
+ Messages: append(b.Messages, ev),
}
- return
+ return nil
}
}
@@ -714,14 +494,10 @@ func (s *Session) handle(msg Message) (err error) {
s.users[s.nickCf] = &User{Name: &Prefix{
Name: s.nick, User: s.user, Host: s.host,
}}
- s.evts <- RegisteredEvent{}
-
if s.host == "" {
- err = s.send("WHO %s\r\n", s.nick)
- if err != nil {
- return
- }
+ s.out <- NewMessage("WHO", s.nick)
}
+ return RegisteredEvent{}
case rplIsupport:
s.updateFeatures(msg.Params[1 : len(msg.Params)-1])
case rplWhoreply:
@@ -731,106 +507,49 @@ func (s *Session) handle(msg Message) (err error) {
case "CAP":
switch msg.Params[1] {
case "ACK":
- for _, c := range strings.Split(msg.Params[2], " ") {
- s.enabledCaps[c] = struct{}{}
+ for _, c := range ParseCaps(msg.Params[2]) {
+ if c.Enable {
+ s.enabledCaps[c.Name] = struct{}{}
+ } else {
+ delete(s.enabledCaps, c.Name)
+ }
- if s.auth != nil && c == "sasl" {
+ if s.auth != nil && c.Name == "sasl" {
h := s.auth.Handshake()
- err = s.send("AUTHENTICATE %s\r\n", h)
- if err != nil {
- return
- }
- } else if len(s.channels) != 0 && c == "multi-prefix" {
+ s.out <- NewMessage("AUTHENTICATE", h)
+ } else if len(s.channels) != 0 && c.Name == "multi-prefix" {
// TODO merge NAMES commands
- var sb strings.Builder
- sb.Grow(512)
- for _, c := range s.channels {
- sb.WriteString("NAMES ")
- sb.WriteString(c.Name)
- sb.WriteString("\r\n")
- }
- err = s.send(sb.String())
- if err != nil {
- return
+ for channel := range s.channels {
+ s.out <- NewMessage("NAMES", channel)
}
}
}
case "NAK":
- for _, c := range strings.Split(msg.Params[2], " ") {
- delete(s.enabledCaps, c)
- }
+ // do nothing
case "NEW":
- diff := ParseCaps(msg.Params[2])
-
- for _, c := range diff {
- if c.Enable {
- s.availableCaps[c.Name] = c.Value
- } else {
- delete(s.availableCaps, c.Name)
- }
- }
-
- var req strings.Builder
-
- for _, c := range diff {
+ for _, c := range ParseCaps(msg.Params[2]) {
+ s.availableCaps[c.Name] = c.Value
_, ok := SupportedCapabilities[c.Name]
- if !c.Enable || !ok {
+ if !ok {
continue
}
-
- _, _ = fmt.Fprintf(&req, "CAP REQ %s\r\n", c.Name)
+ s.out <- NewMessage("CAP", "REQ", c.Name)
}
_, ok := s.availableCaps["sasl"]
if s.acct == "" && ok {
// TODO authenticate
}
-
- err = s.send(req.String())
- if err != nil {
- return
- }
case "DEL":
- diff := ParseCaps(msg.Params[2])
-
- for i := range diff {
- diff[i].Enable = !diff[i].Enable
- }
-
- for _, c := range diff {
- if c.Enable {
- s.availableCaps[c.Name] = c.Value
- } else {
- delete(s.availableCaps, c.Name)
- }
- }
-
- var req strings.Builder
-
- for _, c := range diff {
- _, ok := SupportedCapabilities[c.Name]
- if !c.Enable || !ok {
- continue
- }
-
- _, _ = fmt.Fprintf(&req, "CAP REQ %s\r\n", c.Name)
- }
-
- _, ok := s.availableCaps["sasl"]
- if s.acct == "" && ok {
- // TODO authenticate
- }
-
- err = s.send(req.String())
- if err != nil {
- return
+ for _, c := range ParseCaps(msg.Params[2]) {
+ delete(s.availableCaps, c.Name)
+ delete(s.enabledCaps, c.Name)
}
}
case "JOIN":
nickCf := s.Casemap(msg.Prefix.Name)
channelCf := s.Casemap(msg.Params[0])
-
- if nickCf == s.nickCf {
+ if s.IsMe(msg.Prefix.Name) {
s.channels[channelCf] = Channel{
Name: msg.Params[0],
Members: map[*User]string{},
@@ -840,61 +559,56 @@ func (s *Session) handle(msg Message) (err error) {
s.users[nickCf] = &User{Name: msg.Prefix.Copy()}
}
c.Members[s.users[nickCf]] = ""
- t := msg.TimeOrNow()
-
- s.evts <- UserJoinEvent{
- User: msg.Prefix.Copy(),
+ return UserJoinEvent{
+ User: msg.Prefix.Name,
Channel: c.Name,
- Time: t,
}
}
case "PART":
nickCf := s.Casemap(msg.Prefix.Name)
channelCf := s.Casemap(msg.Params[0])
-
- if nickCf == s.nickCf {
+ if s.IsMe(msg.Prefix.Name) {
if c, ok := s.channels[channelCf]; ok {
delete(s.channels, channelCf)
for u := range c.Members {
s.cleanUser(u)
}
- s.evts <- SelfPartEvent{Channel: c.Name}
+ return SelfPartEvent{
+ Channel: c.Name,
+ }
}
} else if c, ok := s.channels[channelCf]; ok {
if u, ok := s.users[nickCf]; ok {
delete(c.Members, u)
s.cleanUser(u)
s.typings.Done(channelCf, nickCf)
-
- s.evts <- UserPartEvent{
- User: msg.Prefix.Copy(),
+ return UserPartEvent{
+ User: u.Name.Name,
Channel: c.Name,
- Time: msg.TimeOrNow(),
}
}
}
case "KICK":
- channelCf := s.Casemap(msg.Params[0])
nickCf := s.Casemap(msg.Params[1])
-
- if nickCf == s.nickCf {
+ channelCf := s.Casemap(msg.Params[0])
+ if s.IsMe(msg.Prefix.Name) {
if c, ok := s.channels[channelCf]; ok {
delete(s.channels, channelCf)
for u := range c.Members {
s.cleanUser(u)
}
- s.evts <- SelfPartEvent{Channel: c.Name}
+ return SelfPartEvent{
+ Channel: c.Name,
+ }
}
} else if c, ok := s.channels[channelCf]; ok {
if u, ok := s.users[nickCf]; ok {
delete(c.Members, u)
s.cleanUser(u)
s.typings.Done(channelCf, nickCf)
-
- s.evts <- UserPartEvent{
- User: u.Name.Copy(),
+ return UserPartEvent{
+ User: u.Name.Name,
Channel: c.Name,
- Time: msg.TimeOrNow(),
}
}
}
@@ -911,11 +625,9 @@ func (s *Session) handle(msg Message) (err error) {
s.typings.Done(channelCf, nickCf)
}
}
-
- s.evts <- UserQuitEvent{
- User: msg.Prefix.Copy(),
+ return UserQuitEvent{
+ User: u.Name.Name,
Channels: channels,
- Time: msg.TimeOrNow(),
}
}
case rplNamreply:
@@ -924,8 +636,7 @@ func (s *Session) handle(msg Message) (err error) {
if c, ok := s.channels[channelCf]; ok {
c.Secret = msg.Params[1] == "@"
- // TODO compute CHANTYPES
- for _, name := range ParseNameReply(msg.Params[3], "~&@%+") {
+ for _, name := range ParseNameReply(msg.Params[3], s.prefixSymbols) {
nickCf := s.Casemap(name.Name.Name)
if _, ok := s.users[nickCf]; !ok {
@@ -941,7 +652,9 @@ func (s *Session) handle(msg Message) (err error) {
if c, ok := s.channels[channelCf]; ok && !c.complete {
c.complete = true
s.channels[channelCf] = c
- s.evts <- SelfJoinEvent{Channel: c.Name}
+ return SelfJoinEvent{
+ Channel: c.Name,
+ }
}
case rplTopic:
channelCf := s.Casemap(msg.Params[1])
@@ -970,51 +683,34 @@ func (s *Session) handle(msg Message) (err error) {
c.TopicWho = msg.Prefix.Copy()
c.TopicTime = msg.TimeOrNow()
s.channels[channelCf] = c
- s.evts <- TopicChangeEvent{
- User: msg.Prefix.Copy(),
+ return TopicChangeEvent{
Channel: c.Name,
Topic: c.Topic,
- Time: c.TopicTime,
}
}
case "PRIVMSG", "NOTICE":
- s.evts <- s.privmsgToEvent(msg)
+ targetCf := s.casemap(msg.Params[0])
+ nickCf := s.casemap(msg.Prefix.Name)
+ s.typings.Done(targetCf, nickCf)
+ return s.newMessageEvent(msg)
case "TAGMSG":
nickCf := s.Casemap(msg.Prefix.Name)
targetCf := s.Casemap(msg.Params[0])
- if nickCf == s.nickCf {
+ if s.IsMe(msg.Prefix.Name) {
// TAGMSG from self
break
}
- typing := TypingUnspec
if t, ok := msg.Tags["+typing"]; ok {
if t == "active" {
- typing = TypingActive
s.typings.Active(targetCf, nickCf)
} else if t == "paused" {
- typing = TypingPaused
- s.typings.Active(targetCf, nickCf)
+ s.typings.Done(targetCf, nickCf)
} else if t == "done" {
- typing = TypingDone
s.typings.Done(targetCf, nickCf)
}
- } else {
- break
- }
-
- ev := TagEvent{
- User: msg.Prefix.Copy(), // TODO correctly casemap
- Target: msg.Params[0], // TODO correctly casemap
- Typing: typing,
- Time: msg.TimeOrNow(),
}
- if c, ok := s.channels[targetCf]; ok {
- ev.Target = c.Name
- ev.TargetIsChannel = true
- }
- s.evts <- ev
case "BATCH":
batchStart := msg.Params[0][0] == '+'
id := msg.Params[0][1:]
@@ -1022,90 +718,74 @@ func (s *Session) handle(msg Message) (err error) {
if batchStart && msg.Params[1] == "chathistory" {
s.chBatches[id] = HistoryEvent{Target: msg.Params[2]}
} else if b, ok := s.chBatches[id]; ok {
- s.evts <- b
delete(s.chBatches, id)
delete(s.chReqs, s.Casemap(b.Target))
+ return b
}
case "NICK":
nickCf := s.Casemap(msg.Prefix.Name)
newNick := msg.Params[0]
newNickCf := s.Casemap(newNick)
- t := msg.TimeOrNow()
- var u *Prefix
if formerUser, ok := s.users[nickCf]; ok {
formerUser.Name.Name = newNick
delete(s.users, nickCf)
s.users[newNickCf] = formerUser
- u = formerUser.Name.Copy()
} else {
break
}
- if nickCf == s.nickCf {
- s.evts <- SelfNickEvent{
- FormerNick: s.nick,
- Time: t,
- }
+ if s.IsMe(msg.Prefix.Name) {
s.nick = newNick
s.nickCf = newNickCf
+ return SelfNickEvent{
+ FormerNick: msg.Prefix.Name,
+ }
} else {
- s.evts <- UserNickEvent{
- User: u,
+ return UserNickEvent{
+ User: msg.Params[0],
FormerNick: msg.Prefix.Name,
- Time: t,
}
}
+ case "PING":
+ s.out <- NewMessage("PONG", msg.Params[0])
+ case "ERROR":
+ s.Close()
case "FAIL":
- s.evts <- ErrorEvent{
+ return ErrorEvent{
Severity: SeverityFail,
Code: msg.Params[1],
- Message: msg.Params[len(msg.Params)-1],
+ Message: strings.Join(msg.Params[2:], " "),
}
case "WARN":
- s.evts <- ErrorEvent{
+ return ErrorEvent{
Severity: SeverityWarn,
Code: msg.Params[1],
- Message: msg.Params[len(msg.Params)-1],
+ Message: strings.Join(msg.Params[2:], " "),
}
case "NOTE":
- s.evts <- ErrorEvent{
+ return ErrorEvent{
Severity: SeverityNote,
Code: msg.Params[1],
- Message: msg.Params[len(msg.Params)-1],
- }
- case "PING":
- err = s.send("PONG :%s\r\n", msg.Params[0])
- if err != nil {
- return
- }
- case "ERROR":
- err = errors.New("connection terminated")
- if len(msg.Params) > 0 {
- err = fmt.Errorf("connection terminated: %s", msg.Params[0])
+ Message: strings.Join(msg.Params[2:], " "),
}
- _ = s.conn.Close()
default:
- // reply handling
- if ReplySeverity(msg.Command) == SeverityFail {
- s.evts <- ErrorEvent{
- Severity: SeverityFail,
+ if msg.IsReply() {
+ return ErrorEvent{
+ Severity: ReplySeverity(msg.Command),
Code: msg.Command,
- Message: msg.Params[len(msg.Params)-1],
+ Message: strings.Join(msg.Params[1:], " "),
}
}
}
-
- return
+ return nil
}
-func (s *Session) privmsgToEvent(msg Message) (ev MessageEvent) {
+func (s *Session) newMessageEvent(msg Message) MessageEvent {
targetCf := s.Casemap(msg.Params[0])
-
- s.typings.Done(targetCf, s.Casemap(msg.Prefix.Name))
- ev = MessageEvent{
- User: msg.Prefix.Copy(), // TODO correctly casemap
- Target: msg.Params[0], // TODO correctly casemap
+ ev := MessageEvent{
+ User: msg.Prefix.Name, // TODO correctly casemap
+ Target: msg.Params[0], // TODO correctly casemap
Command: msg.Command,
Content: msg.Params[1],
Time: msg.TimeOrNow(),
@@ -1114,8 +794,7 @@ func (s *Session) privmsgToEvent(msg Message) (ev MessageEvent) {
ev.Target = c.Name
ev.TargetIsChannel = true
}
-
- return
+ return ev
}
func (s *Session) cleanUser(parted *User) {
@@ -1157,6 +836,7 @@ func (s *Session) updateFeatures(features []string) {
continue
}
+ Switch:
switch key {
case "CASEMAPPING":
switch value {
@@ -1167,31 +847,32 @@ func (s *Session) updateFeatures(features []string) {
}
case "CHANTYPES":
s.chantypes = value
+ case "CHATHISTORY":
+ historyLimit, err := strconv.Atoi(value)
+ if err == nil {
+ s.historyLimit = historyLimit
+ }
case "LINELEN":
linelen, err := strconv.Atoi(value)
if err == nil && linelen != 0 {
s.linelen = linelen
}
- }
- }
-}
-
-func (s *Session) send(format string, args ...interface{}) (err error) {
- msg := fmt.Sprintf(format, args...)
-
- s.conn.SetWriteDeadline(time.Now().Add(writeDeadline))
- _, err = s.conn.Write([]byte(msg))
-
- if s.debug {
- for _, line := range strings.Split(msg, "\r\n") {
- if line != "" {
- s.evts <- RawMessageEvent{
- Message: line,
- Outgoing: true,
+ case "PREFIX":
+ if value == "" {
+ s.prefixModes = ""
+ s.prefixSymbols = ""
+ }
+ if len(value)%2 != 0 {
+ break Switch
+ }
+ for i := 0; i < len(value); i++ {
+ if unicode.MaxASCII < value[i] {
+ break Switch
}
}
+ numPrefixes := len(value)/2 - 1
+ s.prefixModes = value[1 : numPrefixes+1]
+ s.prefixSymbols = value[numPrefixes+2:]
}
}
-
- return
}
diff --git a/irc/tokens.go b/irc/tokens.go
index 86e6539..9c669bb 100644
--- a/irc/tokens.go
+++ b/irc/tokens.go
@@ -229,6 +229,10 @@ type Message struct {
Params []string
}
+func NewMessage(command string, params ...string) Message {
+ return Message{Command: command, Params: params}
+}
+
// ParseMessage parses the message from the given string, which must be trimmed
// of "\r\n" beforehand.
func ParseMessage(line string) (msg Message, err error) {
@@ -282,6 +286,14 @@ func ParseMessage(line string) (msg Message, err error) {
return
}
+func (msg Message) WithTag(key, value string) Message {
+ if msg.Tags == nil {
+ msg.Tags = map[string]string{}
+ }
+ msg.Tags[key] = escapeTagValue(value)
+ return msg
+}
+
// IsReply reports whether the message command is a server reply.
func (msg *Message) IsReply() bool {
if len(msg.Command) != 3 {
@@ -326,9 +338,15 @@ func (msg *Message) String() string {
sb.WriteRune(' ')
sb.WriteString(p)
}
- sb.WriteRune(' ')
- sb.WriteRune(':')
- sb.WriteString(msg.Params[len(msg.Params)-1])
+ lastParam := msg.Params[len(msg.Params)-1]
+ if !strings.ContainsRune(lastParam, ' ') && !strings.HasPrefix(lastParam, ":") {
+ sb.WriteRune(' ')
+ sb.WriteString(lastParam)
+ } else {
+ sb.WriteRune(' ')
+ sb.WriteRune(':')
+ sb.WriteString(lastParam)
+ }
}
return sb.String()
diff --git a/irc/typing.go b/irc/typing.go
index bfadd64..5f028e4 100644
--- a/irc/typing.go
+++ b/irc/typing.go
@@ -73,3 +73,16 @@ func (ts *Typings) Done(target, name string) {
delete(ts.targets, Typing{target, name})
ts.l.Unlock()
}
+
+func (ts *Typings) List(target string) []string {
+ ts.l.Lock()
+ defer ts.l.Unlock()
+
+ var res []string
+ for t := range ts.targets {
+ if target == t.Target {
+ res = append(res, t.Name)
+ }
+ }
+ return res
+}
diff --git a/window.go b/window.go
index dcd3fba..3633c18 100644
--- a/window.go
+++ b/window.go
@@ -22,28 +22,35 @@ var homeMessages = []string{
func (app *App) initWindow() {
hmIdx := rand.Intn(len(homeMessages))
app.win.AddBuffer(Home)
- app.addLineNow("", ui.Line{
+ app.win.AddLine(Home, false, ui.Line{
Head: "--",
Body: homeMessages[hmIdx],
+ At: time.Now(),
})
}
-func (app *App) addLineNow(buffer string, line ui.Line) {
+func (app *App) queueStatusLine(line ui.Line) {
if line.At.IsZero() {
line.At = time.Now()
}
- app.win.AddLine(buffer, false, line)
- app.draw()
+ app.events <- event{
+ src: uiEvent,
+ content: line,
+ }
}
-func (app *App) draw() {
- if app.s != nil {
- app.setStatus()
+func (app *App) addStatusLine(line ui.Line) {
+ buffer := app.win.CurrentBuffer()
+ if buffer != Home {
+ app.win.AddLine(Home, false, line)
}
- app.win.Draw()
+ app.win.AddLine(buffer, false, line)
}
func (app *App) setStatus() {
+ if app.s == nil {
+ return
+ }
ts := app.s.Typings(app.win.CurrentBuffer())
status := ""
if 3 < len(ts) {