From 7835d39dda5f7a1dde3056de3dd8a84913b94c14 Mon Sep 17 00:00:00 2001 From: Hubert Hirtz Date: Tue, 18 May 2021 21:26:03 +0200 Subject: Fix races conditions Refactor: - Split out reads/writes from irc.Session to irc.ChanInOut, - Message handling is now manual, messages must be passed to irc.Session.HandleMessage for its state to change, - Remove data-race-prone App.addLineNow (called from both the main eventLoop and irc loops) and add App.addStatusLine (to be called from the main event loop) and App.queueStatusLine (to be called from other goroutines). These two functions now write to both the current buffer and the home buffer, - add a irc.Typings.List function that locks the list of typings before accessing it. Changes as I went through the whole code... - CAP handling is fixed (especially CAP DEL and CAP ACK), - irc.Session now handles PREFIX, - unhandled messages are now shown, except for some rare cases where it's completely useless to show them. --- irc/channel.go | 40 ++ irc/events.go | 35 +- irc/session.go | 878 +++++++++++++++++++++++++++++++++++++++++ irc/states.go | 1197 -------------------------------------------------------- irc/tokens.go | 24 +- irc/typing.go | 13 + 6 files changed, 958 insertions(+), 1229 deletions(-) create mode 100644 irc/channel.go create mode 100644 irc/session.go delete mode 100644 irc/states.go (limited to 'irc') 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/session.go b/irc/session.go new file mode 100644 index 0000000..69d29aa --- /dev/null +++ b/irc/session.go @@ -0,0 +1,878 @@ +package irc + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +type SASLClient interface { + Handshake() (mech string) + Respond(challenge string) (res string, err error) +} + +type SASLPlain struct { + Username string + Password string +} + +func (auth *SASLPlain) Handshake() (mech string) { + mech = "PLAIN" + return +} + +func (auth *SASLPlain) Respond(challenge string) (res string, err error) { + if challenge != "+" { + err = errors.New("unexpected challenge") + return + } + + user := []byte(auth.Username) + pass := []byte(auth.Password) + payload := bytes.Join([][]byte{user, user, pass}, []byte{0}) + res = base64.StdEncoding.EncodeToString(payload) + + return +} + +// SupportedCapabilities is the set of capabilities supported by this library. +var SupportedCapabilities = map[string]struct{}{ + "account-notify": {}, + "account-tag": {}, + "away-notify": {}, + "batch": {}, + "cap-notify": {}, + "draft/chathistory": {}, + "echo-message": {}, + "extended-join": {}, + "invite-notify": {}, + "labeled-response": {}, + "message-tags": {}, + "multi-prefix": {}, + "server-time": {}, + "sasl": {}, + "setname": {}, + "userhost-in-names": {}, +} + +// Values taken by the "@+typing=" client tag. TypingUnspec means the value or +// tag is absent. +const ( + TypingUnspec = iota + TypingActive + TypingPaused + TypingDone +) + +// 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. + AwayMsg string // the away message if the user is away, "" otherwise. +} + +// Channel is a joined channel. +type Channel struct { + Name string // the name of the channel. + Members map[*User]string // the set of members associated with their membership. + Topic string // the topic of the channel, or "" if absent. + TopicWho *Prefix // the name of the last user who set the topic. + TopicTime time.Time // the last time the topic has been changed. + Secret bool // whether the channel is on the server channel list. + + complete bool // whether this stucture is fully initialized. +} + +// SessionParams defines how to connect to an IRC server. +type SessionParams struct { + Nickname string + Username string + RealName string + + Auth SASLClient +} + +type Session struct { + out chan<- Message + closed bool + registered bool + typings *Typings // incoming typing notifications. + typingStamps map[string]time.Time // user typing instants. + + nick string + nickCf string // casemapped nickname. + user string + real string + acct string + host string + auth SASLClient + + availableCaps map[string]string + enabledCaps map[string]struct{} + + // ISUPPORT features + 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. + chBatches map[string]HistoryEvent // channel history batches being processed. + chReqs map[string]struct{} // set of targets for which history is currently requested. +} + +func NewSession(out chan<- Message, params SessionParams) *Session { + s := &Session{ + out: out, + typings: NewTypings(), + typingStamps: map[string]time.Time{}, + nick: params.Nickname, + nickCf: CasemapASCII(params.Nickname), + user: params.Username, + real: params.RealName, + auth: params.Auth, + availableCaps: map[string]string{}, + enabledCaps: map[string]struct{}{}, + 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.out <- NewMessage("CAP", "LS", "302") + s.out <- NewMessage("NICK", s.nick) + s.out <- NewMessage("USER", s.user, "0", "*", s.real) + + return s +} + +func (s *Session) Close() { + if s.closed { + return + } + s.closed = true + close(s.out) +} + +// HasCapability reports whether the given capability has been negociated +// successfully. +func (s *Session) HasCapability(capability string) bool { + _, ok := s.enabledCaps[capability] + return ok +} + +func (s *Session) Nick() string { + return s.nick +} + +// NickCf is our casemapped nickname. +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 +} + +func (s *Session) Casemap(name string) string { + return s.casemap(name) +} + +// Users returns the list of all known nicknames. +func (s *Session) Users() []string { + users := make([]string, 0, len(s.users)) + for _, u := range s.users { + users = append(users, u.Name.Name) + } + return users +} + +// Names returns the list of users in the given channel, or nil if this channel +// is not known by the session. +func (s *Session) Names(channel string) []Member { + var names []Member + if c, ok := s.channels[s.Casemap(channel)]; ok { + names = make([]Member, 0, len(c.Members)) + for u, pl := range c.Members { + names = append(names, Member{ + PowerLevel: pl, + Name: u.Name.Copy(), + }) + } + } + return names +} + +// Typings returns the list of nickname who are currently typing. +func (s *Session) Typings(target string) []string { + 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 +} + +func (s *Session) ChannelsSharedWith(name string) []string { + var user *User + if u, ok := s.users[s.Casemap(name)]; ok { + user = u + } else { + return nil + } + var channels []string + for _, c := range s.channels { + if _, ok := c.Members[user]; ok { + channels = append(channels, c.Name) + } + } + return channels +} + +func (s *Session) Topic(channel string) (topic string, who *Prefix, at time.Time) { + channelCf := s.Casemap(channel) + if c, ok := s.channels[channelCf]; ok { + topic = c.Topic + who = c.TopicWho + at = c.TopicTime + } + return +} + +func (s *Session) SendRaw(raw string) { + s.out <- NewMessage(raw) +} + +func (s *Session) Join(channel string) { + // TODO support keys + s.out <- NewMessage("JOIN", channel) +} + +func (s *Session) Part(channel, reason string) { + s.out <- NewMessage("PART", channel, reason) +} + +func (s *Session) ChangeTopic(channel, topic string) { + s.out <- NewMessage("TOPIC", channel, topic) +} + +func (s *Session) Quit(reason string) { + s.out <- NewMessage("QUIT", reason) +} + +func (s *Session) ChangeNick(nick string) { + s.out <- NewMessage("NICK", nick) +} + +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) { + if chunkLen <= 0 { + return []string{s} + } + for chunkLen < len(s) { + i := chunkLen + min := chunkLen - utf8.UTFMax + for min <= i && !utf8.RuneStart(s[i]) { + i-- + } + chunks = append(chunks, s[:i]) + s = s[i:] + } + if len(s) != 0 { + chunks = append(chunks, s) + } + return +} + +func (s *Session) PrivMsg(target, content string) { + hostLen := len(s.host) + if hostLen == 0 { + hostLen = len("255.255.255.255") + } + maxMessageLen := s.linelen - + len(":!@ PRIVMSG :\r\n") - + len(s.nick) - + len(s.user) - + hostLen - + len(target) + chunks := splitChunks(content, maxMessageLen) + for _, chunk := range chunks { + s.out <- NewMessage("PRIVMSG", target, chunk) + } + targetCf := s.Casemap(target) + delete(s.typingStamps, targetCf) +} + +func (s *Session) Typing(target string) { + if !s.HasCapability("message-tags") { + return + } + targetCf := s.casemap(target) + now := time.Now() + if t, ok := s.typingStamps[targetCf]; ok && now.Sub(t).Seconds() < 3.0 { + return + } + s.typingStamps[targetCf] = now + s.out <- NewMessage("TAGMSG", target).WithTag("+typing", "active") +} + +func (s *Session) TypingStop(target string) { + if !s.HasCapability("message-tags") { + return + } + s.out <- NewMessage("TAGMSG", target).WithTag("+typing", "done") +} + +type HistoryRequest struct { + s *Session + target string + command string + bounds []string + limit int +} + +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 (r *HistoryRequest) WithLimit(limit int) *HistoryRequest { + if limit < r.s.historyLimit { + r.limit = limit + } else { + r.limit = r.s.historyLimit + } + return r +} + +func (r *HistoryRequest) doRequest() { + if !r.s.HasCapability("draft/chathistory") { + return + } + + targetCf := r.s.casemap(r.target) + if _, ok := r.s.chReqs[targetCf]; ok { + return + } + r.s.chReqs[targetCf] = struct{}{} + + 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 (r *HistoryRequest) Before(t time.Time) { + r.command = "BEFORE" + r.bounds = []string{formatTimestamp(t)} + r.doRequest() +} + +func (s *Session) NewHistoryRequest(target string) *HistoryRequest { + return &HistoryRequest{ + s: s, + target: target, + limit: s.historyLimit, + } +} + +func (s *Session) HandleMessage(msg Message) Event { + if s.registered { + return s.handleRegistered(msg) + } else { + return s.handleUnregistered(msg) + } +} + +func (s *Session) handleUnregistered(msg Message) Event { + switch msg.Command { + case "AUTHENTICATE": + if s.auth != nil { + res, err := s.auth.Respond(msg.Params[0]) + if err != nil { + s.out <- NewMessage("AUTHENTICATE", "*") + } else { + s.out <- NewMessage("AUTHENTICATE", res) + } + } + case rplLoggedin: + s.out <- NewMessage("CAP", "END") + s.acct = msg.Params[2] + s.host = ParsePrefix(msg.Params[1]).Host + case errNicklocked, errSaslfail, errSasltoolong, errSaslaborted, errSaslalready, rplSaslmechs: + s.out <- NewMessage("CAP", "END") + case "CAP": + switch msg.Params[1] { + case "LS": + var willContinue bool + var ls string + + if msg.Params[2] == "*" { + willContinue = true + ls = msg.Params[3] + } else { + willContinue = false + ls = msg.Params[2] + } + + for _, c := range ParseCaps(ls) { + s.availableCaps[c.Name] = c.Value + } + + if !willContinue { + for c := range s.availableCaps { + if _, ok := SupportedCapabilities[c]; !ok { + continue + } + s.out <- NewMessage("CAP", "REQ", c) + } + + _, ok := s.availableCaps["sasl"] + if s.auth == nil || !ok { + s.out <- NewMessage("CAP", "END") + } + } + default: + return s.handleRegistered(msg) + } + case errNicknameinuse: + s.out <- NewMessage("NICK", msg.Params[1]+"_") + case rplSaslsuccess: + // do nothing + default: + return s.handleRegistered(msg) + } + return nil +} + +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, ev), + } + return nil + } + } + + switch msg.Command { + case rplWelcome: + s.nick = msg.Params[0] + s.nickCf = s.Casemap(s.nick) + s.registered = true + s.users[s.nickCf] = &User{Name: &Prefix{ + Name: s.nick, User: s.user, Host: s.host, + }} + if s.host == "" { + s.out <- NewMessage("WHO", s.nick) + } + return RegisteredEvent{} + case rplIsupport: + s.updateFeatures(msg.Params[1 : len(msg.Params)-1]) + case rplWhoreply: + if s.nickCf == s.Casemap(msg.Params[5]) { + s.host = msg.Params[3] + } + case "CAP": + switch msg.Params[1] { + case "ACK": + 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.Name == "sasl" { + h := s.auth.Handshake() + s.out <- NewMessage("AUTHENTICATE", h) + } else if len(s.channels) != 0 && c.Name == "multi-prefix" { + // TODO merge NAMES commands + for channel := range s.channels { + s.out <- NewMessage("NAMES", channel) + } + } + } + case "NAK": + // do nothing + case "NEW": + for _, c := range ParseCaps(msg.Params[2]) { + s.availableCaps[c.Name] = c.Value + _, ok := SupportedCapabilities[c.Name] + if !ok { + continue + } + s.out <- NewMessage("CAP", "REQ", c.Name) + } + + _, ok := s.availableCaps["sasl"] + if s.acct == "" && ok { + // TODO authenticate + } + case "DEL": + 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 s.IsMe(msg.Prefix.Name) { + s.channels[channelCf] = Channel{ + Name: msg.Params[0], + Members: map[*User]string{}, + } + } else if c, ok := s.channels[channelCf]; ok { + if _, ok := s.users[nickCf]; !ok { + s.users[nickCf] = &User{Name: msg.Prefix.Copy()} + } + c.Members[s.users[nickCf]] = "" + return UserJoinEvent{ + User: msg.Prefix.Name, + Channel: c.Name, + } + } + case "PART": + nickCf := s.Casemap(msg.Prefix.Name) + 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) + } + 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) + return UserPartEvent{ + User: u.Name.Name, + Channel: c.Name, + } + } + } + case "KICK": + nickCf := s.Casemap(msg.Params[1]) + 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) + } + 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) + return UserPartEvent{ + User: u.Name.Name, + Channel: c.Name, + } + } + } + case "QUIT": + nickCf := s.Casemap(msg.Prefix.Name) + + if u, ok := s.users[nickCf]; ok { + var channels []string + for channelCf, c := range s.channels { + if _, ok := c.Members[u]; ok { + channels = append(channels, c.Name) + delete(c.Members, u) + s.cleanUser(u) + s.typings.Done(channelCf, nickCf) + } + } + return UserQuitEvent{ + User: u.Name.Name, + Channels: channels, + } + } + case rplNamreply: + channelCf := s.Casemap(msg.Params[2]) + + if c, ok := s.channels[channelCf]; ok { + c.Secret = msg.Params[1] == "@" + + for _, name := range ParseNameReply(msg.Params[3], s.prefixSymbols) { + nickCf := s.Casemap(name.Name.Name) + + if _, ok := s.users[nickCf]; !ok { + s.users[nickCf] = &User{Name: name.Name.Copy()} + } + c.Members[s.users[nickCf]] = name.PowerLevel + } + + s.channels[channelCf] = c + } + case rplEndofnames: + channelCf := s.Casemap(msg.Params[1]) + if c, ok := s.channels[channelCf]; ok && !c.complete { + c.complete = true + s.channels[channelCf] = c + return SelfJoinEvent{ + Channel: c.Name, + } + } + case rplTopic: + channelCf := s.Casemap(msg.Params[1]) + if c, ok := s.channels[channelCf]; ok { + c.Topic = msg.Params[2] + s.channels[channelCf] = c + } + case rplTopicwhotime: + channelCf := s.Casemap(msg.Params[1]) + t, _ := strconv.ParseInt(msg.Params[3], 10, 64) + if c, ok := s.channels[channelCf]; ok { + c.TopicWho = ParsePrefix(msg.Params[2]) + c.TopicTime = time.Unix(t, 0) + s.channels[channelCf] = c + } + case rplNotopic: + channelCf := s.Casemap(msg.Params[1]) + if c, ok := s.channels[channelCf]; ok { + c.Topic = "" + s.channels[channelCf] = c + } + case "TOPIC": + channelCf := s.Casemap(msg.Params[0]) + if c, ok := s.channels[channelCf]; ok { + c.Topic = msg.Params[1] + c.TopicWho = msg.Prefix.Copy() + c.TopicTime = msg.TimeOrNow() + s.channels[channelCf] = c + return TopicChangeEvent{ + Channel: c.Name, + Topic: c.Topic, + } + } + case "PRIVMSG", "NOTICE": + 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 s.IsMe(msg.Prefix.Name) { + // TAGMSG from self + break + } + + if t, ok := msg.Tags["+typing"]; ok { + if t == "active" { + s.typings.Active(targetCf, nickCf) + } else if t == "paused" { + s.typings.Done(targetCf, nickCf) + } else if t == "done" { + s.typings.Done(targetCf, nickCf) + } + } + case "BATCH": + batchStart := msg.Params[0][0] == '+' + id := msg.Params[0][1:] + + if batchStart && msg.Params[1] == "chathistory" { + s.chBatches[id] = HistoryEvent{Target: msg.Params[2]} + } else if b, ok := s.chBatches[id]; ok { + 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) + + if formerUser, ok := s.users[nickCf]; ok { + formerUser.Name.Name = newNick + delete(s.users, nickCf) + s.users[newNickCf] = formerUser + } else { + break + } + + if s.IsMe(msg.Prefix.Name) { + s.nick = newNick + s.nickCf = newNickCf + return SelfNickEvent{ + FormerNick: msg.Prefix.Name, + } + } else { + return UserNickEvent{ + User: msg.Params[0], + FormerNick: msg.Prefix.Name, + } + } + case "PING": + s.out <- NewMessage("PONG", msg.Params[0]) + case "ERROR": + s.Close() + case "FAIL": + return ErrorEvent{ + Severity: SeverityFail, + Code: msg.Params[1], + Message: strings.Join(msg.Params[2:], " "), + } + case "WARN": + return ErrorEvent{ + Severity: SeverityWarn, + Code: msg.Params[1], + Message: strings.Join(msg.Params[2:], " "), + } + case "NOTE": + return ErrorEvent{ + Severity: SeverityNote, + Code: msg.Params[1], + Message: strings.Join(msg.Params[2:], " "), + } + default: + if msg.IsReply() { + return ErrorEvent{ + Severity: ReplySeverity(msg.Command), + Code: msg.Command, + Message: strings.Join(msg.Params[1:], " "), + } + } + } + return nil +} + +func (s *Session) newMessageEvent(msg Message) MessageEvent { + targetCf := s.Casemap(msg.Params[0]) + 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(), + } + if c, ok := s.channels[targetCf]; ok { + ev.Target = c.Name + ev.TargetIsChannel = true + } + return ev +} + +func (s *Session) cleanUser(parted *User) { + for _, c := range s.channels { + if _, ok := c.Members[parted]; ok { + return + } + } + delete(s.users, s.Casemap(parted.Name.Name)) +} + +func (s *Session) updateFeatures(features []string) { + for _, f := range features { + if f == "" || f == "-" || f == "=" || f == "-=" { + continue + } + + var ( + add bool + key string + value string + ) + + if strings.HasPrefix(f, "-") { + add = false + f = f[1:] + } else { + add = true + } + + kv := strings.SplitN(f, "=", 2) + key = strings.ToUpper(kv[0]) + if len(kv) > 1 { + value = kv[1] + } + + if !add { + // TODO support ISUPPORT negations + continue + } + + Switch: + switch key { + case "CASEMAPPING": + switch value { + case "ascii": + s.casemap = CasemapASCII + default: + s.casemap = CasemapRFC1459 + } + 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 + } + 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:] + } + } +} diff --git a/irc/states.go b/irc/states.go deleted file mode 100644 index b2a129a..0000000 --- a/irc/states.go +++ /dev/null @@ -1,1197 +0,0 @@ -package irc - -import ( - "bufio" - "bytes" - "encoding/base64" - "errors" - "fmt" - "net" - "strconv" - "strings" - "sync/atomic" - "time" - "unicode/utf8" -) - -const writeDeadline = 10 * time.Second - -type SASLClient interface { - Handshake() (mech string) - Respond(challenge string) (res string, err error) -} - -type SASLPlain struct { - Username string - Password string -} - -func (auth *SASLPlain) Handshake() (mech string) { - mech = "PLAIN" - return -} - -func (auth *SASLPlain) Respond(challenge string) (res string, err error) { - if challenge != "+" { - err = errors.New("unexpected challenge") - return - } - - user := []byte(auth.Username) - pass := []byte(auth.Password) - payload := bytes.Join([][]byte{user, user, pass}, []byte{0}) - res = base64.StdEncoding.EncodeToString(payload) - - return -} - -// SupportedCapabilities is the set of capabilities supported by this library. -var SupportedCapabilities = map[string]struct{}{ - "account-notify": {}, - "account-tag": {}, - "away-notify": {}, - "batch": {}, - "cap-notify": {}, - "draft/chathistory": {}, - "echo-message": {}, - "extended-join": {}, - "invite-notify": {}, - "labeled-response": {}, - "message-tags": {}, - "multi-prefix": {}, - "server-time": {}, - "sasl": {}, - "setname": {}, - "userhost-in-names": {}, -} - -// Values taken by the "@+typing=" client tag. TypingUnspec means the value or -// tag is absent. -const ( - TypingUnspec = iota - TypingActive - TypingPaused - 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. - AwayMsg string // the away message if the user is away, "" otherwise. -} - -// Channel is a joined channel. -type Channel struct { - Name string // the name of the channel. - Members map[*User]string // the set of members associated with their membership. - Topic string // the topic of the channel, or "" if absent. - TopicWho *Prefix // the name of the last user who set the topic. - TopicTime time.Time // the last time the topic has been changed. - Secret bool // whether the channel is on the server channel list. - - complete bool // whether this stucture is fully initialized. -} - -// SessionParams defines how to connect to an IRC server. -type SessionParams struct { - Nickname string - Username string - 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 - registered bool - typings *Typings // incoming typing notifications. - typingStamps map[string]time.Time // user typing instants. - - nick string - nickCf string // casemapped nickname. - user string - real string - acct string - host string - auth SASLClient - - availableCaps map[string]string - enabledCaps map[string]struct{} - - // ISUPPORT features - casemap func(string) string - chantypes string - linelen int - - users map[string]*User // known users. - channels map[string]Channel // joined channels. - chBatches map[string]HistoryEvent // channel history batches being processed. - 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) { - s := &Session{ - conn: conn, - msgs: make(chan Message, 64), - acts: make(chan action, 64), - evts: make(chan Event, 64), - debug: params.Debug, - typings: NewTypings(), - typingStamps: map[string]time.Time{}, - nick: params.Nickname, - nickCf: CasemapASCII(params.Nickname), - user: params.Username, - real: params.RealName, - auth: params.Auth, - availableCaps: map[string]string{}, - enabledCaps: map[string]struct{}{}, - casemap: CasemapRFC1459, - chantypes: "#&", - linelen: 512, - 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) - - 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) -} - -// Stop stops the session and closes the connection. -func (s *Session) Stop() { - if !s.Running() { - 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 -} - -// HasCapability reports whether the given capability has been negociated -// successfully. -func (s *Session) HasCapability(capability string) bool { - _, ok := s.enabledCaps[capability] - return ok -} - -func (s *Session) Nick() string { - return s.nick -} - -// NickCf is our casemapped nickname. -func (s *Session) NickCf() string { - return s.nickCf -} - -func (s *Session) IsChannel(name string) bool { - return strings.IndexAny(name, s.chantypes) == 0 -} - -func (s *Session) Casemap(name string) string { - return s.casemap(name) -} - -// Users returns the list of all known nicknames. -func (s *Session) Users() []string { - users := make([]string, 0, len(s.users)) - for _, u := range s.users { - users = append(users, u.Name.Name) - } - return users -} - -// Names returns the list of users in the given channel, or nil if this channel -// is not known by the session. -func (s *Session) Names(channel string) []Member { - var names []Member - if c, ok := s.channels[s.Casemap(channel)]; ok { - names = make([]Member, 0, len(c.Members)) - for u, pl := range c.Members { - names = append(names, Member{ - PowerLevel: pl, - Name: u.Name.Copy(), - }) - } - } - return names -} - -// 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) - } - } - return res -} - -func (s *Session) ChannelsSharedWith(name string) []string { - var user *User - if u, ok := s.users[s.Casemap(name)]; ok { - user = u - } else { - return nil - } - var channels []string - for _, c := range s.channels { - if _, ok := c.Members[user]; ok { - channels = append(channels, c.Name) - } - } - return channels -} - -func (s *Session) Topic(channel string) (topic string, who *Prefix, at time.Time) { - channelCf := s.Casemap(channel) - if c, ok := s.channels[channelCf]; ok { - topic = c.Topic - who = c.TopicWho - at = c.TopicTime - } - 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 -} - -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 -} - -func (s *Session) Part(channel, reason string) { - s.acts <- actionPart{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) 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.acts <- actionQuit{reason} -} - -func (s *Session) quit(act actionQuit) (err error) { - err = s.send("QUIT :%s\r\n", act.Reason) - return -} - -func splitChunks(s string, chunkLen int) (chunks []string) { - if chunkLen <= 0 { - return []string{s} - } - for chunkLen < len(s) { - i := chunkLen - min := chunkLen - utf8.UTFMax - for min <= i && !utf8.RuneStart(s[i]) { - i-- - } - chunks = append(chunks, s[:i]) - s = s[i:] - } - if len(s) != 0 { - chunks = append(chunks, s) - } - 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") - } - maxMessageLen := s.linelen - - len(":!@ PRIVMSG :\r\n") - - len(s.nick) - - len(s.user) - - hostLen - - len(act.Target) - chunks := splitChunks(act.Content, maxMessageLen) - for _, chunk := range chunks { - err = s.send("PRIVMSG %s :%s\r\n", act.Target, chunk) - if err != nil { - return - } - } - target := s.Casemap(act.Target) - delete(s.typingStamps, target) - return -} - -func (s *Session) Typing(channel string) { - s.acts <- actionTyping{channel} -} - -func (s *Session) typing(act actionTyping) (err error) { - if _, ok := s.enabledCaps["message-tags"]; !ok { - return - } - - to := s.Casemap(act.Channel) - now := time.Now() - - if t, ok := s.typingStamps[to]; 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} -} - -func (s *Session) typingStop(act actionTypingStop) (err error) { - if _, ok := s.enabledCaps["message-tags"]; !ok { - return - } - - err = s.send("@+typing=done TAGMSG %s\r\n", act.Channel) - return -} - -func (s *Session) RequestHistory(target string, before time.Time) { - s.acts <- actionRequestHistory{target, before} -} - -func (s *Session) requestHistory(act actionRequestHistory) (err error) { - if _, ok := s.enabledCaps["draft/chathistory"]; !ok { - return - } - - target := s.Casemap(act.Target) - if _, ok := s.chReqs[target]; ok { - 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) - - return -} - -func (s *Session) run() { - for s.Running() { - var err error - - 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(), - } - } - - if err != nil { - s.evts <- err - } - } -} - -func (s *Session) handleStart(msg Message) (err error) { - switch msg.Command { - case "AUTHENTICATE": - if s.auth != nil { - var res string - - 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 - } - } - case rplLoggedin: - err = s.send("CAP END\r\n") - if err != nil { - return - } - - 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 - } - case "CAP": - switch msg.Params[1] { - case "LS": - var willContinue bool - var ls string - - if msg.Params[2] == "*" { - willContinue = true - ls = msg.Params[3] - } else { - willContinue = false - ls = msg.Params[2] - } - - for _, c := range ParseCaps(ls) { - if c.Enable { - s.availableCaps[c.Name] = c.Value - } else { - delete(s.availableCaps, c.Name) - } - } - - 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) - } - - _, 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 - } - } - default: - s.handle(msg) - } - case errNicknameinuse: - err = s.send("NICK %s_\r\n", msg.Params[1]) - if err != nil { - return - } - default: - err = s.handle(msg) - } - - return -} - -func (s *Session) handle(msg Message) (err error) { - if id, ok := msg.Tags["batch"]; ok { - if b, ok := s.chBatches[id]; ok { - s.chBatches[id] = HistoryEvent{ - Target: b.Target, - Messages: append(b.Messages, s.privmsgToEvent(msg)), - } - return - } - } - - switch msg.Command { - case rplWelcome: - s.nick = msg.Params[0] - s.nickCf = s.Casemap(s.nick) - s.registered = true - 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 - } - } - case rplIsupport: - s.updateFeatures(msg.Params[1 : len(msg.Params)-1]) - case rplWhoreply: - if s.nickCf == s.Casemap(msg.Params[5]) { - s.host = msg.Params[3] - } - case "CAP": - switch msg.Params[1] { - case "ACK": - for _, c := range strings.Split(msg.Params[2], " ") { - s.enabledCaps[c] = struct{}{} - - if s.auth != nil && c == "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" { - // 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 - } - } - } - case "NAK": - for _, c := range strings.Split(msg.Params[2], " ") { - delete(s.enabledCaps, c) - } - 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 { - _, 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 - } - 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 - } - } - case "JOIN": - nickCf := s.Casemap(msg.Prefix.Name) - channelCf := s.Casemap(msg.Params[0]) - - if nickCf == s.nickCf { - s.channels[channelCf] = Channel{ - Name: msg.Params[0], - Members: map[*User]string{}, - } - } else if c, ok := s.channels[channelCf]; ok { - if _, ok := s.users[nickCf]; !ok { - s.users[nickCf] = &User{Name: msg.Prefix.Copy()} - } - c.Members[s.users[nickCf]] = "" - t := msg.TimeOrNow() - - s.evts <- UserJoinEvent{ - User: msg.Prefix.Copy(), - Channel: c.Name, - Time: t, - } - } - case "PART": - nickCf := s.Casemap(msg.Prefix.Name) - channelCf := s.Casemap(msg.Params[0]) - - if nickCf == s.nickCf { - 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} - } - } 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(), - Channel: c.Name, - Time: msg.TimeOrNow(), - } - } - } - case "KICK": - channelCf := s.Casemap(msg.Params[0]) - nickCf := s.Casemap(msg.Params[1]) - - if nickCf == s.nickCf { - 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} - } - } 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(), - Channel: c.Name, - Time: msg.TimeOrNow(), - } - } - } - case "QUIT": - nickCf := s.Casemap(msg.Prefix.Name) - - if u, ok := s.users[nickCf]; ok { - var channels []string - for channelCf, c := range s.channels { - if _, ok := c.Members[u]; ok { - channels = append(channels, c.Name) - delete(c.Members, u) - s.cleanUser(u) - s.typings.Done(channelCf, nickCf) - } - } - - s.evts <- UserQuitEvent{ - User: msg.Prefix.Copy(), - Channels: channels, - Time: msg.TimeOrNow(), - } - } - case rplNamreply: - channelCf := s.Casemap(msg.Params[2]) - - if c, ok := s.channels[channelCf]; ok { - c.Secret = msg.Params[1] == "@" - - // TODO compute CHANTYPES - for _, name := range ParseNameReply(msg.Params[3], "~&@%+") { - nickCf := s.Casemap(name.Name.Name) - - if _, ok := s.users[nickCf]; !ok { - s.users[nickCf] = &User{Name: name.Name.Copy()} - } - c.Members[s.users[nickCf]] = name.PowerLevel - } - - s.channels[channelCf] = c - } - case rplEndofnames: - channelCf := s.Casemap(msg.Params[1]) - if c, ok := s.channels[channelCf]; ok && !c.complete { - c.complete = true - s.channels[channelCf] = c - s.evts <- SelfJoinEvent{Channel: c.Name} - } - case rplTopic: - channelCf := s.Casemap(msg.Params[1]) - if c, ok := s.channels[channelCf]; ok { - c.Topic = msg.Params[2] - s.channels[channelCf] = c - } - case rplTopicwhotime: - channelCf := s.Casemap(msg.Params[1]) - t, _ := strconv.ParseInt(msg.Params[3], 10, 64) - if c, ok := s.channels[channelCf]; ok { - c.TopicWho = ParsePrefix(msg.Params[2]) - c.TopicTime = time.Unix(t, 0) - s.channels[channelCf] = c - } - case rplNotopic: - channelCf := s.Casemap(msg.Params[1]) - if c, ok := s.channels[channelCf]; ok { - c.Topic = "" - s.channels[channelCf] = c - } - case "TOPIC": - channelCf := s.Casemap(msg.Params[0]) - if c, ok := s.channels[channelCf]; ok { - c.Topic = msg.Params[1] - c.TopicWho = msg.Prefix.Copy() - c.TopicTime = msg.TimeOrNow() - s.channels[channelCf] = c - s.evts <- TopicChangeEvent{ - User: msg.Prefix.Copy(), - Channel: c.Name, - Topic: c.Topic, - Time: c.TopicTime, - } - } - case "PRIVMSG", "NOTICE": - s.evts <- s.privmsgToEvent(msg) - case "TAGMSG": - nickCf := s.Casemap(msg.Prefix.Name) - targetCf := s.Casemap(msg.Params[0]) - - if nickCf == s.nickCf { - // 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) - } 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:] - - 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)) - } - 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, - } - s.nick = newNick - s.nickCf = newNickCf - } else { - s.evts <- UserNickEvent{ - User: u, - FormerNick: msg.Prefix.Name, - Time: t, - } - } - case "FAIL": - s.evts <- ErrorEvent{ - Severity: SeverityFail, - Code: msg.Params[1], - Message: msg.Params[len(msg.Params)-1], - } - case "WARN": - s.evts <- ErrorEvent{ - Severity: SeverityWarn, - Code: msg.Params[1], - Message: msg.Params[len(msg.Params)-1], - } - case "NOTE": - s.evts <- 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]) - } - _ = s.conn.Close() - default: - // reply handling - if ReplySeverity(msg.Command) == SeverityFail { - s.evts <- ErrorEvent{ - Severity: SeverityFail, - Code: msg.Command, - Message: msg.Params[len(msg.Params)-1], - } - } - } - - return -} - -func (s *Session) privmsgToEvent(msg Message) (ev 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 - Command: msg.Command, - Content: msg.Params[1], - Time: msg.TimeOrNow(), - } - if c, ok := s.channels[targetCf]; ok { - ev.Target = c.Name - ev.TargetIsChannel = true - } - - return -} - -func (s *Session) cleanUser(parted *User) { - for _, c := range s.channels { - if _, ok := c.Members[parted]; ok { - return - } - } - delete(s.users, s.Casemap(parted.Name.Name)) -} - -func (s *Session) updateFeatures(features []string) { - for _, f := range features { - if f == "" || f == "-" || f == "=" || f == "-=" { - continue - } - - var ( - add bool - key string - value string - ) - - if strings.HasPrefix(f, "-") { - add = false - f = f[1:] - } else { - add = true - } - - kv := strings.SplitN(f, "=", 2) - key = strings.ToUpper(kv[0]) - if len(kv) > 1 { - value = kv[1] - } - - if !add { - // TODO support ISUPPORT negations - continue - } - - switch key { - case "CASEMAPPING": - switch value { - case "ascii": - s.casemap = CasemapASCII - default: - s.casemap = CasemapRFC1459 - } - case "CHANTYPES": - s.chantypes = value - 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, - } - } - } - } - - 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 +} -- cgit v1.2.3