summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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) {