├── go.mod ├── irc_test_fuzz.go ├── examples ├── simple │ └── simple.go └── simple-tor.go │ └── simple-tor.go ├── go.sum ├── irc_parse_test.go ├── LICENSE ├── README.markdown ├── irc_sasl.go ├── irc_sasl_test.go ├── irc_struct.go ├── irc_callback.go ├── irc_test.go └── irc.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/thoj/go-ircevent 2 | 3 | go 1.12 4 | 5 | require ( 6 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e 7 | golang.org/x/text v0.3.6 8 | ) 9 | -------------------------------------------------------------------------------- /irc_test_fuzz.go: -------------------------------------------------------------------------------- 1 | // +build gofuzz 2 | 3 | package irc 4 | 5 | func Fuzz(data []byte) int { 6 | b := bytes.NewBuffer(data) 7 | event, err := parseToEvent(b.String()) 8 | if err == nil { 9 | irc := IRC("go-eventirc", "go-eventirc") 10 | irc.RunCallbacks(event) 11 | return 1 12 | } 13 | return 0 14 | } 15 | -------------------------------------------------------------------------------- /examples/simple/simple.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/thoj/go-ircevent" 5 | "crypto/tls" 6 | "fmt" 7 | ) 8 | 9 | const channel = "#go-eventirc-test"; 10 | const serverssl = "irc.freenode.net:7000" 11 | 12 | func main() { 13 | ircnick1 := "blatiblat" 14 | irccon := irc.IRC(ircnick1, "IRCTestSSL") 15 | irccon.VerboseCallbackHandler = true 16 | irccon.Debug = true 17 | irccon.UseTLS = true 18 | irccon.TLSConfig = &tls.Config{InsecureSkipVerify: true} 19 | irccon.AddCallback("001", func(e *irc.Event) { irccon.Join(channel) }) 20 | irccon.AddCallback("366", func(e *irc.Event) { }) 21 | err := irccon.Connect(serverssl) 22 | if err != nil { 23 | fmt.Printf("Err %s", err ) 24 | return 25 | } 26 | irccon.Loop() 27 | } 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= 2 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 3 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 4 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 5 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 6 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 7 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 8 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 9 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 10 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 11 | -------------------------------------------------------------------------------- /irc_parse_test.go: -------------------------------------------------------------------------------- 1 | package irc 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func checkResult(t *testing.T, event *Event) { 8 | if event.Nick != "nick" { 9 | t.Fatal("Parse failed: nick") 10 | } 11 | if event.User != "~user" { 12 | t.Fatal("Parse failed: user") 13 | } 14 | if event.Code != "PRIVMSG" { 15 | t.Fatal("Parse failed: code") 16 | } 17 | if event.Arguments[0] != "#channel" { 18 | t.Fatal("Parse failed: channel") 19 | } 20 | if event.Arguments[1] != "message text" { 21 | t.Fatal("Parse failed: message") 22 | } 23 | } 24 | 25 | func TestParse(t *testing.T) { 26 | event, err := parseToEvent(":nick!~user@host PRIVMSG #channel :message text") 27 | if err != nil { 28 | t.Fatal("Parse PRIVMSG failed") 29 | } 30 | checkResult(t, event) 31 | } 32 | 33 | func TestParseTags(t *testing.T) { 34 | event, err := parseToEvent("@tag;+tag2=raw+:=,escaped\\:\\s\\\\ :nick!~user@host PRIVMSG #channel :message text") 35 | if err != nil { 36 | t.Fatal("Parse PRIVMSG with tags failed") 37 | } 38 | checkResult(t, event) 39 | t.Logf("%s", event.Tags) 40 | if _, ok := event.Tags["tag"]; !ok { 41 | t.Fatal("Parsing value-less tag failed") 42 | } 43 | if event.Tags["+tag2"] != "raw+:=,escaped; \\" { 44 | t.Fatal("Parsing tag failed") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2009 Thomas Jager. All rights reserved. 2 | // 3 | // Redistribution and use in source and binary forms, with or without 4 | // modification, are permitted provided that the following conditions are 5 | // met: 6 | // 7 | // * Redistributions of source code must retain the above copyright 8 | // notice, this list of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above 10 | // copyright notice, this list of conditions and the following disclaimer 11 | // in the documentation and/or other materials provided with the 12 | // distribution. 13 | // * Neither the name of Google Inc. nor the names of its 14 | // contributors may be used to endorse or promote products derived from 15 | // this software without specific prior written permission. 16 | // 17 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /examples/simple-tor.go/simple-tor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/thoj/go-ircevent" 5 | "crypto/tls" 6 | "log" 7 | "os" 8 | ) 9 | 10 | const addr = "libera75jm6of4wxpxt4aynol3xjmbtxgfyjpu34ss4d7r7q2v5zrpyd.onion:6697" 11 | 12 | // This demos connecting to Libera.Chat over TOR using SASL EXTERNAL and a TLS 13 | // client cert. It assumes a TOR SOCKS service is running on localhost:9050 14 | // and requires an existing account with a fingerprint already registered. See 15 | // https://libera.chat/guides/connect#accessing-liberachat-via-tor for details. 16 | // 17 | // Pass the full path to your cert and key on the command line like so: 18 | // $ go run simple-tor.go my-nick my-cert.pem my-key.pem 19 | 20 | func main() { 21 | os.Setenv("ALL_PROXY", "socks5h://localhost:9050") 22 | nick, certFile := os.Args[1], os.Args[2] 23 | keyFile := certFile 24 | if len(os.Args) == 4 { 25 | keyFile = os.Args[3] 26 | } 27 | clientCert, err := tls.LoadX509KeyPair(certFile, keyFile) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | ircnick1 := nick 32 | irccon := irc.IRC(ircnick1, nick) 33 | irccon.VerboseCallbackHandler = true 34 | irccon.UseSASL = true 35 | irccon.SASLMech = "EXTERNAL" 36 | irccon.Debug = true 37 | irccon.UseTLS = true 38 | irccon.TLSConfig = &tls.Config{ 39 | InsecureSkipVerify: true, 40 | Certificates: []tls.Certificate{clientCert}, 41 | } 42 | irccon.AddCallback("001", func(e *irc.Event) {}) 43 | irccon.AddCallback("376", func(e *irc.Event) { 44 | log.Println("Quitting") 45 | irccon.Quit() 46 | }) 47 | err = irccon.Connect(addr) 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | irccon.Loop() 52 | } 53 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | Description 2 | ----------- 3 | 4 | Event based irc client library. 5 | 6 | 7 | Features 8 | -------- 9 | * Event based. Register Callbacks for the events you need to handle. 10 | * Handles basic irc demands for you 11 | * Standard CTCP 12 | * Reconnections on errors 13 | * Detect stoned servers 14 | 15 | Install 16 | ------- 17 | $ go get github.com/thoj/go-ircevent 18 | 19 | Example 20 | ------- 21 | See [examples/simple/simple.go](examples/simple/simple.go) and [irc_test.go](irc_test.go) 22 | 23 | Events for callbacks 24 | -------------------- 25 | * 001 Welcome 26 | * PING 27 | * CTCP Unknown CTCP 28 | * CTCP_VERSION Version request (Handled internaly) 29 | * CTCP_USERINFO 30 | * CTCP_CLIENTINFO 31 | * CTCP_TIME 32 | * CTCP_PING 33 | * CTCP_ACTION (/me) 34 | * PRIVMSG 35 | * MODE 36 | * JOIN 37 | 38 | +Many more 39 | 40 | 41 | AddCallback Example 42 | ------------------- 43 | ircobj.AddCallback("PRIVMSG", func(event *irc.Event) { 44 | //event.Message() contains the message 45 | //event.Nick Contains the sender 46 | //event.Arguments[0] Contains the channel 47 | }); 48 | 49 | Please note: Callbacks are run in the main thread. If a callback needs a long 50 | time to execute please run it in a new thread. 51 | 52 | Example: 53 | 54 | ircobj.AddCallback("PRIVMSG", func(event *irc.Event) { 55 | go func(event *irc.Event) { 56 | //event.Message() contains the message 57 | //event.Nick Contains the sender 58 | //event.Arguments[0] Contains the channel 59 | }(event) 60 | }); 61 | 62 | 63 | Commands 64 | -------- 65 | ircobj := irc.IRC("", "") //Create new ircobj 66 | //Set options 67 | ircobj.UseTLS = true //default is false 68 | //ircobj.TLSOptions //set ssl options 69 | ircobj.Password = "[server password]" 70 | //Commands 71 | ircobj.Connect("irc.someserver.com:6667") //Connect to server 72 | ircobj.SendRaw("") //sends string to server. Adds \r\n 73 | ircobj.SendRawf("", ...) //sends formatted string to server.n 74 | ircobj.Join("<#channel> [password]") 75 | ircobj.Nick("newnick") 76 | ircobj.Privmsg("", "msg") // sends a message to either a certain nick or a channel 77 | ircobj.Privmsgf(, "", ...) 78 | ircobj.Notice("", "msg") 79 | ircobj.Noticef("", "", ...) 80 | -------------------------------------------------------------------------------- /irc_sasl.go: -------------------------------------------------------------------------------- 1 | package irc 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | type SASLResult struct { 11 | Failed bool 12 | Err error 13 | } 14 | 15 | // Check if a space-separated list of arguments contains a value. 16 | func listContains(list string, value string) bool { 17 | for _, arg_name := range strings.Split(strings.TrimSpace(list), " ") { 18 | if arg_name == value { 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | 25 | func (irc *Connection) setupSASLCallbacks(result chan<- *SASLResult) (callbacks []CallbackID) { 26 | id := irc.AddCallback("CAP", func(e *Event) { 27 | if len(e.Arguments) == 3 { 28 | if e.Arguments[1] == "LS" { 29 | if !listContains(e.Arguments[2], "sasl") { 30 | result <- &SASLResult{true, errors.New("no SASL capability " + e.Arguments[2])} 31 | } 32 | } 33 | if e.Arguments[1] == "ACK" && listContains(e.Arguments[2], "sasl") { 34 | if irc.SASLMech != "PLAIN" && irc.SASLMech != "EXTERNAL" { 35 | result <- &SASLResult{true, errors.New("only PLAIN and EXTERNAL supported")} 36 | } 37 | irc.SendRaw("AUTHENTICATE " + irc.SASLMech) 38 | } 39 | } 40 | }) 41 | callbacks = append(callbacks, CallbackID{"CAP", id}) 42 | 43 | id = irc.AddCallback("AUTHENTICATE", func(e *Event) { 44 | if irc.SASLMech == "EXTERNAL" { 45 | irc.SendRaw("AUTHENTICATE +") 46 | return 47 | } 48 | str := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s\x00%s\x00%s", irc.SASLLogin, irc.SASLLogin, irc.SASLPassword))) 49 | irc.SendRaw("AUTHENTICATE " + str) 50 | }) 51 | callbacks = append(callbacks, CallbackID{"AUTHENTICATE", id}) 52 | 53 | id = irc.AddCallback("901", func(e *Event) { 54 | irc.SendRaw("CAP END") 55 | irc.SendRaw("QUIT") 56 | result <- &SASLResult{true, errors.New(e.Arguments[1])} 57 | }) 58 | callbacks = append(callbacks, CallbackID{"901", id}) 59 | 60 | id = irc.AddCallback("902", func(e *Event) { 61 | irc.SendRaw("CAP END") 62 | irc.SendRaw("QUIT") 63 | result <- &SASLResult{true, errors.New(e.Arguments[1])} 64 | }) 65 | callbacks = append(callbacks, CallbackID{"902", id}) 66 | 67 | id = irc.AddCallback("903", func(e *Event) { 68 | result <- &SASLResult{false, nil} 69 | }) 70 | callbacks = append(callbacks, CallbackID{"903", id}) 71 | 72 | id = irc.AddCallback("904", func(e *Event) { 73 | irc.SendRaw("CAP END") 74 | irc.SendRaw("QUIT") 75 | result <- &SASLResult{true, errors.New(e.Arguments[1])} 76 | }) 77 | callbacks = append(callbacks, CallbackID{"904", id}) 78 | 79 | return 80 | } 81 | -------------------------------------------------------------------------------- /irc_sasl_test.go: -------------------------------------------------------------------------------- 1 | package irc 2 | 3 | import ( 4 | "crypto/tls" 5 | "os" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | // set SASLLogin and SASLPassword environment variables before testing 11 | func TestConnectionSASL(t *testing.T) { 12 | SASLServer := "irc.freenode.net:7000" 13 | SASLLogin := os.Getenv("SASLLogin") 14 | SASLPassword := os.Getenv("SASLPassword") 15 | 16 | if SASLLogin == "" { 17 | t.Skip("Define SASLLogin and SASLPasword environment varables to test SASL") 18 | } 19 | if testing.Short() { 20 | t.Skip("skipping test in short mode.") 21 | } 22 | irccon := IRC("go-eventirc", "go-eventirc") 23 | irccon.VerboseCallbackHandler = true 24 | irccon.Debug = true 25 | irccon.UseTLS = true 26 | irccon.UseSASL = true 27 | irccon.SASLLogin = SASLLogin 28 | irccon.SASLPassword = SASLPassword 29 | irccon.TLSConfig = &tls.Config{InsecureSkipVerify: true} 30 | irccon.AddCallback("001", func(e *Event) { irccon.Join("#go-eventirc") }) 31 | 32 | irccon.AddCallback("366", func(e *Event) { 33 | irccon.Privmsg("#go-eventirc", "Test Message SASL\n") 34 | time.Sleep(2 * time.Second) 35 | irccon.Quit() 36 | }) 37 | 38 | err := irccon.Connect(SASLServer) 39 | if err != nil { 40 | t.Fatalf("SASL failed: %s", err) 41 | } 42 | irccon.Loop() 43 | } 44 | 45 | 46 | // 1. Register fingerprint with IRC network 47 | // 2. Add SASLKeyPem="-----BEGIN PRIVATE KEY-----..." 48 | // and SASLCertPem="-----BEGIN CERTIFICATE-----..." 49 | // to CI environment as masked variables 50 | func TestConnectionSASLExternal(t *testing.T) { 51 | SASLServer := "irc.freenode.net:7000" 52 | keyPem := os.Getenv("SASLKeyPem") 53 | certPem := os.Getenv("SASLCertPem") 54 | 55 | if certPem == "" || keyPem == "" { 56 | t.Skip("Env vars SASLKeyPem SASLCertPem not present, skipping") 57 | } 58 | if testing.Short() { 59 | t.Skip("skipping test in short mode.") 60 | } 61 | cert, err := tls.X509KeyPair([]byte(certPem), []byte(keyPem)) 62 | if err != nil { 63 | t.Fatalf("SASL EXTERNAL cert creation failed: %s", err) 64 | } 65 | 66 | irccon := IRC("go-eventirc", "go-eventirc") 67 | irccon.VerboseCallbackHandler = true 68 | irccon.Debug = true 69 | irccon.UseTLS = true 70 | irccon.UseSASL = true 71 | irccon.SASLMech = "EXTERNAL" 72 | irccon.TLSConfig = &tls.Config{ 73 | InsecureSkipVerify: true, 74 | Certificates: []tls.Certificate{cert}, 75 | } 76 | irccon.AddCallback("001", func(e *Event) { irccon.Join("#go-eventirc") }) 77 | 78 | irccon.AddCallback("366", func(e *Event) { 79 | irccon.Privmsg("#go-eventirc", "Test Message SASL EXTERNAL\n") 80 | time.Sleep(2 * time.Second) 81 | irccon.Quit() 82 | }) 83 | 84 | err = irccon.Connect(SASLServer) 85 | if err != nil { 86 | t.Fatalf("SASL EXTERNAL failed: %s", err) 87 | } 88 | irccon.Loop() 89 | } 90 | -------------------------------------------------------------------------------- /irc_struct.go: -------------------------------------------------------------------------------- 1 | // Copyright 2009 Thomas Jager All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package irc 6 | 7 | import ( 8 | "context" 9 | "crypto/tls" 10 | "log" 11 | "net" 12 | "regexp" 13 | "sync" 14 | "time" 15 | 16 | "golang.org/x/text/encoding" 17 | ) 18 | 19 | type Connection struct { 20 | sync.Mutex 21 | sync.WaitGroup 22 | Debug bool 23 | Error chan error 24 | WebIRC string 25 | Password string 26 | UseTLS bool 27 | UseSASL bool 28 | RequestCaps []string 29 | AcknowledgedCaps []string 30 | SASLLogin string 31 | SASLPassword string 32 | SASLMech string 33 | TLSConfig *tls.Config 34 | Version string 35 | Timeout time.Duration 36 | CallbackTimeout time.Duration 37 | PingFreq time.Duration 38 | KeepAlive time.Duration 39 | Server string 40 | Encoding encoding.Encoding 41 | 42 | RealName string // The real name we want to display. 43 | // If zero-value defaults to the user. 44 | 45 | socket net.Conn 46 | pwrite chan string 47 | end chan struct{} 48 | 49 | nick string //The nickname we want. 50 | nickcurrent string //The nickname we currently have. 51 | user string 52 | registered bool 53 | events map[string]map[int]func(*Event) 54 | eventsMutex sync.Mutex 55 | 56 | QuitMessage string 57 | lastMessage time.Time 58 | lastMessageMutex sync.Mutex 59 | 60 | VerboseCallbackHandler bool 61 | Log *log.Logger 62 | 63 | stopped bool 64 | quit bool //User called Quit, do not reconnect. 65 | 66 | idCounter int // assign unique IDs to callbacks 67 | } 68 | 69 | // A struct to represent an event. 70 | type Event struct { 71 | Code string 72 | Raw string 73 | Nick string // 74 | Host string //!@ 75 | Source string // 76 | User string // 77 | Arguments []string 78 | Tags map[string]string 79 | Connection *Connection 80 | Ctx context.Context 81 | } 82 | 83 | // Retrieve the last message from Event arguments. 84 | // This function leaves the arguments untouched and 85 | // returns an empty string if there are none. 86 | func (e *Event) Message() string { 87 | if len(e.Arguments) == 0 { 88 | return "" 89 | } 90 | return e.Arguments[len(e.Arguments)-1] 91 | } 92 | 93 | // https://stackoverflow.com/a/10567935/6754440 94 | // Regex of IRC formatting. 95 | var ircFormat = regexp.MustCompile(`[\x02\x1F\x0F\x16\x1D\x1E]|\x03(\d\d?(,\d\d?)?)?`) 96 | 97 | // Retrieve the last message from Event arguments, but without IRC formatting (color. 98 | // This function leaves the arguments untouched and 99 | // returns an empty string if there are none. 100 | func (e *Event) MessageWithoutFormat() string { 101 | if len(e.Arguments) == 0 { 102 | return "" 103 | } 104 | return ircFormat.ReplaceAllString(e.Arguments[len(e.Arguments)-1], "") 105 | } 106 | -------------------------------------------------------------------------------- /irc_callback.go: -------------------------------------------------------------------------------- 1 | package irc 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "runtime" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // Tuple type for uniquely identifying callbacks 13 | type CallbackID struct { 14 | EventCode string 15 | ID int 16 | } 17 | 18 | // Register a callback to a connection and event code. A callback is a function 19 | // which takes only an Event pointer as parameter. Valid event codes are all 20 | // IRC/CTCP commands and error/response codes. To register a callback for all 21 | // events pass "*" as the event code. This function returns the ID of the 22 | // registered callback for later management. 23 | func (irc *Connection) AddCallback(eventcode string, callback func(*Event)) int { 24 | eventcode = strings.ToUpper(eventcode) 25 | 26 | irc.eventsMutex.Lock() 27 | _, ok := irc.events[eventcode] 28 | if !ok { 29 | irc.events[eventcode] = make(map[int]func(*Event)) 30 | } 31 | id := irc.idCounter 32 | irc.idCounter++ 33 | irc.events[eventcode][id] = callback 34 | irc.eventsMutex.Unlock() 35 | return id 36 | } 37 | 38 | // Remove callback i (ID) from the given event code. This functions returns 39 | // true upon success, false if any error occurs. 40 | func (irc *Connection) RemoveCallback(eventcode string, i int) bool { 41 | eventcode = strings.ToUpper(eventcode) 42 | 43 | irc.eventsMutex.Lock() 44 | event, ok := irc.events[eventcode] 45 | if ok { 46 | if _, ok := event[i]; ok { 47 | delete(irc.events[eventcode], i) 48 | irc.eventsMutex.Unlock() 49 | return true 50 | } 51 | irc.Log.Printf("Event found, but no callback found at id %d\n", i) 52 | irc.eventsMutex.Unlock() 53 | return false 54 | } 55 | 56 | irc.eventsMutex.Unlock() 57 | irc.Log.Println("Event not found") 58 | return false 59 | } 60 | 61 | // Remove all callbacks from a given event code. It returns true 62 | // if given event code is found and cleared. 63 | func (irc *Connection) ClearCallback(eventcode string) bool { 64 | eventcode = strings.ToUpper(eventcode) 65 | 66 | irc.eventsMutex.Lock() 67 | _, ok := irc.events[eventcode] 68 | if ok { 69 | irc.events[eventcode] = make(map[int]func(*Event)) 70 | irc.eventsMutex.Unlock() 71 | return true 72 | } 73 | irc.eventsMutex.Unlock() 74 | 75 | irc.Log.Println("Event not found") 76 | return false 77 | } 78 | 79 | // Replace callback i (ID) associated with a given event code with a new callback function. 80 | func (irc *Connection) ReplaceCallback(eventcode string, i int, callback func(*Event)) { 81 | eventcode = strings.ToUpper(eventcode) 82 | 83 | irc.eventsMutex.Lock() 84 | event, ok := irc.events[eventcode] 85 | irc.eventsMutex.Unlock() 86 | if ok { 87 | if _, ok := event[i]; ok { 88 | event[i] = callback 89 | return 90 | } 91 | irc.Log.Printf("Event found, but no callback found at id %d\n", i) 92 | } 93 | irc.Log.Printf("Event not found. Use AddCallBack\n") 94 | } 95 | 96 | // Execute all callbacks associated with a given event. 97 | func (irc *Connection) RunCallbacks(event *Event) { 98 | msg := event.Message() 99 | if event.Code == "PRIVMSG" && len(msg) > 2 && msg[0] == '\x01' { 100 | event.Code = "CTCP" //Unknown CTCP 101 | 102 | if i := strings.LastIndex(msg, "\x01"); i > 0 { 103 | msg = msg[1:i] 104 | } else { 105 | irc.Log.Printf("Invalid CTCP Message: %s\n", strconv.Quote(msg)) 106 | return 107 | } 108 | 109 | if msg == "VERSION" { 110 | event.Code = "CTCP_VERSION" 111 | 112 | } else if msg == "TIME" { 113 | event.Code = "CTCP_TIME" 114 | 115 | } else if strings.HasPrefix(msg, "PING") { 116 | event.Code = "CTCP_PING" 117 | 118 | } else if msg == "USERINFO" { 119 | event.Code = "CTCP_USERINFO" 120 | 121 | } else if msg == "CLIENTINFO" { 122 | event.Code = "CTCP_CLIENTINFO" 123 | 124 | } else if strings.HasPrefix(msg, "ACTION") { 125 | event.Code = "CTCP_ACTION" 126 | if len(msg) > 6 { 127 | msg = msg[7:] 128 | } else { 129 | msg = "" 130 | } 131 | } 132 | 133 | event.Arguments[len(event.Arguments)-1] = msg 134 | } 135 | 136 | irc.eventsMutex.Lock() 137 | callbacks := make(map[int]func(*Event)) 138 | eventCallbacks, ok := irc.events[event.Code] 139 | id := 0 140 | if ok { 141 | for _, callback := range eventCallbacks { 142 | callbacks[id] = callback 143 | id++ 144 | } 145 | } 146 | allCallbacks, ok := irc.events["*"] 147 | if ok { 148 | for _, callback := range allCallbacks { 149 | callbacks[id] = callback 150 | id++ 151 | } 152 | } 153 | irc.eventsMutex.Unlock() 154 | 155 | if irc.VerboseCallbackHandler { 156 | irc.Log.Printf("%v (%v) >> %#v\n", event.Code, len(callbacks), event) 157 | } 158 | 159 | event.Ctx = context.Background() 160 | if irc.CallbackTimeout != 0 { 161 | event.Ctx, _ = context.WithTimeout(event.Ctx, irc.CallbackTimeout) 162 | } 163 | 164 | done := make(chan int) 165 | for id, callback := range callbacks { 166 | go func(id int, done chan<- int, cb func(*Event), event *Event) { 167 | start := time.Now() 168 | cb(event) 169 | select { 170 | case done <- id: 171 | case <-event.Ctx.Done(): // If we timed out, report how long until we eventually finished 172 | irc.Log.Printf("Canceled callback %s finished in %s >> %#v\n", 173 | getFunctionName(cb), 174 | time.Since(start), 175 | event, 176 | ) 177 | } 178 | }(id, done, callback, event) 179 | } 180 | 181 | for len(callbacks) > 0 { 182 | select { 183 | case jobID := <-done: 184 | delete(callbacks, jobID) 185 | case <-event.Ctx.Done(): // context timed out! 186 | timedOutCallbacks := []string{} 187 | for _, cb := range callbacks { // Everything left here did not finish 188 | timedOutCallbacks = append(timedOutCallbacks, getFunctionName(cb)) 189 | } 190 | irc.Log.Printf("Timeout while waiting for %d callback(s) to finish (%s)\n", 191 | len(callbacks), 192 | strings.Join(timedOutCallbacks, ", "), 193 | ) 194 | return 195 | } 196 | } 197 | } 198 | 199 | func getFunctionName(f func(*Event)) string { 200 | return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() 201 | } 202 | 203 | // Set up some initial callbacks to handle the IRC/CTCP protocol. 204 | func (irc *Connection) setupCallbacks() { 205 | irc.events = make(map[string]map[int]func(*Event)) 206 | 207 | //Handle ping events 208 | irc.AddCallback("PING", func(e *Event) { irc.SendRaw("PONG :" + e.Message()) }) 209 | 210 | //Version handler 211 | irc.AddCallback("CTCP_VERSION", func(e *Event) { 212 | irc.SendRawf("NOTICE %s :\x01VERSION %s\x01", e.Nick, irc.Version) 213 | }) 214 | 215 | irc.AddCallback("CTCP_USERINFO", func(e *Event) { 216 | irc.SendRawf("NOTICE %s :\x01USERINFO %s\x01", e.Nick, irc.user) 217 | }) 218 | 219 | irc.AddCallback("CTCP_CLIENTINFO", func(e *Event) { 220 | irc.SendRawf("NOTICE %s :\x01CLIENTINFO PING VERSION TIME USERINFO CLIENTINFO\x01", e.Nick) 221 | }) 222 | 223 | irc.AddCallback("CTCP_TIME", func(e *Event) { 224 | ltime := time.Now() 225 | irc.SendRawf("NOTICE %s :\x01TIME %s\x01", e.Nick, ltime.String()) 226 | }) 227 | 228 | irc.AddCallback("CTCP_PING", func(e *Event) { irc.SendRawf("NOTICE %s :\x01%s\x01", e.Nick, e.Message()) }) 229 | 230 | // 437: ERR_UNAVAILRESOURCE " :Nick/channel is temporarily unavailable" 231 | // Add a _ to current nick. If irc.nickcurrent is empty this cannot 232 | // work. It has to be set somewhere first in case the nick is already 233 | // taken or unavailable from the beginning. 234 | irc.AddCallback("437", func(e *Event) { 235 | // If irc.nickcurrent hasn't been set yet, set to irc.nick 236 | if irc.nickcurrent == "" { 237 | irc.nickcurrent = irc.nick 238 | } 239 | 240 | if len(irc.nickcurrent) > 8 { 241 | irc.nickcurrent = "_" + irc.nickcurrent 242 | } else { 243 | irc.nickcurrent = irc.nickcurrent + "_" 244 | } 245 | irc.SendRawf("NICK %s", irc.nickcurrent) 246 | }) 247 | 248 | // 433: ERR_NICKNAMEINUSE " :Nickname is already in use" 249 | // Add a _ to current nick. 250 | irc.AddCallback("433", func(e *Event) { 251 | // If irc.nickcurrent hasn't been set yet, set to irc.nick 252 | if irc.nickcurrent == "" { 253 | irc.nickcurrent = irc.nick 254 | } 255 | 256 | if len(irc.nickcurrent) > 8 { 257 | irc.nickcurrent = "_" + irc.nickcurrent 258 | } else { 259 | irc.nickcurrent = irc.nickcurrent + "_" 260 | } 261 | irc.SendRawf("NICK %s", irc.nickcurrent) 262 | }) 263 | 264 | irc.AddCallback("PONG", func(e *Event) { 265 | ns, _ := strconv.ParseInt(e.Message(), 10, 64) 266 | delta := time.Duration(time.Now().UnixNano() - ns) 267 | if irc.Debug { 268 | irc.Log.Printf("Lag: %.3f s\n", delta.Seconds()) 269 | } 270 | }) 271 | 272 | // NICK Define a nickname. 273 | // Set irc.nickcurrent to the new nick actually used in this connection. 274 | irc.AddCallback("NICK", func(e *Event) { 275 | if e.Nick == irc.nick { 276 | irc.nickcurrent = e.Message() 277 | } 278 | }) 279 | 280 | // 1: RPL_WELCOME "Welcome to the Internet Relay Network !@" 281 | // Set irc.nickcurrent to the actually used nick in this connection. 282 | irc.AddCallback("001", func(e *Event) { 283 | irc.Lock() 284 | irc.nickcurrent = e.Arguments[0] 285 | irc.Unlock() 286 | }) 287 | } 288 | -------------------------------------------------------------------------------- /irc_test.go: -------------------------------------------------------------------------------- 1 | package irc 2 | 3 | import ( 4 | "crypto/tls" 5 | "math/rand" 6 | "sort" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | const server = "irc.freenode.net:6667" 12 | const serverssl = "irc.freenode.net:7000" 13 | const channel = "#go-eventirc-test" 14 | const dict = "abcdefghijklmnopqrstuvwxyz" 15 | 16 | //Spammy 17 | const verbose_tests = false 18 | const debug_tests = true 19 | 20 | func TestConnectionEmtpyServer(t *testing.T) { 21 | irccon := IRC("go-eventirc", "go-eventirc") 22 | err := irccon.Connect("") 23 | if err == nil { 24 | t.Fatal("emtpy server string not detected") 25 | } 26 | } 27 | 28 | func TestConnectionDoubleColon(t *testing.T) { 29 | irccon := IRC("go-eventirc", "go-eventirc") 30 | err := irccon.Connect("::") 31 | if err == nil { 32 | t.Fatal("wrong number of ':' not detected") 33 | } 34 | } 35 | 36 | func TestConnectionMissingHost(t *testing.T) { 37 | irccon := IRC("go-eventirc", "go-eventirc") 38 | err := irccon.Connect(":6667") 39 | if err == nil { 40 | t.Fatal("missing host not detected") 41 | } 42 | } 43 | 44 | func TestConnectionMissingPort(t *testing.T) { 45 | irccon := IRC("go-eventirc", "go-eventirc") 46 | err := irccon.Connect("chat.freenode.net:") 47 | if err == nil { 48 | t.Fatal("missing port not detected") 49 | } 50 | } 51 | 52 | func TestConnectionNegativePort(t *testing.T) { 53 | irccon := IRC("go-eventirc", "go-eventirc") 54 | err := irccon.Connect("chat.freenode.net:-1") 55 | if err == nil { 56 | t.Fatal("negative port number not detected") 57 | } 58 | } 59 | 60 | func TestConnectionTooLargePort(t *testing.T) { 61 | irccon := IRC("go-eventirc", "go-eventirc") 62 | err := irccon.Connect("chat.freenode.net:65536") 63 | if err == nil { 64 | t.Fatal("too large port number not detected") 65 | } 66 | } 67 | 68 | func TestConnectionMissingLog(t *testing.T) { 69 | irccon := IRC("go-eventirc", "go-eventirc") 70 | irccon.Log = nil 71 | err := irccon.Connect("chat.freenode.net:6667") 72 | if err == nil { 73 | t.Fatal("missing 'Log' not detected") 74 | } 75 | } 76 | 77 | func TestConnectionEmptyUser(t *testing.T) { 78 | irccon := IRC("go-eventirc", "go-eventirc") 79 | // user may be changed after creation 80 | irccon.user = "" 81 | err := irccon.Connect("chat.freenode.net:6667") 82 | if err == nil { 83 | t.Fatal("empty 'user' not detected") 84 | } 85 | } 86 | 87 | func TestConnectionEmptyNick(t *testing.T) { 88 | irccon := IRC("go-eventirc", "go-eventirc") 89 | // nick may be changed after creation 90 | irccon.nick = "" 91 | err := irccon.Connect("chat.freenode.net:6667") 92 | if err == nil { 93 | t.Fatal("empty 'nick' not detected") 94 | } 95 | } 96 | 97 | func TestRemoveCallback(t *testing.T) { 98 | irccon := IRC("go-eventirc", "go-eventirc") 99 | debugTest(irccon) 100 | 101 | done := make(chan int, 10) 102 | 103 | irccon.AddCallback("TEST", func(e *Event) { done <- 1 }) 104 | id := irccon.AddCallback("TEST", func(e *Event) { done <- 2 }) 105 | irccon.AddCallback("TEST", func(e *Event) { done <- 3 }) 106 | 107 | // Should remove callback at index 1 108 | irccon.RemoveCallback("TEST", id) 109 | 110 | irccon.RunCallbacks(&Event{ 111 | Code: "TEST", 112 | }) 113 | 114 | var results []int 115 | 116 | results = append(results, <-done) 117 | results = append(results, <-done) 118 | 119 | if !compareResults(results, 1, 3) { 120 | t.Error("Callback 2 not removed") 121 | } 122 | } 123 | 124 | func TestWildcardCallback(t *testing.T) { 125 | irccon := IRC("go-eventirc", "go-eventirc") 126 | debugTest(irccon) 127 | 128 | done := make(chan int, 10) 129 | 130 | irccon.AddCallback("TEST", func(e *Event) { done <- 1 }) 131 | irccon.AddCallback("*", func(e *Event) { done <- 2 }) 132 | 133 | irccon.RunCallbacks(&Event{ 134 | Code: "TEST", 135 | }) 136 | 137 | var results []int 138 | 139 | results = append(results, <-done) 140 | results = append(results, <-done) 141 | 142 | if !compareResults(results, 1, 2) { 143 | t.Error("Wildcard callback not called") 144 | } 145 | } 146 | 147 | func TestClearCallback(t *testing.T) { 148 | irccon := IRC("go-eventirc", "go-eventirc") 149 | debugTest(irccon) 150 | 151 | done := make(chan int, 10) 152 | 153 | irccon.AddCallback("TEST", func(e *Event) { done <- 0 }) 154 | irccon.AddCallback("TEST", func(e *Event) { done <- 1 }) 155 | irccon.ClearCallback("TEST") 156 | irccon.AddCallback("TEST", func(e *Event) { done <- 2 }) 157 | irccon.AddCallback("TEST", func(e *Event) { done <- 3 }) 158 | 159 | irccon.RunCallbacks(&Event{ 160 | Code: "TEST", 161 | }) 162 | 163 | var results []int 164 | 165 | results = append(results, <-done) 166 | results = append(results, <-done) 167 | 168 | if !compareResults(results, 2, 3) { 169 | t.Error("Callbacks not cleared") 170 | } 171 | } 172 | 173 | func TestIRCemptyNick(t *testing.T) { 174 | irccon := IRC("", "go-eventirc") 175 | irccon = nil 176 | if irccon != nil { 177 | t.Error("empty nick didn't result in error") 178 | t.Fail() 179 | } 180 | } 181 | 182 | func TestIRCemptyUser(t *testing.T) { 183 | irccon := IRC("go-eventirc", "") 184 | if irccon != nil { 185 | t.Error("empty user didn't result in error") 186 | } 187 | } 188 | func TestConnection(t *testing.T) { 189 | if testing.Short() { 190 | t.Skip("skipping test in short mode.") 191 | } 192 | rand.Seed(time.Now().UnixNano()) 193 | ircnick1 := randStr(8) 194 | ircnick2 := randStr(8) 195 | irccon1 := IRC(ircnick1, "IRCTest1") 196 | 197 | irccon1.PingFreq = time.Second * 3 198 | 199 | debugTest(irccon1) 200 | 201 | irccon2 := IRC(ircnick2, "IRCTest2") 202 | debugTest(irccon2) 203 | 204 | teststr := randStr(20) 205 | testmsgok := make(chan bool, 1) 206 | 207 | irccon1.AddCallback("001", func(e *Event) { irccon1.Join(channel) }) 208 | irccon2.AddCallback("001", func(e *Event) { irccon2.Join(channel) }) 209 | irccon1.AddCallback("366", func(e *Event) { 210 | go func(e *Event) { 211 | tick := time.NewTicker(1 * time.Second) 212 | i := 10 213 | for { 214 | select { 215 | case <-tick.C: 216 | irccon1.Privmsgf(channel, "%s\n", teststr) 217 | if i == 0 { 218 | t.Errorf("Timeout while wating for test message from the other thread.") 219 | return 220 | } 221 | 222 | case <-testmsgok: 223 | tick.Stop() 224 | irccon1.Quit() 225 | return 226 | } 227 | i -= 1 228 | } 229 | }(e) 230 | }) 231 | 232 | irccon2.AddCallback("366", func(e *Event) { 233 | ircnick2 = randStr(8) 234 | irccon2.Nick(ircnick2) 235 | }) 236 | 237 | irccon2.AddCallback("PRIVMSG", func(e *Event) { 238 | if e.Message() == teststr { 239 | if e.Nick == ircnick1 { 240 | testmsgok <- true 241 | irccon2.Quit() 242 | } else { 243 | t.Errorf("Test message came from an unexpected nickname") 244 | } 245 | } else { 246 | //this may fail if there are other incoming messages, unlikely. 247 | t.Errorf("Test message mismatch") 248 | } 249 | }) 250 | 251 | irccon2.AddCallback("NICK", func(e *Event) { 252 | if irccon2.nickcurrent == ircnick2 { 253 | t.Errorf("Nick change did not work!") 254 | } 255 | }) 256 | 257 | err := irccon1.Connect(server) 258 | if err != nil { 259 | t.Log(err.Error()) 260 | t.Errorf("Can't connect to freenode.") 261 | } 262 | err = irccon2.Connect(server) 263 | if err != nil { 264 | t.Log(err.Error()) 265 | t.Errorf("Can't connect to freenode.") 266 | } 267 | 268 | go irccon2.Loop() 269 | irccon1.Loop() 270 | } 271 | 272 | func TestReconnect(t *testing.T) { 273 | if testing.Short() { 274 | t.Skip("skipping test in short mode.") 275 | } 276 | ircnick1 := randStr(8) 277 | irccon := IRC(ircnick1, "IRCTestRe") 278 | irccon.PingFreq = time.Second * 3 279 | debugTest(irccon) 280 | 281 | connects := 0 282 | irccon.AddCallback("001", func(e *Event) { irccon.Join(channel) }) 283 | 284 | irccon.AddCallback("366", func(e *Event) { 285 | connects += 1 286 | if connects > 2 { 287 | irccon.Privmsgf(channel, "Connection nr %d (test done)\n", connects) 288 | go irccon.Quit() 289 | } else { 290 | irccon.Privmsgf(channel, "Connection nr %d\n", connects) 291 | time.Sleep(100) //Need to let the thraed actually send before closing socket 292 | go irccon.Disconnect() 293 | } 294 | }) 295 | 296 | err := irccon.Connect(server) 297 | if err != nil { 298 | t.Log(err.Error()) 299 | t.Errorf("Can't connect to freenode.") 300 | } 301 | 302 | irccon.Loop() 303 | if connects != 3 { 304 | t.Errorf("Reconnect test failed. Connects = %d", connects) 305 | } 306 | } 307 | 308 | func TestConnectionSSL(t *testing.T) { 309 | if testing.Short() { 310 | t.Skip("skipping test in short mode.") 311 | } 312 | ircnick1 := randStr(8) 313 | irccon := IRC(ircnick1, "IRCTestSSL") 314 | debugTest(irccon) 315 | irccon.UseTLS = true 316 | irccon.TLSConfig = &tls.Config{InsecureSkipVerify: true} 317 | irccon.AddCallback("001", func(e *Event) { irccon.Join(channel) }) 318 | 319 | irccon.AddCallback("366", func(e *Event) { 320 | irccon.Privmsg(channel, "Test Message from SSL\n") 321 | irccon.Quit() 322 | }) 323 | 324 | err := irccon.Connect(serverssl) 325 | if err != nil { 326 | t.Log(err.Error()) 327 | t.Errorf("Can't connect to freenode.") 328 | } 329 | 330 | irccon.Loop() 331 | } 332 | 333 | // Helper Functions 334 | func randStr(n int) string { 335 | b := make([]byte, n) 336 | for i := range b { 337 | b[i] = dict[rand.Intn(len(dict))] 338 | } 339 | return string(b) 340 | } 341 | 342 | func debugTest(irccon *Connection) *Connection { 343 | irccon.VerboseCallbackHandler = verbose_tests 344 | irccon.Debug = debug_tests 345 | return irccon 346 | } 347 | 348 | func compareResults(received []int, desired ...int) bool { 349 | if len(desired) != len(received) { 350 | return false 351 | } 352 | sort.IntSlice(desired).Sort() 353 | sort.IntSlice(received).Sort() 354 | for i := 0; i < len(desired); i++ { 355 | if desired[i] != received[i] { 356 | return false 357 | } 358 | } 359 | return true 360 | } 361 | -------------------------------------------------------------------------------- /irc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2009 Thomas Jager All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | /* 6 | This package provides an event based IRC client library. It allows to 7 | register callbacks for the events you need to handle. Its features 8 | include handling standard CTCP, reconnecting on errors and detecting 9 | stones servers. 10 | Details of the IRC protocol can be found in the following RFCs: 11 | https://tools.ietf.org/html/rfc1459 12 | https://tools.ietf.org/html/rfc2810 13 | https://tools.ietf.org/html/rfc2811 14 | https://tools.ietf.org/html/rfc2812 15 | https://tools.ietf.org/html/rfc2813 16 | The details of the client-to-client protocol (CTCP) can be found here: http://www.irchelp.org/irchelp/rfc/ctcpspec.html 17 | */ 18 | 19 | package irc 20 | 21 | import ( 22 | "bufio" 23 | "bytes" 24 | "crypto/tls" 25 | "errors" 26 | "fmt" 27 | "log" 28 | "net" 29 | "os" 30 | "strconv" 31 | "strings" 32 | "time" 33 | 34 | "golang.org/x/net/proxy" 35 | "golang.org/x/text/encoding" 36 | ) 37 | 38 | const ( 39 | VERSION = "go-ircevent v2.1" 40 | ) 41 | 42 | const CAP_TIMEOUT = time.Second * 15 43 | 44 | var ErrDisconnected = errors.New("Disconnect Called") 45 | 46 | // Read data from a connection. To be used as a goroutine. 47 | func (irc *Connection) readLoop() { 48 | defer irc.Done() 49 | r := irc.Encoding.NewDecoder().Reader(irc.socket) 50 | br := bufio.NewReaderSize(r, 512) 51 | 52 | errChan := irc.ErrorChan() 53 | 54 | for { 55 | select { 56 | case <-irc.end: 57 | return 58 | default: 59 | // Set a read deadline based on the combined timeout and ping frequency 60 | // We should ALWAYS have received a response from the server within the timeout 61 | // after our own pings 62 | if irc.socket != nil { 63 | irc.socket.SetReadDeadline(time.Now().Add(irc.Timeout + irc.PingFreq)) 64 | } 65 | 66 | msg, err := br.ReadString('\n') 67 | 68 | // We got past our blocking read, so bin timeout 69 | if irc.socket != nil { 70 | var zero time.Time 71 | irc.socket.SetReadDeadline(zero) 72 | } 73 | 74 | if err != nil { 75 | errChan <- err 76 | return 77 | } 78 | 79 | if irc.Debug { 80 | irc.Log.Printf("<-- %s\n", strings.TrimSpace(msg)) 81 | } 82 | 83 | irc.lastMessageMutex.Lock() 84 | irc.lastMessage = time.Now() 85 | irc.lastMessageMutex.Unlock() 86 | event, err := parseToEvent(msg) 87 | if err == nil { 88 | event.Connection = irc 89 | irc.RunCallbacks(event) 90 | } 91 | } 92 | } 93 | } 94 | 95 | // Unescape tag values as defined in the IRCv3.2 message tags spec 96 | // http://ircv3.net/specs/core/message-tags-3.2.html 97 | func unescapeTagValue(value string) string { 98 | value = strings.Replace(value, "\\:", ";", -1) 99 | value = strings.Replace(value, "\\s", " ", -1) 100 | value = strings.Replace(value, "\\\\", "\\", -1) 101 | value = strings.Replace(value, "\\r", "\r", -1) 102 | value = strings.Replace(value, "\\n", "\n", -1) 103 | return value 104 | } 105 | 106 | //Parse raw irc messages 107 | func parseToEvent(msg string) (*Event, error) { 108 | msg = strings.TrimSuffix(msg, "\n") //Remove \r\n 109 | msg = strings.TrimSuffix(msg, "\r") 110 | event := &Event{Raw: msg} 111 | if len(msg) < 5 { 112 | return nil, errors.New("Malformed msg from server") 113 | } 114 | 115 | if msg[0] == '@' { 116 | // IRCv3 Message Tags 117 | if i := strings.Index(msg, " "); i > -1 { 118 | event.Tags = make(map[string]string) 119 | tags := strings.Split(msg[1:i], ";") 120 | for _, data := range tags { 121 | parts := strings.SplitN(data, "=", 2) 122 | if len(parts) == 1 { 123 | event.Tags[parts[0]] = "" 124 | } else { 125 | event.Tags[parts[0]] = unescapeTagValue(parts[1]) 126 | } 127 | } 128 | msg = msg[i+1 : len(msg)] 129 | } else { 130 | return nil, errors.New("Malformed msg from server") 131 | } 132 | } 133 | 134 | if msg[0] == ':' { 135 | if i := strings.Index(msg, " "); i > -1 { 136 | event.Source = msg[1:i] 137 | msg = msg[i+1 : len(msg)] 138 | 139 | } else { 140 | return nil, errors.New("Malformed msg from server") 141 | } 142 | 143 | if i, j := strings.Index(event.Source, "!"), strings.Index(event.Source, "@"); i > -1 && j > -1 && i < j { 144 | event.Nick = event.Source[0:i] 145 | event.User = event.Source[i+1 : j] 146 | event.Host = event.Source[j+1 : len(event.Source)] 147 | } 148 | } 149 | 150 | split := strings.SplitN(msg, " :", 2) 151 | args := strings.Split(split[0], " ") 152 | event.Code = strings.ToUpper(args[0]) 153 | event.Arguments = args[1:] 154 | if len(split) > 1 { 155 | event.Arguments = append(event.Arguments, split[1]) 156 | } 157 | return event, nil 158 | 159 | } 160 | 161 | // Loop to write to a connection. To be used as a goroutine. 162 | func (irc *Connection) writeLoop() { 163 | defer irc.Done() 164 | w := irc.Encoding.NewEncoder().Writer(irc.socket) 165 | errChan := irc.ErrorChan() 166 | for { 167 | select { 168 | case <-irc.end: 169 | return 170 | case b, ok := <-irc.pwrite: 171 | if !ok || b == "" || irc.socket == nil { 172 | return 173 | } 174 | 175 | if irc.Debug { 176 | irc.Log.Printf("--> %s\n", strings.TrimSpace(b)) 177 | } 178 | 179 | // Set a write deadline based on the time out 180 | irc.socket.SetWriteDeadline(time.Now().Add(irc.Timeout)) 181 | 182 | _, err := w.Write([]byte(b)) 183 | 184 | // Past blocking write, bin timeout 185 | var zero time.Time 186 | irc.socket.SetWriteDeadline(zero) 187 | 188 | if err != nil { 189 | errChan <- err 190 | return 191 | } 192 | } 193 | } 194 | } 195 | 196 | // Pings the server if we have not received any messages for 5 minutes 197 | // to keep the connection alive. To be used as a goroutine. 198 | func (irc *Connection) pingLoop() { 199 | defer irc.Done() 200 | ticker := time.NewTicker(1 * time.Minute) // Tick every minute for monitoring 201 | ticker2 := time.NewTicker(irc.PingFreq) // Tick at the ping frequency. 202 | for { 203 | select { 204 | case <-ticker.C: 205 | //Ping if we haven't received anything from the server within the keep alive period 206 | irc.lastMessageMutex.Lock() 207 | if time.Since(irc.lastMessage) >= irc.KeepAlive { 208 | irc.SendRawf("PING %d", time.Now().UnixNano()) 209 | } 210 | irc.lastMessageMutex.Unlock() 211 | case <-ticker2.C: 212 | //Ping at the ping frequency 213 | irc.SendRawf("PING %d", time.Now().UnixNano()) 214 | //Try to recapture nickname if it's not as configured. 215 | irc.Lock() 216 | if irc.nick != irc.nickcurrent { 217 | irc.nickcurrent = irc.nick 218 | irc.SendRawf("NICK %s", irc.nick) 219 | } 220 | irc.Unlock() 221 | case <-irc.end: 222 | ticker.Stop() 223 | ticker2.Stop() 224 | return 225 | } 226 | } 227 | } 228 | 229 | func (irc *Connection) isQuitting() bool { 230 | irc.Lock() 231 | defer irc.Unlock() 232 | return irc.quit 233 | } 234 | 235 | // Main loop to control the connection. 236 | func (irc *Connection) Loop() { 237 | errChan := irc.ErrorChan() 238 | for !irc.isQuitting() { 239 | err := <-errChan 240 | if irc.end != nil { 241 | close(irc.end) 242 | } 243 | irc.Wait() 244 | for !irc.isQuitting() { 245 | irc.Log.Printf("Error, disconnected: %s\n", err) 246 | if err = irc.Reconnect(); err != nil { 247 | irc.Log.Printf("Error while reconnecting: %s\n", err) 248 | time.Sleep(60 * time.Second) 249 | } else { 250 | errChan = irc.ErrorChan() 251 | break 252 | } 253 | } 254 | } 255 | } 256 | 257 | // Quit the current connection and disconnect from the server 258 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.1.6 259 | func (irc *Connection) Quit() { 260 | quit := "QUIT" 261 | 262 | if irc.QuitMessage != "" { 263 | quit = fmt.Sprintf("QUIT :%s", irc.QuitMessage) 264 | } 265 | 266 | irc.SendRaw(quit) 267 | irc.Lock() 268 | irc.stopped = true 269 | irc.quit = true 270 | irc.Unlock() 271 | } 272 | 273 | // Use the connection to join a given channel. 274 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.2.1 275 | func (irc *Connection) Join(channel string) { 276 | irc.pwrite <- fmt.Sprintf("JOIN %s\r\n", channel) 277 | } 278 | 279 | // Leave a given channel. 280 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.2.2 281 | func (irc *Connection) Part(channel string) { 282 | irc.pwrite <- fmt.Sprintf("PART %s\r\n", channel) 283 | } 284 | 285 | // Send a notification to a nickname. This is similar to Privmsg but must not receive replies. 286 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.4.2 287 | func (irc *Connection) Notice(target, message string) { 288 | irc.pwrite <- fmt.Sprintf("NOTICE %s :%s\r\n", target, message) 289 | } 290 | 291 | // Send a formated notification to a nickname. 292 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.4.2 293 | func (irc *Connection) Noticef(target, format string, a ...interface{}) { 294 | irc.Notice(target, fmt.Sprintf(format, a...)) 295 | } 296 | 297 | // Send (action) message to a target (channel or nickname). 298 | // No clear RFC on this one... 299 | func (irc *Connection) Action(target, message string) { 300 | irc.pwrite <- fmt.Sprintf("PRIVMSG %s :\001ACTION %s\001\r\n", target, message) 301 | } 302 | 303 | // Send formatted (action) message to a target (channel or nickname). 304 | func (irc *Connection) Actionf(target, format string, a ...interface{}) { 305 | irc.Action(target, fmt.Sprintf(format, a...)) 306 | } 307 | 308 | // Send (private) message to a target (channel or nickname). 309 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.4.1 310 | func (irc *Connection) Privmsg(target, message string) { 311 | irc.pwrite <- fmt.Sprintf("PRIVMSG %s :%s\r\n", target, message) 312 | } 313 | 314 | // Send formated string to specified target (channel or nickname). 315 | func (irc *Connection) Privmsgf(target, format string, a ...interface{}) { 316 | irc.Privmsg(target, fmt.Sprintf(format, a...)) 317 | } 318 | 319 | // Kick from with . For no message, pass empty string ("") 320 | func (irc *Connection) Kick(user, channel, msg string) { 321 | var cmd bytes.Buffer 322 | cmd.WriteString(fmt.Sprintf("KICK %s %s", channel, user)) 323 | if msg != "" { 324 | cmd.WriteString(fmt.Sprintf(" :%s", msg)) 325 | } 326 | cmd.WriteString("\r\n") 327 | irc.pwrite <- cmd.String() 328 | } 329 | 330 | // Kick all from with . For no message, pass 331 | // empty string ("") 332 | func (irc *Connection) MultiKick(users []string, channel string, msg string) { 333 | var cmd bytes.Buffer 334 | cmd.WriteString(fmt.Sprintf("KICK %s %s", channel, strings.Join(users, ","))) 335 | if msg != "" { 336 | cmd.WriteString(fmt.Sprintf(" :%s", msg)) 337 | } 338 | cmd.WriteString("\r\n") 339 | irc.pwrite <- cmd.String() 340 | } 341 | 342 | // Send raw string. 343 | func (irc *Connection) SendRaw(message string) { 344 | irc.pwrite <- message + "\r\n" 345 | } 346 | 347 | // Send raw formated string. 348 | func (irc *Connection) SendRawf(format string, a ...interface{}) { 349 | irc.SendRaw(fmt.Sprintf(format, a...)) 350 | } 351 | 352 | // Set (new) nickname. 353 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.1.2 354 | func (irc *Connection) Nick(n string) { 355 | irc.nick = n 356 | irc.SendRawf("NICK %s", n) 357 | } 358 | 359 | // Determine nick currently used with the connection. 360 | func (irc *Connection) GetNick() string { 361 | return irc.nickcurrent 362 | } 363 | 364 | // Query information about a particular nickname. 365 | // RFC 1459: https://tools.ietf.org/html/rfc1459#section-4.5.2 366 | func (irc *Connection) Whois(nick string) { 367 | irc.SendRawf("WHOIS %s", nick) 368 | } 369 | 370 | // Query information about a given nickname in the server. 371 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.5.1 372 | func (irc *Connection) Who(nick string) { 373 | irc.SendRawf("WHO %s", nick) 374 | } 375 | 376 | // Set different modes for a target (channel or nickname). 377 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.2.3 378 | func (irc *Connection) Mode(target string, modestring ...string) { 379 | if len(modestring) > 0 { 380 | mode := strings.Join(modestring, " ") 381 | irc.SendRawf("MODE %s %s", target, mode) 382 | return 383 | } 384 | irc.SendRawf("MODE %s", target) 385 | } 386 | 387 | func (irc *Connection) ErrorChan() chan error { 388 | return irc.Error 389 | } 390 | 391 | // Returns true if the connection is connected to an IRC server. 392 | func (irc *Connection) Connected() bool { 393 | return !irc.stopped 394 | } 395 | 396 | // A disconnect sends all buffered messages (if possible), 397 | // stops all goroutines and then closes the socket. 398 | func (irc *Connection) Disconnect() { 399 | irc.Lock() 400 | defer irc.Unlock() 401 | 402 | if irc.end != nil { 403 | close(irc.end) 404 | } 405 | 406 | irc.Wait() 407 | 408 | irc.end = nil 409 | 410 | if irc.pwrite != nil { 411 | close(irc.pwrite) 412 | } 413 | 414 | if irc.socket != nil { 415 | irc.socket.Close() 416 | } 417 | irc.ErrorChan() <- ErrDisconnected 418 | } 419 | 420 | // Reconnect to a server using the current connection. 421 | func (irc *Connection) Reconnect() error { 422 | irc.end = make(chan struct{}) 423 | return irc.Connect(irc.Server) 424 | } 425 | 426 | // Connect to a given server using the current connection configuration. 427 | // This function also takes care of identification if a password is provided. 428 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.1 429 | func (irc *Connection) Connect(server string) error { 430 | irc.Server = server 431 | // mark Server as stopped since there can be an error during connect 432 | irc.stopped = true 433 | 434 | // make sure everything is ready for connection 435 | if len(irc.Server) == 0 { 436 | return errors.New("empty 'server'") 437 | } 438 | if strings.Index(irc.Server, ":") == 0 { 439 | return errors.New("hostname is missing") 440 | } 441 | if strings.Index(irc.Server, ":") == len(irc.Server)-1 { 442 | return errors.New("port missing") 443 | } 444 | _, ports, err := net.SplitHostPort(irc.Server) 445 | if err != nil { 446 | return errors.New("wrong address string") 447 | } 448 | port, err := strconv.Atoi(ports) 449 | if err != nil { 450 | return errors.New("extracting port failed") 451 | } 452 | if !((port >= 0) && (port <= 65535)) { 453 | return errors.New("port number outside valid range") 454 | } 455 | if irc.Log == nil { 456 | return errors.New("'Log' points to nil") 457 | } 458 | if len(irc.nick) == 0 { 459 | return errors.New("empty 'nick'") 460 | } 461 | if len(irc.user) == 0 { 462 | return errors.New("empty 'user'") 463 | } 464 | 465 | dialer := proxy.FromEnvironmentUsing(&net.Dialer{Timeout: irc.Timeout}) 466 | irc.socket, err = dialer.Dial("tcp", irc.Server) 467 | if err != nil { 468 | return err 469 | } 470 | if irc.UseTLS { 471 | irc.socket = tls.Client(irc.socket, irc.TLSConfig) 472 | } 473 | 474 | if irc.Encoding == nil { 475 | irc.Encoding = encoding.Nop 476 | } 477 | 478 | irc.stopped = false 479 | irc.Log.Printf("Connected to %s (%s)\n", irc.Server, irc.socket.RemoteAddr()) 480 | 481 | irc.pwrite = make(chan string, 10) 482 | irc.Error = make(chan error, 10) 483 | irc.Add(3) 484 | go irc.readLoop() 485 | go irc.writeLoop() 486 | go irc.pingLoop() 487 | 488 | if len(irc.WebIRC) > 0 { 489 | irc.pwrite <- fmt.Sprintf("WEBIRC %s\r\n", irc.WebIRC) 490 | } 491 | 492 | if len(irc.Password) > 0 { 493 | irc.pwrite <- fmt.Sprintf("PASS %s\r\n", irc.Password) 494 | } 495 | 496 | err = irc.negotiateCaps() 497 | if err != nil { 498 | return err 499 | } 500 | 501 | realname := irc.user 502 | if irc.RealName != "" { 503 | realname = irc.RealName 504 | } 505 | 506 | irc.pwrite <- fmt.Sprintf("NICK %s\r\n", irc.nick) 507 | irc.pwrite <- fmt.Sprintf("USER %s 0.0.0.0 0.0.0.0 :%s\r\n", irc.user, realname) 508 | return nil 509 | } 510 | 511 | // Negotiate IRCv3 capabilities 512 | func (irc *Connection) negotiateCaps() error { 513 | irc.RequestCaps = nil 514 | irc.AcknowledgedCaps = nil 515 | 516 | var negotiationCallbacks []CallbackID 517 | defer func() { 518 | for _, callback := range negotiationCallbacks { 519 | irc.RemoveCallback(callback.EventCode, callback.ID) 520 | } 521 | }() 522 | 523 | saslResChan := make(chan *SASLResult) 524 | if irc.UseSASL { 525 | irc.RequestCaps = append(irc.RequestCaps, "sasl") 526 | negotiationCallbacks = irc.setupSASLCallbacks(saslResChan) 527 | } 528 | 529 | if len(irc.RequestCaps) == 0 { 530 | return nil 531 | } 532 | 533 | cap_chan := make(chan bool, len(irc.RequestCaps)) 534 | id := irc.AddCallback("CAP", func(e *Event) { 535 | if len(e.Arguments) != 3 { 536 | return 537 | } 538 | command := e.Arguments[1] 539 | 540 | if command == "LS" { 541 | missing_caps := len(irc.RequestCaps) 542 | for _, cap_name := range strings.Split(e.Arguments[2], " ") { 543 | for _, req_cap := range irc.RequestCaps { 544 | if cap_name == req_cap { 545 | irc.pwrite <- fmt.Sprintf("CAP REQ :%s\r\n", cap_name) 546 | missing_caps-- 547 | } 548 | } 549 | } 550 | 551 | for i := 0; i < missing_caps; i++ { 552 | cap_chan <- true 553 | } 554 | } else if command == "ACK" || command == "NAK" { 555 | for _, cap_name := range strings.Split(strings.TrimSpace(e.Arguments[2]), " ") { 556 | if cap_name == "" { 557 | continue 558 | } 559 | 560 | if command == "ACK" { 561 | irc.AcknowledgedCaps = append(irc.AcknowledgedCaps, cap_name) 562 | } 563 | cap_chan <- true 564 | } 565 | } 566 | }) 567 | negotiationCallbacks = append(negotiationCallbacks, CallbackID{"CAP", id}) 568 | 569 | irc.pwrite <- "CAP LS\r\n" 570 | 571 | if irc.UseSASL { 572 | select { 573 | case res := <-saslResChan: 574 | if res.Failed { 575 | return res.Err 576 | } 577 | case <-time.After(CAP_TIMEOUT): 578 | // Raise an error if we can't authenticate with SASL. 579 | return errors.New("SASL setup timed out. Does the server support SASL?") 580 | } 581 | } 582 | 583 | remaining_caps := len(irc.RequestCaps) 584 | 585 | select { 586 | case <-cap_chan: 587 | remaining_caps-- 588 | case <-time.After(CAP_TIMEOUT): 589 | // The server probably doesn't implement CAP LS, which is "normal". 590 | return nil 591 | } 592 | 593 | // Wait for all capabilities to be ACKed or NAKed before ending negotiation 594 | for remaining_caps > 0 { 595 | <-cap_chan 596 | remaining_caps-- 597 | } 598 | 599 | irc.pwrite <- fmt.Sprintf("CAP END\r\n") 600 | 601 | return nil 602 | } 603 | 604 | // Create a connection with the (publicly visible) nickname and username. 605 | // The nickname is later used to address the user. Returns nil if nick 606 | // or user are empty. 607 | func IRC(nick, user string) *Connection { 608 | // catch invalid values 609 | if len(nick) == 0 { 610 | return nil 611 | } 612 | if len(user) == 0 { 613 | return nil 614 | } 615 | 616 | irc := &Connection{ 617 | nick: nick, 618 | nickcurrent: nick, 619 | user: user, 620 | Log: log.New(os.Stdout, "", log.LstdFlags), 621 | end: make(chan struct{}), 622 | Version: VERSION, 623 | KeepAlive: 4 * time.Minute, 624 | Timeout: 1 * time.Minute, 625 | PingFreq: 15 * time.Minute, 626 | SASLMech: "PLAIN", 627 | QuitMessage: "", 628 | } 629 | irc.setupCallbacks() 630 | return irc 631 | } 632 | --------------------------------------------------------------------------------