diff options
Diffstat (limited to 'irc/session.go')
-rw-r--r-- | irc/session.go | 878 |
1 files changed, 878 insertions, 0 deletions
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:] + } + } +} |