package senpai import ( "crypto/tls" "errors" "fmt" "net" "os" "os/exec" "strings" "sync" "time" "unicode" "unicode/utf8" "git.sr.ht/~taiite/senpai/irc" "git.sr.ht/~taiite/senpai/ui" "github.com/gdamore/tcell/v2" "golang.org/x/net/context" "golang.org/x/net/proxy" ) const eventChanSize = 1024 func isCommand(input []rune) bool { // Command can't start with two slashes because that's an escape for // a literal slash in the message return len(input) >= 1 && input[0] == '/' && !(len(input) >= 2 && input[1] == '/') } type bound struct { first time.Time last time.Time firstMessage string lastMessage string } // Compare returns 0 if line is within bounds, -1 if before, 1 if after. func (b *bound) Compare(line *ui.Line) int { at := line.At.Truncate(time.Second) if at.Before(b.first) { return -1 } if at.After(b.last) { return 1 } if at.Equal(b.first) && line.Body.String() != b.firstMessage { return -1 } if at.Equal(b.last) && line.Body.String() != b.lastMessage { return -1 } return 0 } // Update updates the bounds to include the given line. func (b *bound) Update(line *ui.Line) { if line.At.IsZero() { return } at := line.At.Truncate(time.Second) if b.first.IsZero() || at.Before(b.first) { b.first = at b.firstMessage = line.Body.String() } else if b.last.IsZero() || at.After(b.last) { b.last = at b.lastMessage = line.Body.String() } } // IsZero reports whether the bound is empty. func (b *bound) IsZero() bool { return b.first.IsZero() } type event struct { src string // "*" if UI, netID otherwise content interface{} } type boundKey struct { netID string target string } type App struct { win *ui.UI sessions map[string]*irc.Session // map of network IDs to their current session pasting bool events chan event cfg Config highlights []string lastQuery string lastQueryNet string messageBounds map[boundKey]bound lastNetID string lastBuffer string monitor map[string]map[string]struct{} // set of targets we want to monitor per netID, best-effort. netID->target->{} networkLock sync.RWMutex // locks networks networks map[string]struct{} // set of network IDs we want to connect to; to be locked with networkLock lastMessageTime time.Time lastCloseTime time.Time } func NewApp(cfg Config) (app *App, err error) { app = &App{ networks: map[string]struct{}{ "": {}, // add the master network by default }, sessions: map[string]*irc.Session{}, events: make(chan event, eventChanSize), cfg: cfg, messageBounds: map[boundKey]bound{}, monitor: make(map[string]map[string]struct{}), } if cfg.Highlights != nil { app.highlights = make([]string, len(cfg.Highlights)) for i := range app.highlights { app.highlights[i] = strings.ToLower(cfg.Highlights[i]) } } mouse := cfg.Mouse app.win, err = ui.New(ui.Config{ NickColWidth: cfg.NickColWidth, ChanColWidth: cfg.ChanColWidth, ChanColEnabled: cfg.ChanColEnabled, MemberColWidth: cfg.MemberColWidth, MemberColEnabled: cfg.MemberColEnabled, TextMaxWidth: cfg.TextMaxWidth, AutoComplete: func(cursorIdx int, text []rune) []ui.Completion { return app.completions(cursorIdx, text) }, Mouse: mouse, MergeLine: func(former *ui.Line, addition ui.Line) { app.mergeLine(former, addition) }, Colors: ui.ConfigColors{ Unread: cfg.Colors.Unread, Nicks: cfg.Colors.Nicks, }, }) if err != nil { return } app.win.SetPrompt(ui.Styled(">", tcell. StyleDefault. Foreground(tcell.Color(app.cfg.Colors.Prompt))), ) app.initWindow() return } func (app *App) Close() { app.win.Exit() // tell all instances of app.ircLoop to stop when possible app.events <- event{ // tell app.eventLoop to stop src: "*", content: nil, } for _, session := range app.sessions { session.Close() } } func (app *App) SwitchToBuffer(netID, buffer string) { app.lastNetID = netID app.lastBuffer = buffer } func (app *App) Run() { if app.lastCloseTime.IsZero() { app.lastCloseTime = time.Now() } go app.uiLoop() go app.ircLoop("") app.eventLoop() } func (app *App) CurrentSession() *irc.Session { netID, _ := app.win.CurrentBuffer() return app.sessions[netID] } func (app *App) CurrentBuffer() (netID, buffer string) { return app.win.CurrentBuffer() } func (app *App) LastMessageTime() time.Time { return app.lastMessageTime } func (app *App) SetLastClose(t time.Time) { app.lastCloseTime = t } // eventLoop retrieves events (in batches) from the event channel and handle // them, then draws the interface after each batch is handled. func (app *App) eventLoop() { defer app.win.Close() for !app.win.ShouldExit() { ev := <-app.events if !app.handleEvent(ev) { return } deadline := time.NewTimer(200 * time.Millisecond) outer: for { select { case <-deadline.C: break outer case ev := <-app.events: if !app.handleEvent(ev) { return } default: if !deadline.Stop() { <-deadline.C } break outer } } if !app.pasting { if netID, buffer, timestamp := app.win.UpdateRead(); buffer != "" { s := app.sessions[netID] if s != nil { s.ReadSet(buffer, timestamp) } } app.setStatus() app.updatePrompt() app.setBufferNumbers() var currentMembers []irc.Member netID, buffer := app.win.CurrentBuffer() s := app.sessions[netID] if s != nil && buffer != "" { currentMembers = s.Names(buffer) } app.win.Draw(currentMembers) } } go func() { // drain events until we close for range app.events { } }() } func (app *App) handleEvent(ev event) bool { if ev.src == "*" { if ev.content == nil { return false } if !app.handleUIEvent(ev.content) { return false } } else { app.handleIRCEvent(ev.src, ev.content) } return true } func (app *App) wantsNetwork(netID string) bool { if app.win.ShouldExit() { return false } app.networkLock.RLock() _, ok := app.networks[netID] app.networkLock.RUnlock() return ok } // ircLoop maintains a connection to the IRC server by connecting and then // forwarding IRC events to app.events repeatedly. func (app *App) ircLoop(netID string) { 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, NetID: netID, Auth: auth, } const throttleInterval = 6 * time.Second const throttleMax = 1 * time.Minute var delay time.Duration = 0 for app.wantsNetwork(netID) { time.Sleep(delay) if delay < throttleMax { delay += throttleInterval } conn := app.connect(netID) if conn == nil { continue } delay = throttleInterval in, out := irc.ChanInOut(conn) if app.cfg.Debug { out = app.debugOutputMessages(netID, out) } session := irc.NewSession(out, params) app.events <- event{ src: netID, content: session, } go func() { for stop := range session.TypingStops() { app.events <- event{ src: netID, content: stop, } } }() for msg := range in { if app.cfg.Debug { app.queueStatusLine(netID, ui.Line{ At: time.Now(), Head: "IN --", Body: ui.PlainString(msg.String()), }) } app.events <- event{ src: netID, content: msg, } } app.events <- event{ src: netID, content: nil, } app.queueStatusLine(netID, ui.Line{ Head: "!!", HeadColor: tcell.ColorRed, Body: ui.PlainString("Connection lost"), }) } } func (app *App) connect(netID string) net.Conn { app.queueStatusLine(netID, ui.Line{ Head: "--", Body: ui.PlainSprintf("Connecting to %s...", app.cfg.Addr), }) conn, err := app.tryConnect() if err == nil { return conn } app.queueStatusLine(netID, ui.Line{ Head: "!!", HeadColor: tcell.ColorRed, Body: ui.PlainSprintf("Connection failed: %v", err), }) return nil } func (app *App) tryConnect() (conn net.Conn, err error) { addr := app.cfg.Addr colonIdx := strings.LastIndexByte(addr, ':') bracketIdx := strings.LastIndexByte(addr, ']') if colonIdx <= bracketIdx { // either colonIdx < 0, or the last colon is before a ']' (end // of IPv6 address. -> missing port if app.cfg.TLS { addr += ":6697" } else { addr += ":6667" } } ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) dialer := &net.Dialer{ Timeout: 10 * time.Second, } conn, err = proxy.FromEnvironmentUsing(dialer).(proxy.ContextDialer).DialContext(ctx, "tcp", addr) if err != nil { return nil, fmt.Errorf("connect: %v", err) } if tcpConn, ok := conn.(*net.TCPConn); ok { tcpConn.SetKeepAlive(true) tcpConn.SetKeepAlivePeriod(15 * time.Second) } if app.cfg.TLS { host, _, _ := net.SplitHostPort(addr) // should succeed since net.Dial did. conn = tls.Client(conn, &tls.Config{ ServerName: host, NextProtos: []string{"irc"}, }) err = conn.(*tls.Conn).HandshakeContext(ctx) if err != nil { conn.Close() return nil, fmt.Errorf("tls handshake: %v", err) } } return } func (app *App) debugOutputMessages(netID string, out chan<- irc.Message) chan<- irc.Message { debugOut := make(chan irc.Message, cap(out)) go func() { for msg := range debugOut { app.queueStatusLine(netID, ui.Line{ At: time.Now(), Head: "OUT --", Body: ui.PlainString(msg.String()), }) out <- msg } close(out) }() return debugOut } // uiLoop retrieves events from the UI and forwards them to app.events for // handling in app.eventLoop(). func (app *App) uiLoop() { for ev := range app.win.Events { app.events <- event{ src: "*", content: ev, } } } func (app *App) handleUIEvent(ev interface{}) bool { 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 *tcell.EventError: // happens when the terminal is closing: in which case, exit return false case statusLine: app.addStatusLine(ev.netID, ev.line) default: panic("unreachable") } return true } func (app *App) handleMouseEvent(ev *tcell.EventMouse) { x, y := ev.Position() w, h := app.win.Size() if ev.Buttons()&tcell.WheelUp != 0 { if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) { app.win.ScrollChannelUpBy(4) } else if x > w-app.win.MemberWidth() { app.win.ScrollMemberUpBy(4) } else { app.win.ScrollUpBy(4) app.requestHistory() } } if ev.Buttons()&tcell.WheelDown != 0 { if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) { app.win.ScrollChannelDownBy(4) } else if x > w-app.win.MemberWidth() { app.win.ScrollMemberDownBy(4) } else { app.win.ScrollDownBy(4) } } if ev.Buttons()&tcell.ButtonPrimary != 0 { if x < app.win.ChannelWidth() { app.win.ClickBuffer(y + app.win.ChannelOffset()) } else if app.win.ChannelWidth() == 0 && y == h-1 { app.win.ClickBuffer(app.win.HorizontalBufferOffset(x)) } else if x > w-app.win.MemberWidth() { app.win.ClickMember(y + app.win.MemberOffset()) } } if ev.Buttons() == 0 { if x < app.win.ChannelWidth() { if i := y + app.win.ChannelOffset(); i == app.win.ClickedBuffer() { app.win.GoToBufferNo(i) } } else if app.win.ChannelWidth() == 0 && y == h-1 { if i := app.win.HorizontalBufferOffset(x); i >= 0 && i == app.win.ClickedBuffer() { app.win.GoToBufferNo(i) } } else if x > w-app.win.MemberWidth() { if i := y + app.win.MemberOffset(); i == app.win.ClickedMember() { netID, target := app.win.CurrentBuffer() s := app.sessions[netID] if s != nil && target != "" { members := s.Names(target) if i < len(members) { buffer := members[i].Name.Name i, added := app.win.AddBuffer(netID, "", buffer) app.win.JumpBufferIndex(i) if added { s.MonitorAdd(buffer) s.ReadGet(buffer) s.NewHistoryRequest(buffer).WithLimit(500).Before(time.Now()) } } } } } app.win.ClickBuffer(-1) app.win.ClickMember(-1) } } func (app *App) handleKeyEvent(ev *tcell.EventKey) { switch ev.Key() { case tcell.KeyCtrlC: if app.win.InputClear() { app.typing() } else { app.win.InputSet("/quit") } case tcell.KeyCtrlL: app.win.Resize() case tcell.KeyCtrlU, tcell.KeyPgUp: app.win.ScrollUp() app.requestHistory() case tcell.KeyCtrlD, tcell.KeyPgDn: app.win.ScrollDown() case tcell.KeyCtrlN: app.win.NextBuffer() app.win.HorizontalBufferScrollTo() case tcell.KeyCtrlP: app.win.PreviousBuffer() app.win.HorizontalBufferScrollTo() case tcell.KeyRight: if ev.Modifiers() == tcell.ModAlt { app.win.NextBuffer() } else if ev.Modifiers() == tcell.ModCtrl { app.win.InputRightWord() } else { app.win.InputRight() } case tcell.KeyLeft: if ev.Modifiers() == tcell.ModAlt { app.win.PreviousBuffer() } else if ev.Modifiers() == tcell.ModCtrl { app.win.InputLeftWord() } else { app.win.InputLeft() } case tcell.KeyUp: if ev.Modifiers() == tcell.ModAlt { app.win.PreviousBuffer() } else { app.win.InputUp() } case tcell.KeyDown: if ev.Modifiers() == tcell.ModAlt { app.win.NextBuffer() } else { app.win.InputDown() } case tcell.KeyHome: if ev.Modifiers() == tcell.ModAlt { app.win.GoToBufferNo(0) } else { app.win.InputHome() } case tcell.KeyEnd: if ev.Modifiers() == tcell.ModAlt { maxInt := int(^uint(0) >> 1) app.win.GoToBufferNo(maxInt) } else { app.win.InputEnd() } case tcell.KeyBackspace, tcell.KeyBackspace2: var ok bool if ev.Modifiers() == tcell.ModAlt { ok = app.win.InputDeleteWord() } else { ok = app.win.InputBackspace() } if ok { app.typing() } case tcell.KeyDelete: ok := app.win.InputDelete() if ok { app.typing() } case tcell.KeyCtrlW: ok := app.win.InputDeleteWord() if ok { app.typing() } case tcell.KeyCtrlR: app.win.InputBackSearch() case tcell.KeyTab: ok := app.win.InputAutoComplete(1) if ok { app.typing() } case tcell.KeyBacktab: ok := app.win.InputAutoComplete(-1) if ok { app.typing() } case tcell.KeyEscape: app.win.CloseOverlay() case tcell.KeyF7: app.win.ToggleChannelList() case tcell.KeyF8: app.win.ToggleMemberList() case tcell.KeyCR, tcell.KeyLF: netID, buffer := app.win.CurrentBuffer() input := app.win.InputEnter() err := app.handleInput(buffer, input) if err != nil { app.win.AddLine(netID, buffer, ui.Line{ At: time.Now(), Head: "!!", HeadColor: tcell.ColorRed, Notify: ui.NotifyUnread, Body: ui.PlainSprintf("%q: %s", input, err), }) } case tcell.KeyRune: if ev.Modifiers() == tcell.ModAlt { switch ev.Rune() { case 'n': app.win.ScrollDownHighlight() case 'p': app.win.ScrollUpHighlight() case '1', '2', '3', '4', '5', '6', '7', '8', '9': app.win.GoToBufferNo(int(ev.Rune()-'0') - 1) } } else { app.win.InputRune(ev.Rune()) app.typing() } default: return } } // requestHistory is a wrapper around irc.Session.RequestHistory to only request // history when needed. func (app *App) requestHistory() { if app.win.HasOverlay() { return } netID, buffer := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return } if app.win.IsAtTop() && buffer != "" { t := time.Now() if bound, ok := app.messageBounds[boundKey{netID, buffer}]; ok { t = bound.first } s.NewHistoryRequest(buffer). WithLimit(200). Before(t) } } func (app *App) handleIRCEvent(netID string, ev interface{}) { if ev == nil { if s, ok := app.sessions[netID]; ok { s.Close() delete(app.sessions, netID) } return } if s, ok := ev.(*irc.Session); ok { if s, ok := app.sessions[netID]; ok { s.Close() } if !app.wantsNetwork(netID) { delete(app.sessions, netID) delete(app.monitor, netID) s.Close() return } app.sessions[netID] = s if _, ok := app.monitor[netID]; !ok { app.monitor[netID] = make(map[string]struct{}) } return } if _, ok := ev.(irc.Typing); ok { // Just refresh the screen. return } msg, ok := ev.(irc.Message) if !ok { panic("unreachable") } s, ok := app.sessions[netID] if !ok { panic("unreachable") } // Mutate IRC state ev, err := s.HandleMessage(msg) if err != nil { app.win.AddLine(netID, "", ui.Line{ Head: "!!", HeadColor: tcell.ColorRed, Notify: ui.NotifyUnread, Body: ui.PlainSprintf("Received corrupt message %q: %s", msg.String(), err), }) return } t := msg.TimeOrNow() if t.After(app.lastMessageTime) { app.lastMessageTime = t } // Mutate UI state switch ev := ev.(type) { case irc.RegisteredEvent: for _, channel := range app.cfg.Channels { // TODO: group JOIN messages // TODO: support autojoining channels with keys s.Join(channel, "") } s.NewHistoryRequest(""). WithLimit(1000). Targets(app.lastCloseTime, msg.TimeOrNow()) body := "Connected to the server" if s.Nick() != app.cfg.Nick { body = fmt.Sprintf("Connected to the server as %s", s.Nick()) } app.addStatusLine(netID, ui.Line{ At: msg.TimeOrNow(), Head: "--", Body: ui.PlainString(body), }) for target := range app.monitor[s.NetID()] { // TODO: batch MONITOR + s.MonitorAdd(target) } case irc.SelfNickEvent: var body ui.StyledStringBuilder body.WriteString(fmt.Sprintf("%s\u2192%s", ev.FormerNick, s.Nick())) textStyle := tcell.StyleDefault.Foreground(tcell.ColorGray) arrowStyle := tcell.StyleDefault body.AddStyle(0, textStyle) body.AddStyle(len(ev.FormerNick), arrowStyle) body.AddStyle(body.Len()-len(s.Nick()), textStyle) app.addStatusLine(netID, ui.Line{ At: msg.TimeOrNow(), Head: "--", HeadColor: tcell.ColorGray, Body: body.StyledString(), Highlight: true, Readable: true, }) case irc.UserNickEvent: line := app.formatEvent(ev) for _, c := range s.ChannelsSharedWith(ev.User) { app.win.AddLine(netID, c, line) } case irc.SelfJoinEvent: i, added := app.win.AddBuffer(netID, "", ev.Channel) bounds, ok := app.messageBounds[boundKey{netID, ev.Channel}] if added || !ok { s.NewHistoryRequest(ev.Channel). WithLimit(500). Before(msg.TimeOrNow()) } else { s.NewHistoryRequest(ev.Channel). WithLimit(1000). After(bounds.last) } if ev.Requested { app.win.JumpBufferIndex(i) } if ev.Topic != "" { topic := ui.IRCString(ev.Topic).String() app.win.SetTopic(netID, ev.Channel, topic) } // Restore last buffer if netID == app.lastNetID && ev.Channel == app.lastBuffer { app.win.JumpBufferNetwork(app.lastNetID, app.lastBuffer) app.win.HorizontalBufferScrollTo() app.lastNetID = "" app.lastBuffer = "" } case irc.UserJoinEvent: line := app.formatEvent(ev) app.win.AddLine(netID, ev.Channel, line) case irc.SelfPartEvent: app.win.RemoveBuffer(netID, ev.Channel) delete(app.messageBounds, boundKey{netID, ev.Channel}) case irc.UserPartEvent: line := app.formatEvent(ev) app.win.AddLine(netID, ev.Channel, line) case irc.UserQuitEvent: line := app.formatEvent(ev) for _, c := range ev.Channels { app.win.AddLine(netID, c, line) } case irc.TopicChangeEvent: line := app.formatEvent(ev) app.win.AddLine(netID, ev.Channel, line) topic := ui.IRCString(ev.Topic).String() app.win.SetTopic(netID, ev.Channel, topic) case irc.ModeChangeEvent: line := app.formatEvent(ev) app.win.AddLine(netID, ev.Channel, line) case irc.InviteEvent: var buffer string var notify ui.NotifyType var body string if s.IsMe(ev.Invitee) { buffer = "" notify = ui.NotifyHighlight body = fmt.Sprintf("%s invited you to join %s", ev.Inviter, ev.Channel) } else if s.IsMe(ev.Inviter) { buffer = ev.Channel notify = ui.NotifyNone body = fmt.Sprintf("You invited %s to join this channel", ev.Invitee) } else { buffer = ev.Channel notify = ui.NotifyUnread body = fmt.Sprintf("%s invited %s to join this channel", ev.Inviter, ev.Invitee) } app.win.AddLine(netID, buffer, ui.Line{ At: msg.TimeOrNow(), Head: "--", HeadColor: tcell.ColorGray, Notify: notify, Body: ui.Styled(body, tcell.StyleDefault.Foreground(tcell.ColorGray)), Highlight: notify == ui.NotifyHighlight, Readable: true, }) case irc.MessageEvent: buffer, line := app.formatMessage(s, ev) if line.IsZero() { break } if buffer != "" && !s.IsChannel(buffer) { if _, added := app.win.AddBuffer(netID, "", buffer); added { app.monitor[netID][buffer] = struct{}{} s.MonitorAdd(buffer) s.ReadGet(buffer) s.NewHistoryRequest(buffer). WithLimit(500). Before(msg.TimeOrNow()) } } app.win.AddLine(netID, buffer, line) if line.Notify == ui.NotifyHighlight { app.notifyHighlight(buffer, ev.User, line.Body.String()) } if !s.IsChannel(msg.Params[0]) && !s.IsMe(ev.User) { app.lastQuery = msg.Prefix.Name app.lastQueryNet = netID } bounds := app.messageBounds[boundKey{netID, ev.Target}] bounds.Update(&line) app.messageBounds[boundKey{netID, buffer}] = bounds case irc.HistoryTargetsEvent: type target struct { name string last time.Time } // try to fetch the history of the last opened buffer first targets := make([]target, 0, len(ev.Targets)) if app.lastNetID == netID { if last, ok := ev.Targets[app.lastBuffer]; ok { targets = append(targets, target{app.lastBuffer, last}) delete(ev.Targets, app.lastBuffer) } } for name, last := range ev.Targets { targets = append(targets, target{name, last}) } for _, target := range targets { if s.IsChannel(target.name) { continue } s.MonitorAdd(target.name) s.ReadGet(target.name) app.win.AddBuffer(netID, "", target.name) // CHATHISTORY BEFORE excludes its bound, so add 1ms // (precision of the time tag) to include that last message. target.last = target.last.Add(1 * time.Millisecond) s.NewHistoryRequest(target.name). WithLimit(500). Before(target.last) } case irc.HistoryEvent: var linesBefore []ui.Line var linesAfter []ui.Line bounds, hasBounds := app.messageBounds[boundKey{netID, ev.Target}] for _, m := range ev.Messages { var line ui.Line switch ev := m.(type) { case irc.MessageEvent: _, line = app.formatMessage(s, ev) default: line = app.formatEvent(ev) } if line.IsZero() { continue } if hasBounds { c := bounds.Compare(&line) if c < 0 { linesBefore = append(linesBefore, line) } else if c > 0 { linesAfter = append(linesAfter, line) } } else { linesBefore = append(linesBefore, line) } } app.win.AddLines(netID, ev.Target, linesBefore, linesAfter) if len(linesBefore) != 0 { bounds.Update(&linesBefore[0]) bounds.Update(&linesBefore[len(linesBefore)-1]) } if len(linesAfter) != 0 { bounds.Update(&linesAfter[0]) bounds.Update(&linesAfter[len(linesAfter)-1]) } if !bounds.IsZero() { app.messageBounds[boundKey{netID, ev.Target}] = bounds } case irc.SearchEvent: app.win.OpenOverlay() lines := make([]ui.Line, 0, len(ev.Messages)) for _, m := range ev.Messages { _, line := app.formatMessage(s, m) if line.IsZero() { continue } lines = append(lines, line) } app.win.AddLines("", ui.Overlay, lines, nil) case irc.ReadEvent: app.win.SetRead(netID, ev.Target, ev.Timestamp) case irc.BouncerNetworkEvent: if !ev.Delete { _, added := app.win.AddBuffer(ev.ID, ev.Name, "") if added { app.networkLock.Lock() app.networks[ev.ID] = struct{}{} app.networkLock.Unlock() go app.ircLoop(ev.ID) } } else { app.networkLock.Lock() delete(app.networks, ev.ID) app.networkLock.Unlock() // if a session was already opened, close it now. // otherwise, we'll close it when it sends a new session event. if s, ok := app.sessions[ev.ID]; ok { s.Close() delete(app.sessions, ev.ID) delete(app.monitor, ev.ID) } app.win.RemoveNetworkBuffers(ev.ID) } case irc.ErrorEvent: if isBlackListed(msg.Command) { break } if ev.Code == "372" { app.win.AddLine(netID, "", ui.Line{ At: msg.TimeOrNow(), Head: "MOTD --", Body: ui.PlainString(ev.Message), }) 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(netID, ui.Line{ At: msg.TimeOrNow(), Head: head, Body: ui.PlainString(body), }) } } func isBlackListed(command string) bool { switch command { case "002", "003", "004", "375", "376", "422": // useless connection messages return true } return false } func isWordBoundary(r rune) bool { switch r { case '-', '_', '|': // inspired from weechat.look.highlight_regex return false default: return !unicode.IsLetter(r) && !unicode.IsNumber(r) } } func isHighlight(text, nick string) bool { for { i := strings.Index(text, nick) if i < 0 { return false } left, _ := utf8.DecodeLastRuneInString(text[:i]) right, _ := utf8.DecodeRuneInString(text[i+len(nick):]) if isWordBoundary(left) && isWordBoundary(right) { return true } text = text[i+len(nick):] } } // isHighlight reports whether the given message content is a highlight. func (app *App) isHighlight(s *irc.Session, content string) bool { contentCf := s.Casemap(content) if app.highlights == nil { return isHighlight(contentCf, s.NickCf()) } for _, h := range app.highlights { if isHighlight(contentCf, s.Casemap(h)) { return true } } return false } // notifyHighlight executes the script at "on-highlight-path" according to the given // message context. func (app *App) notifyHighlight(buffer, nick, content string) { if app.cfg.OnHighlightBeep { app.win.Beep() } path := app.cfg.OnHighlightPath if path == "" { defaultHighlightPath, err := DefaultHighlightPath() if err != nil { return } path = defaultHighlightPath } netID, curBuffer := app.win.CurrentBuffer() if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { // only error out if the user specified a highlight path // if default path unreachable, simple bail if app.cfg.OnHighlightPath != "" { body := fmt.Sprintf("Unable to find on-highlight command at path: %q", path) app.addStatusLine(netID, ui.Line{ At: time.Now(), Head: "!!", HeadColor: tcell.ColorRed, Body: ui.PlainString(body), }) } return } here := "0" if buffer == curBuffer { // TODO also check netID here = "1" } cmd := exec.Command(path) cmd.Env = append(os.Environ(), fmt.Sprintf("BUFFER=%s", buffer), fmt.Sprintf("HERE=%s", here), fmt.Sprintf("SENDER=%s", nick), fmt.Sprintf("MESSAGE=%s", content), ) output, err := cmd.CombinedOutput() if err != nil { body := fmt.Sprintf("Failed to invoke on-highlight command at path: %v. Output: %q", err, string(output)) app.addStatusLine(netID, ui.Line{ At: time.Now(), Head: "!!", HeadColor: tcell.ColorRed, Body: ui.PlainString(body), }) } } // typing sends typing notifications to the IRC server according to the user // input. func (app *App) typing() { netID, buffer := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil || !app.cfg.Typings { return } if buffer == "" { return } input := app.win.InputContent() if len(input) == 0 { s.TypingStop(buffer) } else if !isCommand(input) { s.Typing(buffer) } } // completions computes the list of completions given the input text and the // cursor position. func (app *App) completions(cursorIdx int, text []rune) []ui.Completion { if len(text) == 0 { return nil } netID, buffer := app.win.CurrentBuffer() s := app.sessions[netID] if s == nil { return nil } var cs []ui.Completion if buffer != "" { cs = app.completionsChannelTopic(cs, cursorIdx, text) cs = app.completionsChannelMembers(cs, cursorIdx, text) } cs = app.completionsMsg(cs, cursorIdx, text) cs = app.completionsCommands(cs, cursorIdx, text) if cs != nil { cs = append(cs, ui.Completion{ Text: text, CursorIdx: cursorIdx, }) } return cs } // formatEvent returns a formatted ui.Line for an irc.Event. func (app *App) formatEvent(ev irc.Event) ui.Line { switch ev := ev.(type) { case irc.UserNickEvent: var body ui.StyledStringBuilder body.WriteString(fmt.Sprintf("%s\u2192%s", ev.FormerNick, ev.User)) textStyle := tcell.StyleDefault.Foreground(tcell.ColorGray) arrowStyle := tcell.StyleDefault body.AddStyle(0, textStyle) body.AddStyle(len(ev.FormerNick), arrowStyle) body.AddStyle(body.Len()-len(ev.User), textStyle) return ui.Line{ At: ev.Time, Head: "--", HeadColor: tcell.ColorGray, Body: body.StyledString(), Mergeable: true, Data: []irc.Event{ev}, Readable: true, } case irc.UserJoinEvent: var body ui.StyledStringBuilder body.Grow(len(ev.User) + 1) body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen)) body.WriteByte('+') body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) body.WriteString(ev.User) return ui.Line{ At: ev.Time, Head: "--", HeadColor: tcell.ColorGray, Body: body.StyledString(), Mergeable: true, Data: []irc.Event{ev}, Readable: true, } case irc.UserPartEvent: var body ui.StyledStringBuilder body.Grow(len(ev.User) + 1) body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorRed)) body.WriteByte('-') body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) body.WriteString(ev.User) return ui.Line{ At: ev.Time, Head: "--", HeadColor: tcell.ColorGray, Body: body.StyledString(), Mergeable: true, Data: []irc.Event{ev}, Readable: true, } case irc.UserQuitEvent: var body ui.StyledStringBuilder body.Grow(len(ev.User) + 1) body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorRed)) body.WriteByte('-') body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray)) body.WriteString(ev.User) return ui.Line{ At: ev.Time, Head: "--", HeadColor: tcell.ColorGray, Body: body.StyledString(), Mergeable: true, Data: []irc.Event{ev}, Readable: true, } case irc.TopicChangeEvent: topic := ui.IRCString(ev.Topic).String() body := fmt.Sprintf("Topic changed to: %s", topic) return ui.Line{ At: ev.Time, Head: "--", HeadColor: tcell.ColorGray, Notify: ui.NotifyUnread, Body: ui.Styled(body, tcell.StyleDefault.Foreground(tcell.ColorGray)), Readable: true, } case irc.ModeChangeEvent: body := fmt.Sprintf("[%s]", ev.Mode) // simple mode event: <+/-> mergeable := len(strings.Split(ev.Mode, " ")) == 2 return ui.Line{ At: ev.Time, Head: "--", HeadColor: tcell.ColorGray, Body: ui.Styled(body, tcell.StyleDefault.Foreground(tcell.ColorGray)), Mergeable: mergeable, Data: []irc.Event{ev}, Readable: true, } default: return ui.Line{} } } // formatMessage sets how a given message must be formatted. // // It computes three things: // - which buffer the message must be added to, // - the UI line. func (app *App) formatMessage(s *irc.Session, ev irc.MessageEvent) (buffer string, line ui.Line) { isFromSelf := s.IsMe(ev.User) isToSelf := s.IsMe(ev.Target) isHighlight := ev.TargetIsChannel && app.isHighlight(s, ev.Content) isQuery := !ev.TargetIsChannel && ev.Command == "PRIVMSG" isNotice := ev.Command == "NOTICE" content := strings.TrimSuffix(ev.Content, "\x01") content = strings.TrimRightFunc(content, unicode.IsSpace) isAction := false if strings.HasPrefix(ev.Content, "\x01") { parts := strings.SplitN(ev.Content[1:], " ", 2) if len(parts) < 2 { return } switch parts[0] { case "ACTION": isAction = true default: return } content = parts[1] } if !ev.TargetIsChannel && isNotice { curNetID, curBuffer := app.win.CurrentBuffer() if curNetID == s.NetID() { buffer = curBuffer } } else if isToSelf { buffer = ev.User } else { buffer = ev.Target } var notification ui.NotifyType hlLine := ev.TargetIsChannel && isHighlight && !isFromSelf if isFromSelf { notification = ui.NotifyNone } else if isHighlight || isQuery { notification = ui.NotifyHighlight } else { notification = ui.NotifyUnread } head := ev.User headColor := tcell.ColorWhite if isAction || isNotice { head = "*" } else { headColor = ui.IdentColor(app.cfg.Colors.Nicks, head) } var body ui.StyledStringBuilder if isNotice { color := ui.IdentColor(app.cfg.Colors.Nicks, ev.User) body.SetStyle(tcell.StyleDefault.Foreground(color)) body.WriteString(ev.User) body.SetStyle(tcell.StyleDefault) body.WriteString(": ") body.WriteStyledString(ui.IRCString(content)) } else if isAction { color := ui.IdentColor(app.cfg.Colors.Nicks, ev.User) body.SetStyle(tcell.StyleDefault.Foreground(color)) body.WriteString(ev.User) body.SetStyle(tcell.StyleDefault) body.WriteString(" ") body.WriteStyledString(ui.IRCString(content)) } else { body.WriteStyledString(ui.IRCString(content)) } line = ui.Line{ At: ev.Time, Head: head, HeadColor: headColor, Notify: notification, Body: body.StyledString(), Highlight: hlLine, Readable: true, } return } func (app *App) mergeLine(former *ui.Line, addition ui.Line) { events := append(former.Data.([]irc.Event), addition.Data.([]irc.Event)...) type flow struct { hide bool state int // -1: newly offline; 1: newly online } flows := make(map[string]*flow) eventFlows := make([]*flow, len(events)) for i, ev := range events { switch ev := ev.(type) { case irc.UserNickEvent: userCf := strings.ToLower(ev.User) f, ok := flows[strings.ToLower(ev.FormerNick)] if ok { flows[userCf] = f delete(flows, strings.ToLower(ev.FormerNick)) eventFlows[i] = f } else { f = &flow{} flows[userCf] = f eventFlows[i] = f } case irc.UserJoinEvent: userCf := strings.ToLower(ev.User) f, ok := flows[userCf] if ok { if f.state == -1 { f.hide = true delete(flows, userCf) } } else { f = &flow{ state: 1, } flows[userCf] = f eventFlows[i] = f } case irc.UserPartEvent: userCf := strings.ToLower(ev.User) f, ok := flows[userCf] if ok { if f.state == 1 { f.hide = true delete(flows, userCf) } } else { f = &flow{ state: -1, } flows[userCf] = f eventFlows[i] = f } case irc.UserQuitEvent: userCf := strings.ToLower(ev.User) f, ok := flows[userCf] if ok { if f.state == 1 { f.hide = true delete(flows, userCf) } } else { f = &flow{ state: -1, } flows[userCf] = f eventFlows[i] = f } case irc.ModeChangeEvent: userCf := strings.ToLower(strings.Split(ev.Mode, " ")[1]) f, ok := flows[userCf] if ok { eventFlows[i] = f } else { f = &flow{} flows[userCf] = f eventFlows[i] = f } } } newBody := new(ui.StyledStringBuilder) newBody.Grow(128) first := true for i, ev := range events { if f := eventFlows[i]; f == nil || f.hide { continue } l := app.formatEvent(ev) if first { first = false } else { newBody.WriteString(" ") } newBody.WriteStyledString(l.Body) } former.Body = newBody.StyledString() former.Data = events } // updatePrompt changes the prompt text according to the application context. func (app *App) updatePrompt() { netID, buffer := app.win.CurrentBuffer() s := app.sessions[netID] command := isCommand(app.win.InputContent()) var prompt ui.StyledString if buffer == "" || command { prompt = ui.Styled(">", tcell. StyleDefault. Foreground(tcell.Color(app.cfg.Colors.Prompt)), ) } else if s == nil { prompt = ui.Styled("", tcell. StyleDefault. Foreground(tcell.ColorRed), ) } else { prompt = ui.IdentString(app.cfg.Colors.Nicks, s.Nick()) } app.win.SetPrompt(prompt) } func (app *App) printTopic(netID, buffer string) (ok bool) { var body string s := app.sessions[netID] if s == nil { return false } topic, who, at := s.Topic(buffer) topic = ui.IRCString(topic).String() if who == nil { body = fmt.Sprintf("Topic: %s", topic) } else { body = fmt.Sprintf("Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic) } app.win.AddLine(netID, buffer, ui.Line{ At: time.Now(), Head: "--", HeadColor: tcell.ColorGray, Body: ui.Styled(body, tcell.StyleDefault.Foreground(tcell.ColorGray)), }) return true }