package irc
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"golang.org/x/time/rate"
)
type SASLClient interface {
Early() bool
Handshake() (mech string)
Respond(challenge string) (res string, err error)
}
type SASLPlain struct {
Username string
Password string
}
func (auth *SASLPlain) Early() bool {
return true
}
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{}{
"away-notify": {},
"batch": {},
"cap-notify": {},
"echo-message": {},
"extended-monitor": {},
"invite-notify": {},
"message-tags": {},
"multi-prefix": {},
"server-time": {},
"sasl": {},
"setname": {},
"draft/chathistory": {},
"draft/event-playback": {},
"draft/read-marker": {},
"soju.im/bouncer-networks-notify": {},
"soju.im/bouncer-networks": {},
"soju.im/search": {},
}
// 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.
type User struct {
Name *Prefix // the nick, user and hostname of the user if known.
Away bool // whether the user is away or not
Disconnected bool // can only be true for monitored users.
}
// 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.
complete bool // whether this structure is fully initialized.
}
// SessionParams defines how to connect to an IRC server.
type SessionParams struct {
Nickname string
Username string
RealName string
NetID string
Auth SASLClient
}
type Session struct {
out chan<- Message
closed bool
registered bool
typings *Typings // incoming typing notifications.
typingStamps map[string]typingStamp // user typing instants.
nick string
nickCf string // casemapped nickname.
user string
real string
acct string
host string
netID string
auth SASLClient
availableCaps map[string]string
enabledCaps map[string]struct{}
// ISUPPORT features
casemap func(string) string
chanmodes [4]string
chantypes string
linelen int
historyLimit int
prefixSymbols string
prefixModes string
monitor bool
whox bool
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.
targetsBatchID string // ID of the channel history targets batch being processed.
targetsBatch HistoryTargetsEvent // channel history targets batch being processed.
searchBatchID string // ID of the search targets batch being processed.
searchBatch SearchEvent // search batch being processed.
monitors map[string]struct{} // set of users we want to monitor (and keep even if they are disconnected).
pendingChannels map[string]time.Time // set of join requests stamps for channels.
receivedISupport bool
receivedUserMode bool
}
func NewSession(out chan<- Message, params SessionParams) *Session {
s := &Session{
out: out,
typings: NewTypings(),
typingStamps: map[string]typingStamp{},
nick: params.Nickname,
nickCf: CasemapASCII(params.Nickname),
user: params.Username,
real: params.RealName,
netID: params.NetID,
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{}{},
monitors: map[string]struct{}{},
pendingChannels: map[string]time.Time{},
}
s.out <- NewMessage("CAP", "LS", "302")
for capability := range SupportedCapabilities {
s.out <- NewMessage("CAP", "REQ", capability)
}
s.out <- NewMessage("NICK", s.nick)
s.out <- NewMessage("USER", s.user, "0", "*", s.real)
if s.auth != nil && s.auth.Early() {
h := s.auth.Handshake()
s.out <- NewMessage("AUTHENTICATE", h)
res, err := s.auth.Respond("+")
if err != nil {
s.out <- NewMessage("AUTHENTICATE", "*")
} else {
s.out <- NewMessage("AUTHENTICATE", res)
}
s.auth = nil
}
if s.auth == nil {
s.endRegistration()
}
return s
}
func (s *Session) Close() {
if s.closed {
return
}
s.closed = true
s.typings.Close()
close(s.out)
}
// HasCapability reports whether the given capability has been negotiated
// successfully.
func (s *Session) HasCapability(capability string) bool {
_, ok := s.enabledCaps[capability]
return ok
}
func (s *Session) Nick() string {
return s.nick
}
func (s *Session) NetID() string {
return s.netID
}
// 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 target, or nil if the target
// is not a known channel or nick in the session.
// The list is sorted according to member name.
func (s *Session) Names(target string) []Member {
var names []Member
if s.IsChannel(target) {
if c, ok := s.channels[s.Casemap(target)]; ok {
names = make([]Member, 0, len(c.Members))
for u, pl := range c.Members {
names = append(names, Member{
PowerLevel: pl,
Name: u.Name.Copy(),
Away: u.Away,
Disconnected: u.Disconnected,
})
}
}
} else if u, ok := s.users[s.Casemap(target)]; ok {
names = append(names, Member{
Name: u.Name.Copy(),
Away: u.Away,
Disconnected: u.Disconnected,
})
names = append(names, Member{
Name: &Prefix{
Name: s.nick,
},
})
}
sort.Sort(members(names))
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
}
}
sort.Strings(res)
return res
}
func (s *Session) TypingStops() <-chan Typing {
return s.typings.Stops()
}
func (s *Session) ChannelsSharedWith(name string) []string {
var user *User
if u, ok := s.users[s.Casemap(name)]; ok && !u.Disconnected {
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, key string) {
channelCf := s.Casemap(channel)
s.pendingChannels[channelCf] = time.Now()
if key == "" {
s.out <- NewMessage("JOIN", channel)
} else {
s.out <- NewMessage("JOIN", channel, key)
}
}
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) Oper(username string, password string) {
s.out <- NewMessage("OPER", username, password)
}
func (s *Session) MOTD() {
s.out <- NewMessage("MOTD")
}
func (s *Session) Who(target string) {
if s.whox {
// only request what we need, to optimize server who cache hits and reduce traffic
s.out <- NewMessage("WHO", target, "%uhnf")
} else {
s.out <- NewMessage("WHO", target)
}
}
func (s *Session) ChangeMode(channel, flags string, args []string) {
if flags != "" {
args = append([]string{channel, flags}, args...)
} else {
args = append([]string{channel}, args...)
}
s.out <- NewMessage("MODE", args...)
}
func (s *Session) Search(target, text string) {
if _, ok := s.enabledCaps["soju.im/search"]; !ok {
return
}
attrs := make(map[string]string)
attrs["text"] = text
if target != "" {
attrs["in"] = target
}
s.out <- NewMessage("SEARCH", formatTags(attrs))
}
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()
t, ok := s.typingStamps[targetCf]
if ok && ((t.Type == TypingActive && now.Sub(t.Last).Seconds() < 3.0) || !t.Limit.Allow()) {
return
}
if !ok {
t.Limit = rate.NewLimiter(rate.Limit(1.0/3.0), 5)
t.Limit.Reserve() // will always be OK
}
s.typingStamps[targetCf] = typingStamp{
Last: now,
Type: TypingActive,
Limit: t.Limit,
}
s.out <- NewMessage("TAGMSG", target).WithTag("+typing", "active")
}
func (s *Session) TypingStop(target string) {
if !s.HasCapability("message-tags") {
return
}
targetCf := s.casemap(target)
now := time.Now()
t, ok := s.typingStamps[targetCf]
if ok && (t.Type == TypingDone || !t.Limit.Allow()) {
// don't send a +typing=done again if the last typing we sent was a +typing=done
return
}
if !ok {
t.Limit = rate.NewLimiter(rate.Limit(1), 5)
t.Limit.Reserve() // will always be OK
}
s.typingStamps[targetCf] = typingStamp{
Last: now,
Type: TypingDone,
Limit: t.Limit,
}
s.out <- NewMessage("TAGMSG", target).WithTag("+typing", "done")
}
func (s *Session) ReadGet(target string) {
if _, ok := s.enabledCaps["draft/read-marker"]; ok {
s.out <- NewMessage("MARKREAD", target)
}
}
func (s *Session) ReadSet(target string, timestamp time.Time) {
if _, ok := s.enabledCaps["draft/read-marker"]; ok {
s.out <- NewMessage("MARKREAD", target, formatTimestamp(timestamp))
}
}
func (s *Session) MonitorAdd(target string) {
targetCf := s.casemap(target)
if _, ok := s.monitors[targetCf]; !ok {
s.monitors[targetCf] = struct{}{}
if s.monitor {
s.out <- NewMessage("MONITOR", "+", target)
}
}
}
func (s *Session) MonitorRemove(target string) {
targetCf := s.casemap(target)
if _, ok := s.monitors[targetCf]; ok {
delete(s.monitors, targetCf)
if s.monitor {
s.out <- NewMessage("MONITOR", "-", target)
}
}
}
type HistoryRequest struct {
s *Session
target string
command string
bounds []string
limit int
}
func formatTimestamp(t time.Time) string {
t = t.UTC()
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)
if r.target != "" {
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) After(t time.Time) {
r.command = "AFTER"
r.bounds = []string{formatTimestamp(t)}
r.doRequest()
}
func (r *HistoryRequest) Before(t time.Time) {
r.command = "BEFORE"
r.bounds = []string{formatTimestamp(t)}
r.doRequest()
}
func (r *HistoryRequest) Targets(start time.Time, end time.Time) {
r.command = "TARGETS"
r.bounds = []string{formatTimestamp(start), formatTimestamp(end)}
r.target = ""
r.doRequest()
}
func (s *Session) NewHistoryRequest(target string) *HistoryRequest {
return &HistoryRequest{
s: s,
target: target,
limit: s.historyLimit,
}
}
func (s *Session) Whois(nick string) {
s.out <- NewMessage("WHOIS", nick)
}
func (s *Session) Invite(nick, channel string) {
s.out <- NewMessage("INVITE", nick, channel)
}
func (s *Session) Kick(nick, channel, comment string) {
if comment == "" {
s.out <- NewMessage("KICK", channel, nick)
} else {
s.out <- NewMessage("KICK", channel, nick, comment)
}
}
func (s *Session) HandleMessage(msg Message) (Event, error) {
if s.registered {
return s.handleRegistered(msg)
} else {
return s.handleUnregistered(msg)
}
}
func (s *Session) handleUnregistered(msg Message) (Event, error) {
switch msg.Command {
case errNicknameinuse:
var nick string
if err := msg.ParseParams(nil, &nick); err != nil {
return nil, err
}
s.out <- NewMessage("NICK", nick+"_")
case rplSaslsuccess:
if s.auth != nil {
s.endRegistration()
}
default:
return s.handleRegistered(msg)
}
return nil, nil
}
func (s *Session) handleRegistered(msg Message) (Event, error) {
if id, ok := msg.Tags["batch"]; ok {
if id == s.targetsBatchID {
var target, timestamp string
if err := msg.ParseParams(nil, &target, ×tamp); err != nil {
return nil, err
}
t, ok := parseTimestamp(timestamp)
if !ok {
return nil, nil
}
s.targetsBatch.Targets[target] = t
} else if id == s.searchBatchID {
ev, err := s.handleMessageRegistered(msg, true)
if err != nil {
return nil, err
}
if ev, ok := ev.(MessageEvent); ok {
s.searchBatch.Messages = append(s.searchBatch.Messages, ev)
return nil, nil
}
} else if b, ok := s.chBatches[id]; ok {
ev, err := s.handleMessageRegistered(msg, true)
if err != nil {
return nil, err
}
if ev != nil {
s.chBatches[id] = HistoryEvent{
Target: b.Target,
Messages: append(b.Messages, ev),
}
}
return nil, nil
}
}
return s.handleMessageRegistered(msg, false)
}
func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, error) {
switch msg.Command {
case "AUTHENTICATE":
if s.auth == nil {
break
}
var payload string
if err := msg.ParseParams(&payload); err != nil {
return nil, err
}
res, err := s.auth.Respond(payload)
if err != nil {
s.out <- NewMessage("AUTHENTICATE", "*")
} else {
s.out <- NewMessage("AUTHENTICATE", res)
}
case rplLoggedin:
var nuh string
if err := msg.ParseParams(nil, &nuh, &s.acct); err != nil {
return nil, err
}
prefix := ParsePrefix(nuh)
s.user = prefix.User
s.host = prefix.Host
case errNicklocked, errSaslfail, errSasltoolong, errSaslaborted, errSaslalready, rplSaslmechs:
if s.auth != nil {
s.endRegistration()
}
return ErrorEvent{
Severity: SeverityFail,
Code: msg.Command,
Message: fmt.Sprintf("Registration failed: %s", strings.Join(msg.Params[1:], " ")),
}, nil
case rplWelcome:
if err := msg.ParseParams(&s.nick); err != nil {
return nil, err
}
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.Who(s.nick)
}
case rplIsupport:
if len(msg.Params) < 3 {
return nil, msg.errNotEnoughParams(3)
}
s.updateFeatures(msg.Params[1 : len(msg.Params)-1])
if !s.receivedISupport {
// notify only on first RPL_ISUPPORT
s.receivedISupport = true
return RegisteredEvent{}, nil
}
return nil, nil
case rplWhoreply, rplWhospecialreply:
var nick, host, flags, username string
var err error
if msg.Command == rplWhoreply {
err = msg.ParseParams(nil, nil, &username, &host, nil, &nick, &flags, nil)
} else {
// we always request WHOX with %uhnf
err = msg.ParseParams(nil, &username, &host, &nick, &flags)
}
if err != nil {
return nil, err
}
nickCf := s.Casemap(nick)
away := strings.ContainsRune(flags, 'G')
if s.nickCf == nickCf {
s.user = username
s.host = host
}
if u, ok := s.users[nickCf]; ok {
u.Away = away
}
case rplEndofwho:
// do nothing
case "CAP":
var subcommand, caps string
if err := msg.ParseParams(nil, &subcommand, &caps); err != nil {
return nil, err
}
switch subcommand {
case "ACK":
for _, c := range ParseCaps(caps) {
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(caps) {
s.availableCaps[c.Name] = c.Value
if _, ok := SupportedCapabilities[c.Name]; !ok {
continue
}
if _, ok := s.enabledCaps[c.Name]; ok {
continue
}
s.out <- NewMessage("CAP", "REQ", c.Name)
}
case "DEL":
for _, c := range ParseCaps(caps) {
delete(s.availableCaps, c.Name)
delete(s.enabledCaps, c.Name)
}
}
case "JOIN":
if msg.Prefix == nil {
return nil, errMissingPrefix
}
var channel string
if err := msg.ParseParams(&channel); err != nil {
return nil, err
}
if playback {
return UserJoinEvent{
User: msg.Prefix.Name,
Channel: channel,
Time: msg.TimeOrNow(),
}, nil
}
nickCf := s.Casemap(msg.Prefix.Name)
channelCf := s.Casemap(channel)
if s.IsMe(nickCf) {
s.channels[channelCf] = Channel{
Name: msg.Params[0],
Members: map[*User]string{},
}
if _, ok := s.enabledCaps["away-notify"]; ok {
// Only try to know who is away if the list is
// updated by the server via away-notify.
// Otherwise, it'll become outdated over time.
s.Who(channel)
}
} 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,
Time: msg.TimeOrNow(),
}, nil
}
case "PART":
if msg.Prefix == nil {
return nil, errMissingPrefix
}
var channel string
if err := msg.ParseParams(&channel); err != nil {
return nil, err
}
if playback {
return UserPartEvent{
User: msg.Prefix.Name,
Channel: channel,
Time: msg.TimeOrNow(),
}, nil
}
nickCf := s.Casemap(msg.Prefix.Name)
channelCf := s.Casemap(channel)
if s.IsMe(nickCf) {
if c, ok := s.channels[channelCf]; ok {
delete(s.channels, channelCf)
for u := range c.Members {
s.cleanUser(u)
}
return SelfPartEvent{
Channel: c.Name,
}, nil
}
} 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,
Time: msg.TimeOrNow(),
}, nil
}
}
case "KICK":
var channel, nick string
if err := msg.ParseParams(&channel, &nick); err != nil {
return nil, err
}
if playback {
return UserPartEvent{
User: nick,
Channel: channel,
Time: msg.TimeOrNow(),
}, nil
}
nickCf := s.Casemap(nick)
channelCf := s.Casemap(channel)
if s.IsMe(nickCf) {
if c, ok := s.channels[channelCf]; ok {
delete(s.channels, channelCf)
for u := range c.Members {
s.cleanUser(u)
}
return SelfPartEvent{
Channel: c.Name,
}, nil
}
} 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: nick,
Channel: c.Name,
Time: msg.TimeOrNow(),
}, nil
}
}
case "QUIT":
if msg.Prefix == nil {
return nil, errMissingPrefix
}
if playback {
return UserQuitEvent{
User: msg.Prefix.Name,
Time: msg.TimeOrNow(),
}, nil
}
nickCf := s.Casemap(msg.Prefix.Name)
if u, ok := s.users[nickCf]; ok {
u.Disconnected = true
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,
Time: msg.TimeOrNow(),
}, nil
}
case rplMononline:
for _, target := range strings.Split(msg.Params[1], ",") {
prefix := ParsePrefix(target)
if prefix == nil {
continue
}
nickCf := s.casemap(prefix.Name)
if _, ok := s.monitors[nickCf]; ok {
u, ok := s.users[nickCf]
if !ok {
u = &User{
Name: prefix,
}
s.users[nickCf] = u
}
if u.Disconnected {
u.Disconnected = false
return UserOnlineEvent{
User: u.Name.Name,
}, nil
}
}
}
case rplMonoffline:
for _, target := range strings.Split(msg.Params[1], ",") {
prefix := ParsePrefix(target)
if prefix == nil {
continue
}
nickCf := s.casemap(prefix.Name)
if _, ok := s.monitors[nickCf]; ok {
u, ok := s.users[nickCf]
if !ok {
u = &User{
Name: prefix,
}
s.users[nickCf] = u
}
if !u.Disconnected {
u.Disconnected = true
return UserOfflineEvent{
User: u.Name.Name,
}, nil
}
}
}
case rplNamreply:
var channel, names string
if err := msg.ParseParams(nil, nil, &channel, &names); err != nil {
return nil, err
}
channelCf := s.Casemap(channel)
if c, ok := s.channels[channelCf]; ok {
for _, name := range ParseNameReply(names, 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:
var channel string
if err := msg.ParseParams(nil, &channel); err != nil {
return nil, err
}
channelCf := s.Casemap(channel)
if c, ok := s.channels[channelCf]; ok && !c.complete {
c.complete = true
s.channels[channelCf] = c
ev := SelfJoinEvent{
Channel: c.Name,
Topic: c.Topic,
}
if stamp, ok := s.pendingChannels[channelCf]; ok && time.Since(stamp) < 5*time.Second {
ev.Requested = true
}
return ev, nil
}
case rplTopic:
var channel, topic string
if err := msg.ParseParams(nil, &channel, &topic); err != nil {
return nil, err
}
channelCf := s.Casemap(channel)
if c, ok := s.channels[channelCf]; ok {
c.Topic = topic
s.channels[channelCf] = c
}
case rplTopicwhotime:
var channel, topicWho, topicTime string
if err := msg.ParseParams(nil, &channel, &topicWho, &topicTime); err != nil {
return nil, err
}
channelCf := s.Casemap(channel)
// ignore the error, we still have topicWho
t, _ := strconv.ParseInt(topicTime, 10, 64)
if c, ok := s.channels[channelCf]; ok {
c.TopicWho = ParsePrefix(topicWho)
c.TopicTime = time.Unix(t, 0)
s.channels[channelCf] = c
}
case rplNotopic:
var channel string
if err := msg.ParseParams(nil, &channel); err != nil {
return nil, err
}
channelCf := s.Casemap(channel)
if c, ok := s.channels[channelCf]; ok {
c.Topic = ""
s.channels[channelCf] = c
}
case "TOPIC":
if msg.Prefix == nil {
return nil, errMissingPrefix
}
var channel, topic string
if err := msg.ParseParams(&channel, &topic); err != nil {
return nil, err
}
if playback {
return TopicChangeEvent{
Channel: channel,
Topic: topic,
Time: msg.TimeOrNow(),
}, nil
}
channelCf := s.Casemap(channel)
if c, ok := s.channels[channelCf]; ok {
c.Topic = topic
c.TopicWho = msg.Prefix.Copy()
c.TopicTime = msg.TimeOrNow()
s.channels[channelCf] = c
return TopicChangeEvent{
Channel: c.Name,
Topic: c.Topic,
Time: msg.TimeOrNow(),
}, nil
}
case "MODE":
var channel string
if err := msg.ParseParams(&channel, nil); err != nil {
return nil, err
}
mode := strings.Join(msg.Params[1:], " ")
if playback {
return ModeChangeEvent{
Channel: channel,
Mode: mode,
Time: msg.TimeOrNow(),
}, nil
}
channelCf := s.Casemap(channel)
if c, ok := s.channels[channelCf]; ok {
modeChanges, err := ParseChannelMode(msg.Params[1], msg.Params[2:], s.chanmodes, s.prefixModes)
if err != nil {
return nil, err
}
for _, change := range modeChanges {
i := strings.IndexByte(s.prefixModes, change.Mode)
if i < 0 {
continue
}
nickCf := s.Casemap(change.Param)
user := s.users[nickCf]
membership, ok := c.Members[user]
if !ok {
continue
}
var newMembership []byte
if change.Enable {
newMembership = append([]byte(membership), s.prefixSymbols[i])
sort.Slice(newMembership, func(i, j int) bool {
i = strings.IndexByte(s.prefixSymbols, newMembership[i])
j = strings.IndexByte(s.prefixSymbols, newMembership[j])
return i < j
})
} else if j := strings.IndexByte(membership, s.prefixSymbols[i]); j >= 0 {
newMembership = []byte(membership)
newMembership = append(newMembership[:j], newMembership[j+1:]...)
}
c.Members[user] = string(newMembership)
}
s.channels[channelCf] = c
return ModeChangeEvent{
Channel: c.Name,
Mode: mode,
Time: msg.TimeOrNow(),
}, nil
}
case "INVITE":
if msg.Prefix == nil {
return nil, errMissingPrefix
}
var nick, channel string
if err := msg.ParseParams(&nick, &channel); err != nil {
return nil, err
}
return InviteEvent{
Inviter: msg.Prefix.Name,
Invitee: nick,
Channel: channel,
}, nil
case rplInviting:
var nick, channel string
if err := msg.ParseParams(nil, &nick, &channel); err != nil {
return nil, err
}
return InviteEvent{
Inviter: s.nick,
Invitee: nick,
Channel: channel,
}, nil
case "AWAY":
if msg.Prefix == nil {
return nil, errMissingPrefix
}
nickCf := s.Casemap(msg.Prefix.Name)
if u, ok := s.users[nickCf]; ok {
u.Away = len(msg.Params) == 1
}
case "PRIVMSG", "NOTICE":
if msg.Prefix == nil {
return nil, errMissingPrefix
}
var target string
if err := msg.ParseParams(&target); err != nil {
return nil, err
}
if playback {
return s.newMessageEvent(msg)
}
targetCf := s.casemap(target)
nickCf := s.casemap(msg.Prefix.Name)
s.typings.Done(targetCf, nickCf)
return s.newMessageEvent(msg)
case "TAGMSG":
if playback {
return nil, nil
}
if msg.Prefix == nil {
return nil, errMissingPrefix
}
var target string
if err := msg.ParseParams(&target); err != nil {
return nil, err
}
targetCf := s.casemap(target)
nickCf := s.casemap(msg.Prefix.Name)
if s.IsMe(msg.Prefix.Name) {
// TAGMSG from self
break
}
if t, ok := msg.Tags["+typing"]; ok {
switch t {
case "active":
s.typings.Active(targetCf, nickCf)
case "paused", "done":
s.typings.Done(targetCf, nickCf)
}
}
case "BATCH":
var id string
if err := msg.ParseParams(&id); err != nil {
return nil, err
}
if len(id) == 0 {
return nil, fmt.Errorf("empty batch id")
}
batchStart := id[0] == '+'
id = id[1:]
if batchStart {
var name string
if err := msg.ParseParams(nil, &name); err != nil {
return nil, err
}
switch name {
case "chathistory":
var target string
if err := msg.ParseParams(nil, nil, &target); err != nil {
return nil, err
}
s.chBatches[id] = HistoryEvent{Target: target}
case "draft/chathistory-targets":
s.targetsBatchID = id
s.targetsBatch = HistoryTargetsEvent{Targets: make(map[string]time.Time)}
case "soju.im/search":
s.searchBatchID = id
s.searchBatch = SearchEvent{}
}
} else {
if b, ok := s.chBatches[id]; ok {
delete(s.chBatches, id)
delete(s.chReqs, s.Casemap(b.Target))
return b, nil
} else if s.targetsBatchID == id {
s.targetsBatchID = ""
delete(s.chReqs, "")
return s.targetsBatch, nil
} else if s.searchBatchID == id {
s.searchBatchID = ""
return s.searchBatch, nil
}
}
case "NICK":
if msg.Prefix == nil {
return nil, errMissingPrefix
}
var nick string
if err := msg.ParseParams(&nick); err != nil {
return nil, err
}
if playback {
return UserNickEvent{
User: nick,
FormerNick: msg.Prefix.Name,
Time: msg.TimeOrNow(),
}, nil
}
nickCf := s.Casemap(msg.Prefix.Name)
newNick := nick
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,
}, nil
} else {
return UserNickEvent{
User: nick,
FormerNick: msg.Prefix.Name,
Time: msg.TimeOrNow(),
}, nil
}
case "MARKREAD":
if len(msg.Params) < 2 {
break
}
var target, timestamp string
if err := msg.ParseParams(&target, ×tamp); err != nil {
return nil, err
}
if !strings.HasPrefix(timestamp, "timestamp=") {
return nil, nil
}
timestamp = strings.TrimPrefix(timestamp, "timestamp=")
t, ok := parseTimestamp(timestamp)
if !ok {
return nil, nil
}
return ReadEvent{
Target: target,
Timestamp: t,
}, nil
case "BOUNCER":
if len(msg.Params) < 3 {
break
}
if msg.Params[0] != "NETWORK" || s.netID != "" {
break
}
id := msg.Params[1]
event := BouncerNetworkEvent{
ID: id,
}
if msg.Params[2] != "*" {
attrs := parseTags(msg.Params[2])
event.Name = attrs["name"]
} else {
event.Delete = true
}
return event, nil
case "PING":
var payload string
if err := msg.ParseParams(&payload); err != nil {
return nil, err
}
s.out <- NewMessage("PONG", payload)
case "ERROR":
s.Close()
case "FAIL", "WARN", "NOTE":
var severity Severity
var code string
if err := msg.ParseParams(nil, &code); err != nil {
return nil, err
}
switch msg.Command {
case "FAIL":
severity = SeverityFail
case "WARN":
severity = SeverityWarn
case "NOTE":
severity = SeverityNote
}
return ErrorEvent{
Severity: severity,
Code: code,
Message: strings.Join(msg.Params[2:], " "),
}, nil
case errMonlistisfull:
// silence monlist full error, we don't care because we do it best-effort
case rplAway:
// we display user away status, we don't care about automatic AWAY replies
default:
if msg.IsReply() {
if len(msg.Params) < 2 {
return nil, msg.errNotEnoughParams(2)
}
if msg.Command == rplUmodeis && !s.receivedUserMode {
// ignore the first RPL_UMODEIS on join
s.receivedUserMode = true
return nil, nil
}
if msg.Command == errUnknowncommand && msg.Params[1] == "BOUNCER" {
// ignore any error in response to unconditional BOUNCER LISTNETWORKS
return nil, nil
}
return ErrorEvent{
Severity: ReplySeverity(msg.Command),
Code: msg.Command,
Message: strings.Join(msg.Params[1:], " "),
}, nil
}
}
return nil, nil
}
func (s *Session) newMessageEvent(msg Message) (ev MessageEvent, err error) {
if msg.Prefix == nil {
return ev, errMissingPrefix
}
var target, content string
if err := msg.ParseParams(&target, &content); err != nil {
return ev, err
}
ev = MessageEvent{
User: msg.Prefix.Name, // TODO correctly casemap
Target: target, // TODO correctly casemap
Command: msg.Command,
Content: content,
Time: msg.TimeOrNow(),
}
if s.IsMe(target) {
if context := msg.Tags["+draft/channel-context"]; context != "" {
target = context
}
}
targetCf := s.Casemap(target)
if c, ok := s.channels[targetCf]; ok {
ev.Target = c.Name
ev.TargetIsChannel = true
}
return ev, nil
}
func (s *Session) cleanUser(parted *User) {
nameCf := s.Casemap(parted.Name.Name)
if _, ok := s.monitors[nameCf]; ok {
return
}
for _, c := range s.channels {
if _, ok := c.Members[parted]; ok {
return
}
}
delete(s.users, nameCf)
}
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 "BOUNCER_NETID":
s.netID = value
case "CASEMAPPING":
switch value {
case "ascii":
s.casemap = CasemapASCII
default:
s.casemap = CasemapRFC1459
}
case "CHANMODES":
// We only care about the first four params
types := strings.SplitN(value, ",", 5)
for i := 0; i < len(types) && i < len(s.chanmodes); i++ {
s.chanmodes[i] = types[i]
}
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 "MONITOR":
monitor, err := strconv.Atoi(value)
if err == nil && monitor > 0 {
s.monitor = true
}
case "PREFIX":
if value == "" {
s.prefixModes = ""
s.prefixSymbols = ""
break Switch
}
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:]
case "WHOX":
s.whox = true
}
}
}
func (s *Session) endRegistration() {
if s.registered {
return
}
if s.netID != "" {
s.out <- NewMessage("BOUNCER", "BIND", s.netID)
s.out <- NewMessage("CAP", "END")
} else {
s.out <- NewMessage("CAP", "END")
s.out <- NewMessage("BOUNCER", "LISTNETWORKS")
}
}