diff options
-rw-r--r-- | app.go | 431 | ||||
-rw-r--r-- | cmd/test/main.go | 37 | ||||
-rw-r--r-- | commands.go | 10 | ||||
-rw-r--r-- | irc/channel.go | 40 | ||||
-rw-r--r-- | irc/events.go | 35 | ||||
-rw-r--r-- | irc/session.go (renamed from irc/states.go) | 769 | ||||
-rw-r--r-- | irc/tokens.go | 24 | ||||
-rw-r--r-- | irc/typing.go | 13 | ||||
-rw-r--r-- | window.go | 23 |
9 files changed, 583 insertions, 799 deletions
@@ -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 +} @@ -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) { |