package irc
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"golang.org/x/time/rate"
)
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 structure 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]typingStamp // 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.
pendingChannels map[string]time.Time // set of join requests stamps for channels.
}
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,
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{}{},
pendingChannels: map[string]time.Time{},
}
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 negotiated
// 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.
// The list is sorted according to member name.
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(),
})
}
}
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 {
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) 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()
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")
}
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) 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 (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
ev := SelfJoinEvent{
Channel: c.Name,
Topic: c.Topic,
}
if stamp, ok := s.pendingChannels[channelCf]; ok && time.Now().Sub(stamp) < 5*time.Second {
ev.Requested = true
}
return ev
}
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 "MODE":
channelCf := s.Casemap(msg.Params[0])
if c, ok := s.channels[channelCf]; ok {
return ModeChangeEvent{
Channel: c.Name,
Mode: strings.Join(msg.Params[1:], " "),
}
}
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:]
}
}
}