2d0ed40435
At the moment it just hangs if the server doesn't support CAP. This adds a 15 second timeout to the CAP negotiation process, after which the connection continues. The timeout and error on failed SASL negotiation is preserved.
615 lines
16 KiB
Go
615 lines
16 KiB
Go
// Copyright 2009 Thomas Jager <mail@jager.no> All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
/*
|
|
This package provides an event based IRC client library. It allows to
|
|
register callbacks for the events you need to handle. Its features
|
|
include handling standard CTCP, reconnecting on errors and detecting
|
|
stones servers.
|
|
Details of the IRC protocol can be found in the following RFCs:
|
|
https://tools.ietf.org/html/rfc1459
|
|
https://tools.ietf.org/html/rfc2810
|
|
https://tools.ietf.org/html/rfc2811
|
|
https://tools.ietf.org/html/rfc2812
|
|
https://tools.ietf.org/html/rfc2813
|
|
The details of the client-to-client protocol (CTCP) can be found here: http://www.irchelp.org/irchelp/rfc/ctcpspec.html
|
|
*/
|
|
|
|
package irc
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
VERSION = "go-ircevent v2.1"
|
|
)
|
|
|
|
const CAP_TIMEOUT = time.Second * 15
|
|
|
|
var ErrDisconnected = errors.New("Disconnect Called")
|
|
|
|
// Read data from a connection. To be used as a goroutine.
|
|
func (irc *Connection) readLoop() {
|
|
defer irc.Done()
|
|
br := bufio.NewReaderSize(irc.socket, 512)
|
|
|
|
errChan := irc.ErrorChan()
|
|
|
|
for {
|
|
select {
|
|
case <-irc.end:
|
|
return
|
|
default:
|
|
// Set a read deadline based on the combined timeout and ping frequency
|
|
// We should ALWAYS have received a response from the server within the timeout
|
|
// after our own pings
|
|
if irc.socket != nil {
|
|
irc.socket.SetReadDeadline(time.Now().Add(irc.Timeout + irc.PingFreq))
|
|
}
|
|
|
|
msg, err := br.ReadString('\n')
|
|
|
|
// We got past our blocking read, so bin timeout
|
|
if irc.socket != nil {
|
|
var zero time.Time
|
|
irc.socket.SetReadDeadline(zero)
|
|
}
|
|
|
|
if err != nil {
|
|
errChan <- err
|
|
return
|
|
}
|
|
|
|
if irc.Debug {
|
|
irc.Log.Printf("<-- %s\n", strings.TrimSpace(msg))
|
|
}
|
|
|
|
irc.lastMessageMutex.Lock()
|
|
irc.lastMessage = time.Now()
|
|
irc.lastMessageMutex.Unlock()
|
|
event, err := parseToEvent(msg)
|
|
if err == nil {
|
|
event.Connection = irc
|
|
irc.RunCallbacks(event)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unescape tag values as defined in the IRCv3.2 message tags spec
|
|
// http://ircv3.net/specs/core/message-tags-3.2.html
|
|
func unescapeTagValue(value string) string {
|
|
value = strings.Replace(value, "\\:", ";", -1)
|
|
value = strings.Replace(value, "\\s", " ", -1)
|
|
value = strings.Replace(value, "\\\\", "\\", -1)
|
|
value = strings.Replace(value, "\\r", "\r", -1)
|
|
value = strings.Replace(value, "\\n", "\n", -1)
|
|
return value
|
|
}
|
|
|
|
//Parse raw irc messages
|
|
func parseToEvent(msg string) (*Event, error) {
|
|
msg = strings.TrimSuffix(msg, "\n") //Remove \r\n
|
|
msg = strings.TrimSuffix(msg, "\r")
|
|
event := &Event{Raw: msg}
|
|
if len(msg) < 5 {
|
|
return nil, errors.New("Malformed msg from server")
|
|
}
|
|
|
|
if msg[0] == '@' {
|
|
// IRCv3 Message Tags
|
|
if i := strings.Index(msg, " "); i > -1 {
|
|
event.Tags = make(map[string]string)
|
|
tags := strings.Split(msg[1:i], ";")
|
|
for _, data := range tags {
|
|
parts := strings.SplitN(data, "=", 2)
|
|
if len(parts) == 1 {
|
|
event.Tags[parts[0]] = ""
|
|
} else {
|
|
event.Tags[parts[0]] = unescapeTagValue(parts[1])
|
|
}
|
|
}
|
|
msg = msg[i+1 : len(msg)]
|
|
} else {
|
|
return nil, errors.New("Malformed msg from server")
|
|
}
|
|
}
|
|
|
|
if msg[0] == ':' {
|
|
if i := strings.Index(msg, " "); i > -1 {
|
|
event.Source = msg[1:i]
|
|
msg = msg[i+1 : len(msg)]
|
|
|
|
} else {
|
|
return nil, errors.New("Malformed msg from server")
|
|
}
|
|
|
|
if i, j := strings.Index(event.Source, "!"), strings.Index(event.Source, "@"); i > -1 && j > -1 && i < j {
|
|
event.Nick = event.Source[0:i]
|
|
event.User = event.Source[i+1 : j]
|
|
event.Host = event.Source[j+1 : len(event.Source)]
|
|
}
|
|
}
|
|
|
|
split := strings.SplitN(msg, " :", 2)
|
|
args := strings.Split(split[0], " ")
|
|
event.Code = strings.ToUpper(args[0])
|
|
event.Arguments = args[1:]
|
|
if len(split) > 1 {
|
|
event.Arguments = append(event.Arguments, split[1])
|
|
}
|
|
return event, nil
|
|
|
|
}
|
|
|
|
// Loop to write to a connection. To be used as a goroutine.
|
|
func (irc *Connection) writeLoop() {
|
|
defer irc.Done()
|
|
errChan := irc.ErrorChan()
|
|
for {
|
|
select {
|
|
case <-irc.end:
|
|
return
|
|
case b, ok := <-irc.pwrite:
|
|
if !ok || b == "" || irc.socket == nil {
|
|
return
|
|
}
|
|
|
|
if irc.Debug {
|
|
irc.Log.Printf("--> %s\n", strings.TrimSpace(b))
|
|
}
|
|
|
|
// Set a write deadline based on the time out
|
|
irc.socket.SetWriteDeadline(time.Now().Add(irc.Timeout))
|
|
|
|
_, err := irc.socket.Write([]byte(b))
|
|
|
|
// Past blocking write, bin timeout
|
|
var zero time.Time
|
|
irc.socket.SetWriteDeadline(zero)
|
|
|
|
if err != nil {
|
|
errChan <- err
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pings the server if we have not received any messages for 5 minutes
|
|
// to keep the connection alive. To be used as a goroutine.
|
|
func (irc *Connection) pingLoop() {
|
|
defer irc.Done()
|
|
ticker := time.NewTicker(1 * time.Minute) // Tick every minute for monitoring
|
|
ticker2 := time.NewTicker(irc.PingFreq) // Tick at the ping frequency.
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
//Ping if we haven't received anything from the server within the keep alive period
|
|
irc.lastMessageMutex.Lock()
|
|
if time.Since(irc.lastMessage) >= irc.KeepAlive {
|
|
irc.SendRawf("PING %d", time.Now().UnixNano())
|
|
}
|
|
irc.lastMessageMutex.Unlock()
|
|
case <-ticker2.C:
|
|
//Ping at the ping frequency
|
|
irc.SendRawf("PING %d", time.Now().UnixNano())
|
|
//Try to recapture nickname if it's not as configured.
|
|
irc.Lock()
|
|
if irc.nick != irc.nickcurrent {
|
|
irc.nickcurrent = irc.nick
|
|
irc.SendRawf("NICK %s", irc.nick)
|
|
}
|
|
irc.Unlock()
|
|
case <-irc.end:
|
|
ticker.Stop()
|
|
ticker2.Stop()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (irc *Connection) isQuitting() bool {
|
|
irc.Lock()
|
|
defer irc.Unlock()
|
|
return irc.quit
|
|
}
|
|
|
|
// Main loop to control the connection.
|
|
func (irc *Connection) Loop() {
|
|
errChan := irc.ErrorChan()
|
|
for !irc.isQuitting() {
|
|
err := <-errChan
|
|
if irc.end != nil {
|
|
close(irc.end)
|
|
}
|
|
irc.Wait()
|
|
for !irc.isQuitting() {
|
|
irc.Log.Printf("Error, disconnected: %s\n", err)
|
|
if err = irc.Reconnect(); err != nil {
|
|
irc.Log.Printf("Error while reconnecting: %s\n", err)
|
|
time.Sleep(60 * time.Second)
|
|
} else {
|
|
errChan = irc.ErrorChan()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Quit the current connection and disconnect from the server
|
|
// RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.1.6
|
|
func (irc *Connection) Quit() {
|
|
quit := "QUIT"
|
|
|
|
if irc.QuitMessage != "" {
|
|
quit = fmt.Sprintf("QUIT :%s", irc.QuitMessage)
|
|
}
|
|
|
|
irc.SendRaw(quit)
|
|
irc.Lock()
|
|
irc.stopped = true
|
|
irc.quit = true
|
|
irc.Unlock()
|
|
}
|
|
|
|
// Use the connection to join a given channel.
|
|
// RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.2.1
|
|
func (irc *Connection) Join(channel string) {
|
|
irc.pwrite <- fmt.Sprintf("JOIN %s\r\n", channel)
|
|
}
|
|
|
|
// Leave a given channel.
|
|
// RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.2.2
|
|
func (irc *Connection) Part(channel string) {
|
|
irc.pwrite <- fmt.Sprintf("PART %s\r\n", channel)
|
|
}
|
|
|
|
// Send a notification to a nickname. This is similar to Privmsg but must not receive replies.
|
|
// RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.4.2
|
|
func (irc *Connection) Notice(target, message string) {
|
|
irc.pwrite <- fmt.Sprintf("NOTICE %s :%s\r\n", target, message)
|
|
}
|
|
|
|
// Send a formated notification to a nickname.
|
|
// RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.4.2
|
|
func (irc *Connection) Noticef(target, format string, a ...interface{}) {
|
|
irc.Notice(target, fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// Send (action) message to a target (channel or nickname).
|
|
// No clear RFC on this one...
|
|
func (irc *Connection) Action(target, message string) {
|
|
irc.pwrite <- fmt.Sprintf("PRIVMSG %s :\001ACTION %s\001\r\n", target, message)
|
|
}
|
|
|
|
// Send formatted (action) message to a target (channel or nickname).
|
|
func (irc *Connection) Actionf(target, format string, a ...interface{}) {
|
|
irc.Action(target, fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// Send (private) message to a target (channel or nickname).
|
|
// RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.4.1
|
|
func (irc *Connection) Privmsg(target, message string) {
|
|
irc.pwrite <- fmt.Sprintf("PRIVMSG %s :%s\r\n", target, message)
|
|
}
|
|
|
|
// Send formated string to specified target (channel or nickname).
|
|
func (irc *Connection) Privmsgf(target, format string, a ...interface{}) {
|
|
irc.Privmsg(target, fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// Kick <user> from <channel> with <msg>. For no message, pass empty string ("")
|
|
func (irc *Connection) Kick(user, channel, msg string) {
|
|
var cmd bytes.Buffer
|
|
cmd.WriteString(fmt.Sprintf("KICK %s %s", channel, user))
|
|
if msg != "" {
|
|
cmd.WriteString(fmt.Sprintf(" :%s", msg))
|
|
}
|
|
cmd.WriteString("\r\n")
|
|
irc.pwrite <- cmd.String()
|
|
}
|
|
|
|
// Kick all <users> from <channel> with <msg>. For no message, pass
|
|
// empty string ("")
|
|
func (irc *Connection) MultiKick(users []string, channel string, msg string) {
|
|
var cmd bytes.Buffer
|
|
cmd.WriteString(fmt.Sprintf("KICK %s %s", channel, strings.Join(users, ",")))
|
|
if msg != "" {
|
|
cmd.WriteString(fmt.Sprintf(" :%s", msg))
|
|
}
|
|
cmd.WriteString("\r\n")
|
|
irc.pwrite <- cmd.String()
|
|
}
|
|
|
|
// Send raw string.
|
|
func (irc *Connection) SendRaw(message string) {
|
|
irc.pwrite <- message + "\r\n"
|
|
}
|
|
|
|
// Send raw formated string.
|
|
func (irc *Connection) SendRawf(format string, a ...interface{}) {
|
|
irc.SendRaw(fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
// Set (new) nickname.
|
|
// RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.1.2
|
|
func (irc *Connection) Nick(n string) {
|
|
irc.nick = n
|
|
irc.SendRawf("NICK %s", n)
|
|
}
|
|
|
|
// Determine nick currently used with the connection.
|
|
func (irc *Connection) GetNick() string {
|
|
return irc.nickcurrent
|
|
}
|
|
|
|
// Query information about a particular nickname.
|
|
// RFC 1459: https://tools.ietf.org/html/rfc1459#section-4.5.2
|
|
func (irc *Connection) Whois(nick string) {
|
|
irc.SendRawf("WHOIS %s", nick)
|
|
}
|
|
|
|
// Query information about a given nickname in the server.
|
|
// RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.5.1
|
|
func (irc *Connection) Who(nick string) {
|
|
irc.SendRawf("WHO %s", nick)
|
|
}
|
|
|
|
// Set different modes for a target (channel or nickname).
|
|
// RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.2.3
|
|
func (irc *Connection) Mode(target string, modestring ...string) {
|
|
if len(modestring) > 0 {
|
|
mode := strings.Join(modestring, " ")
|
|
irc.SendRawf("MODE %s %s", target, mode)
|
|
return
|
|
}
|
|
irc.SendRawf("MODE %s", target)
|
|
}
|
|
|
|
func (irc *Connection) ErrorChan() chan error {
|
|
return irc.Error
|
|
}
|
|
|
|
// Returns true if the connection is connected to an IRC server.
|
|
func (irc *Connection) Connected() bool {
|
|
return !irc.stopped
|
|
}
|
|
|
|
// A disconnect sends all buffered messages (if possible),
|
|
// stops all goroutines and then closes the socket.
|
|
func (irc *Connection) Disconnect() {
|
|
irc.Lock()
|
|
defer irc.Unlock()
|
|
|
|
if irc.end != nil {
|
|
close(irc.end)
|
|
}
|
|
|
|
irc.Wait()
|
|
|
|
irc.end = nil
|
|
|
|
if irc.pwrite != nil {
|
|
close(irc.pwrite)
|
|
}
|
|
|
|
if irc.socket != nil {
|
|
irc.socket.Close()
|
|
}
|
|
irc.ErrorChan() <- ErrDisconnected
|
|
}
|
|
|
|
// Reconnect to a server using the current connection.
|
|
func (irc *Connection) Reconnect() error {
|
|
irc.end = make(chan struct{})
|
|
return irc.Connect(irc.Server)
|
|
}
|
|
|
|
// Connect to a given server using the current connection configuration.
|
|
// This function also takes care of identification if a password is provided.
|
|
// RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.1
|
|
func (irc *Connection) Connect(server string) error {
|
|
irc.Server = server
|
|
// mark Server as stopped since there can be an error during connect
|
|
irc.stopped = true
|
|
|
|
// make sure everything is ready for connection
|
|
if len(irc.Server) == 0 {
|
|
return errors.New("empty 'server'")
|
|
}
|
|
if strings.Index(irc.Server, ":") == 0 {
|
|
return errors.New("hostname is missing")
|
|
}
|
|
if strings.Index(irc.Server, ":") == len(irc.Server)-1 {
|
|
return errors.New("port missing")
|
|
}
|
|
_, ports, err := net.SplitHostPort(irc.Server)
|
|
if err != nil {
|
|
return errors.New("wrong address string")
|
|
}
|
|
port, err := strconv.Atoi(ports)
|
|
if err != nil {
|
|
return errors.New("extracting port failed")
|
|
}
|
|
if !((port >= 0) && (port <= 65535)) {
|
|
return errors.New("port number outside valid range")
|
|
}
|
|
if irc.Log == nil {
|
|
return errors.New("'Log' points to nil")
|
|
}
|
|
if len(irc.nick) == 0 {
|
|
return errors.New("empty 'nick'")
|
|
}
|
|
if len(irc.user) == 0 {
|
|
return errors.New("empty 'user'")
|
|
}
|
|
|
|
if irc.UseTLS {
|
|
dialer := &net.Dialer{Timeout: irc.Timeout}
|
|
irc.socket, err = tls.DialWithDialer(dialer, "tcp", irc.Server, irc.TLSConfig)
|
|
} else {
|
|
irc.socket, err = net.DialTimeout("tcp", irc.Server, irc.Timeout)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
irc.stopped = false
|
|
irc.Log.Printf("Connected to %s (%s)\n", irc.Server, irc.socket.RemoteAddr())
|
|
|
|
irc.pwrite = make(chan string, 10)
|
|
irc.Error = make(chan error, 10)
|
|
irc.Add(3)
|
|
go irc.readLoop()
|
|
go irc.writeLoop()
|
|
go irc.pingLoop()
|
|
|
|
if len(irc.WebIRC) > 0 {
|
|
irc.pwrite <- fmt.Sprintf("WEBIRC %s\r\n", irc.WebIRC)
|
|
}
|
|
|
|
if len(irc.Password) > 0 {
|
|
irc.pwrite <- fmt.Sprintf("PASS %s\r\n", irc.Password)
|
|
}
|
|
|
|
err = irc.negotiateCaps()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
realname := irc.user
|
|
if irc.RealName != "" {
|
|
realname = irc.RealName
|
|
}
|
|
|
|
irc.pwrite <- fmt.Sprintf("NICK %s\r\n", irc.nick)
|
|
irc.pwrite <- fmt.Sprintf("USER %s 0.0.0.0 0.0.0.0 :%s\r\n", irc.user, realname)
|
|
return nil
|
|
}
|
|
|
|
// Negotiate IRCv3 capabilities
|
|
func (irc *Connection) negotiateCaps() error {
|
|
saslResChan := make(chan *SASLResult)
|
|
if irc.UseSASL {
|
|
irc.RequestCaps = append(irc.RequestCaps, "sasl")
|
|
irc.setupSASLCallbacks(saslResChan)
|
|
}
|
|
|
|
if len(irc.RequestCaps) == 0 {
|
|
return nil
|
|
}
|
|
|
|
cap_chan := make(chan bool, len(irc.RequestCaps))
|
|
irc.AddCallback("CAP", func(e *Event) {
|
|
if len(e.Arguments) != 3 {
|
|
return
|
|
}
|
|
command := e.Arguments[1]
|
|
|
|
if command == "LS" {
|
|
missing_caps := len(irc.RequestCaps)
|
|
for _, cap_name := range strings.Split(e.Arguments[2], " ") {
|
|
for _, req_cap := range irc.RequestCaps {
|
|
if cap_name == req_cap {
|
|
irc.pwrite <- fmt.Sprintf("CAP REQ :%s\r\n", cap_name)
|
|
missing_caps--
|
|
}
|
|
}
|
|
}
|
|
|
|
for i := 0; i < missing_caps; i++ {
|
|
cap_chan <- true
|
|
}
|
|
} else if command == "ACK" || command == "NAK" {
|
|
for _, cap_name := range strings.Split(strings.TrimSpace(e.Arguments[2]), " ") {
|
|
if cap_name == "" {
|
|
continue
|
|
}
|
|
|
|
if command == "ACK" {
|
|
irc.AcknowledgedCaps = append(irc.AcknowledgedCaps, cap_name)
|
|
}
|
|
cap_chan <- true
|
|
}
|
|
}
|
|
})
|
|
|
|
irc.pwrite <- "CAP LS\r\n"
|
|
|
|
if irc.UseSASL {
|
|
select {
|
|
case res := <-saslResChan:
|
|
if res.Failed {
|
|
close(saslResChan)
|
|
return res.Err
|
|
}
|
|
case <-time.After(CAP_TIMEOUT):
|
|
close(saslResChan)
|
|
// Raise an error if we can't authenticate with SASL.
|
|
return errors.New("SASL setup timed out. Does the server support SASL?")
|
|
}
|
|
}
|
|
|
|
remaining_caps := len(irc.RequestCaps)
|
|
|
|
select {
|
|
case <-cap_chan:
|
|
remaining_caps--
|
|
case <-time.After(CAP_TIMEOUT):
|
|
// The server probably doesn't implement CAP LS, which is "normal".
|
|
return nil
|
|
}
|
|
|
|
// Wait for all capabilities to be ACKed or NAKed before ending negotiation
|
|
for remaining_caps > 0 {
|
|
<-cap_chan
|
|
remaining_caps--
|
|
}
|
|
|
|
irc.pwrite <- fmt.Sprintf("CAP END\r\n")
|
|
|
|
return nil
|
|
}
|
|
|
|
// Create a connection with the (publicly visible) nickname and username.
|
|
// The nickname is later used to address the user. Returns nil if nick
|
|
// or user are empty.
|
|
func IRC(nick, user string) *Connection {
|
|
// catch invalid values
|
|
if len(nick) == 0 {
|
|
return nil
|
|
}
|
|
if len(user) == 0 {
|
|
return nil
|
|
}
|
|
|
|
irc := &Connection{
|
|
nick: nick,
|
|
nickcurrent: nick,
|
|
user: user,
|
|
Log: log.New(os.Stdout, "", log.LstdFlags),
|
|
end: make(chan struct{}),
|
|
Version: VERSION,
|
|
KeepAlive: 4 * time.Minute,
|
|
Timeout: 1 * time.Minute,
|
|
PingFreq: 15 * time.Minute,
|
|
SASLMech: "PLAIN",
|
|
QuitMessage: "",
|
|
}
|
|
irc.setupCallbacks()
|
|
return irc
|
|
}
|