├── .check-gofmt.sh ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .travis.gofmt.sh ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── doc.go ├── go.mod ├── go.sum ├── ircevent ├── LICENSE ├── README.markdown ├── examples │ ├── proxy.go │ ├── simple.go │ └── stress.go ├── irc.go ├── irc_callback.go ├── irc_ctcp.go ├── irc_labeledresponse_test.go ├── irc_parse_test.go ├── irc_sasl.go ├── irc_sasl_test.go ├── irc_struct.go ├── irc_test.go └── numerics.go ├── ircfmt ├── doc.go ├── ircfmt.go └── ircfmt_test.go ├── ircmsg ├── doc.go ├── message.go ├── message_test.go ├── tags.go ├── tags_test.go ├── unicode.go ├── unicode_test.go ├── userhost.go └── userhost_test.go ├── ircreader ├── ircreader.go └── ircreader_test.go └── ircutils ├── doc.go ├── hostnames.go ├── sasl.go ├── sasl_test.go ├── unicode.go └── unicode_test.go /.check-gofmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SOURCES="." 4 | 5 | if [ "$1" = "--fix" ]; then 6 | exec gofmt -s -w $SOURCES 7 | fi 8 | 9 | if [ -n "$(gofmt -s -l $SOURCES)" ]; then 10 | echo "Go code is not formatted correctly with \`gofmt -s\`:" 11 | gofmt -s -d $SOURCES 12 | exit 1 13 | fi 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "build" 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "master" 7 | push: 8 | branches: 9 | - "master" 10 | 11 | jobs: 12 | build: 13 | runs-on: "ubuntu-22.04" 14 | steps: 15 | - name: "checkout repository" 16 | uses: "actions/checkout@v3" 17 | - name: "setup go" 18 | uses: "actions/setup-go@v3" 19 | with: 20 | go-version: "1.20" 21 | - name: "make test" 22 | run: "make test" 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/windows,osx,linux,go 3 | 4 | ### Windows ### 5 | # Windows image file caches 6 | Thumbs.db 7 | ehthumbs.db 8 | 9 | # Folder config file 10 | Desktop.ini 11 | 12 | # Recycle Bin used on file shares 13 | $RECYCLE.BIN/ 14 | 15 | # Windows Installer files 16 | *.cab 17 | *.msi 18 | *.msm 19 | *.msp 20 | 21 | # Windows shortcuts 22 | *.lnk 23 | 24 | 25 | ### OSX ### 26 | .DS_Store 27 | .AppleDouble 28 | .LSOverride 29 | 30 | # Icon must end with two \r 31 | Icon 32 | 33 | # Thumbnails 34 | ._* 35 | 36 | # Files that might appear in the root of a volume 37 | .DocumentRevisions-V100 38 | .fseventsd 39 | .Spotlight-V100 40 | .TemporaryItems 41 | .Trashes 42 | .VolumeIcon.icns 43 | 44 | # Directories potentially created on remote AFP share 45 | .AppleDB 46 | .AppleDesktop 47 | Network Trash Folder 48 | Temporary Items 49 | .apdisk 50 | 51 | 52 | ### Linux ### 53 | *~ 54 | 55 | # temporary files which can be created if a process still has a handle open of a deleted file 56 | .fuse_hidden* 57 | 58 | # KDE directory preferences 59 | .directory 60 | 61 | # Linux trash folder which might appear on any partition or disk 62 | .Trash-* 63 | 64 | 65 | ### Go ### 66 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 67 | *.o 68 | *.a 69 | *.so 70 | 71 | # Folders 72 | _obj 73 | _test 74 | 75 | # Architecture specific extensions/prefixes 76 | *.[568vq] 77 | [568vq].out 78 | 79 | *.cgo1.go 80 | *.cgo2.c 81 | _cgo_defun.c 82 | _cgo_gotypes.go 83 | _cgo_export.* 84 | 85 | _testmain.go 86 | 87 | *.exe 88 | *.test 89 | *.prof 90 | 91 | # vim swapfiles 92 | *.swp 93 | -------------------------------------------------------------------------------- /.travis.gofmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SOURCES="." 4 | 5 | if [ -n "$(gofmt -s -l $SOURCES)" ]; then 6 | echo "Go code is not formatted correctly with \`gofmt -s\`:" 7 | gofmt -s -d $SOURCES 8 | exit 1 9 | fi 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - tip 4 | before_install: 5 | - go get -t ./... 6 | - go get github.com/axw/gocov/gocov 7 | - go get github.com/mattn/goveralls 8 | - if ! go get github.com/golang/tools/cmd/cover; then go get golang.org/x/tools/cmd/cover; fi 9 | - go get github.com/pierrre/gotestcover 10 | script: 11 | - gotestcover -coverprofile=cover.out ./... 12 | - $HOME/gopath/bin/goveralls -service=travis-ci -coverprofile cover.out 13 | - make test 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to irc-go will be documented in this file. 3 | 4 | ## [0.4.0] - 2023-06-14 5 | 6 | irc-go v0.4.0 is a new tagged release. It incorporates enhancements to `ircmsg`, our IRC protocol handling library, and `ircfmt`, our library for handling [IRC formatting codes](https://modern.ircdocs.horse/formatting.html). There are no API breaks relative to previous tagged versions. 7 | 8 | ### Changed 9 | * `ircmsg.ParseLineStrict` now does UTF8-aware truncation of the parsed message, using the same algorithm as `ircmsg.LineBytesStrict` (if the truncated message is invalid as UTF8, up to 3 additional bytes are removed in an attempt to make it valid) 10 | * `TruncateUTF8Safe` was moved from `ircutils` to `ircmsg`. (An alias is provided in `ircutils` for compatibility.) 11 | 12 | ### Added 13 | * `ircfmt.Unescape` now accepts the American spellings "gray" and "light gray", in addition to "grey" and "light grey" 14 | 15 | 16 | ## [0.3.0] - 2023-02-13 17 | 18 | irc-go v0.3.0 is a new tagged release. It incorporates enhancements to `ircevent`, our IRC client library, and `ircfmt`, our library for handling [IRC formatting codes](https://modern.ircdocs.horse/formatting.html). There are no API breaks relative to previous tagged versions. 19 | 20 | Thanks to [@kofany](https://github.com/kofany) for helpful discussions. 21 | 22 | ### Added 23 | * Added `(*ircevent.Connection).DialContext`, an optional callback for customizing how ircevent creates IRC connections. Clients can create a custom `net.Dialer` instance and pass in its `DialContext` method, or use a callback that invokes a proxy, e.g. a SOCKS proxy (see `ircevent/examples/proxy.go` for an example). (#64, #91) 24 | * Added `ircfmt.Split()`, which splits an IRC message containing formatting codes into a machine-readable representation (a slice of `ircfmt.FormattedSubstring`). (#89) 25 | * Added `ircfmt.ParseColor()`, which parses an IRC color code string into a machine-readable representation (an `ircfmt.ColorCode`). (#89, #92) 26 | 27 | ### Fixed 28 | * Fixed some edge cases in `ircfmt.Strip()` (#89) 29 | 30 | ## [0.2.0] - 2022-06-22 31 | 32 | irc-go v0.2.0 is a new tagged release, incorporating enhancements to `ircevent`, our IRC client library. There are no API breaks relative to v0.1.0. 33 | 34 | Thanks to [@ludviglundgren](https://github.com/ludviglundgren), [@Mikaela](https://github.com/Mikaela), and [@progval](https://github.com/progval) for helpful discussions, testing, and code reviews. 35 | 36 | ### Added 37 | * Added `(*ircevent.Connection).GetLabeledReponse`, a synchronous API for getting a [labeled message response](https://ircv3.net/specs/extensions/labeled-response). (#74, thanks [@progval](https://github.com/progval)!) 38 | * Added `(*ircevent.Connection).AddDisconnectCallback`, which allows registering callbacks that are invoked whenever ircevent detects disconnection from the server. (#78, #80, thanks [@ludviglundgren](https://github.com/ludviglundgren)!) 39 | * Added `(ircevent.Connection).SASLOptional`; when set to true, this makes failure to SASL non-fatal, which can simplify compatibility with legacy services implementations (#78, #83, thanks [@ludviglundgren](https://github.com/ludviglundgren)!) 40 | * `ircevent` now exposes most commonly used numerics as package constants, e.g. `ircevent.RPL_WHOISUSER` (`311`) 41 | 42 | ### Fixed 43 | * Calling `(*ircevent.Connection).Reconnect` now takes immediate effect, even if the client is waiting for `ReconnectFreq` to expire (i.e. automatic reconnection has been throttled) (#79) 44 | * `(*ircevent.Connection).CurrentNick()` now returns the correct value when called from a `NICK` callback (#78, #84, thanks [@ludviglundgren](https://github.com/ludviglundgren)!) 45 | 46 | ## [0.1.0] - 2022-01-19 47 | 48 | irc-go v0.1.0 is our first tagged release. Although the project is not yet API-stable, we envision this as the first step towards full API stability. All API breaks will be documented in this changelog; we expect any such breaks to be modest in scope. 49 | 50 | ### Added 51 | * Added `(*ircmsg.Message).Nick()` and `(*ircmsg.Message).NUH()`, which permissively interpret the source of the message as a NUH. `Nick()` returns the name component of the source (either nickname or server name) and `NUH` returns all three components (name, username, and hostname) as an `ircmsg.NUH`. (#67, #66, #58) 52 | 53 | ### Changed 54 | * The source/prefix of the message is now parsed into `(ircmsg.Message).Source`, instead of `(ircmsg.Message).Prefix` (#68) 55 | * `ircevent.ExtractNick()` and `ircevent.SplitNUH()` are deprecated in favor of `(*ircmsg.Message).Nick()` and `(*ircmsg.Message).NUH()` respectively 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2021 Daniel Oaks 2 | Copyright (c) 2018-2021 Shivaram Lingamneni 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any 5 | purpose with or without fee is hereby granted, provided that the above 6 | copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 10 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 13 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 | PERFORMANCE OF THIS SOFTWARE. 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test ircevent gofmt 2 | 3 | test: 4 | cd ircfmt && go test . && go vet . 5 | cd ircmsg && go test . && go vet . 6 | cd ircreader && go test . && go vet . 7 | cd ircutils && go test . && go vet . 8 | $(info Note: ircevent must be tested separately) 9 | ./.check-gofmt.sh 10 | 11 | # ircevent requires a local ircd for testing, plus some env vars: 12 | # IRCEVENT_SASL_LOGIN and IRCEVENT_SASL_PASSWORD 13 | ircevent: 14 | cd ircevent && go test . && go vet . 15 | ./.check-gofmt.sh 16 | 17 | gofmt: 18 | ./.check-gofmt.sh --fix 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ergochat/irc-go 2 | 3 | These are libraries to help in writing IRC clients and servers in Go, prioritizing correctness, safety, and [IRCv3 support](https://ircv3.net/). They are not fully API-stable, but we expect any API breaks to be modest in scope. 4 | 5 | --- 6 | 7 | [![GoDoc](https://godoc.org/github.com/ergochat/irc-go?status.svg)](https://godoc.org/github.com/ergochat/irc-go) 8 | [![Build Status](https://travis-ci.org/ergochat/irc-go.svg?branch=master)](https://travis-ci.org/ergochat/irc-go) 9 | [![Coverage Status](https://coveralls.io/repos/ergochat/irc-go/badge.svg?branch=master&service=github)](https://coveralls.io/github/ergochat/irc-go?branch=master) 10 | [![Go Report Card](https://goreportcard.com/badge/github.com/ergochat/irc-go)](https://goreportcard.com/report/github.com/ergochat/irc-go) 11 | 12 | --- 13 | 14 | Packages: 15 | 16 | * [**ircmsg**](https://godoc.org/github.com/ergochat/irc-go/ircmsg): IRC message handling, raw line parsing and creation. 17 | * [**ircreader**](https://godoc.org/github.com/ergochat/irc-go/ircreader): Optimized reader for \n-terminated lines, with an expanding but bounded buffer. 18 | * [**ircevent**](https://godoc.org/github.com/ergochat/irc-go/ircevent): IRC client library (fork of [thoj/go-ircevent](https://github.com/thoj/go-ircevent)). 19 | * [**ircfmt**](https://godoc.org/github.com/ergochat/irc-go/ircfmt): IRC format codes handling, escaping and unescaping. 20 | * [**ircutils**](https://godoc.org/github.com/ergochat/irc-go/ircutils): Useful utility functions and classes that don't fit into their own packages. 21 | 22 | For a relatively complete example of the library's use, see [slingamn/titlebot](https://github.com/slingamn/titlebot). 23 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // written by Daniel Oaks 2 | // released under the ISC license 3 | 4 | /* 5 | ergochat/irc-go has useful, self-contained packages that help with IRC 6 | development. These packages are split up so you can easily choose which ones to 7 | use while ignoring the others, handling things like simplifying formatting 8 | codes, parsing and creating raw IRC lines, and event management. 9 | */ 10 | package ircgo 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ergochat/irc-go 2 | 3 | go 1.15 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ergochat/irc-go/9beac2d29dc5f998c5a53e5db7a6426d7d083a79/go.sum -------------------------------------------------------------------------------- /ircevent/LICENSE: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2009 Thomas Jager. All rights reserved. 2 | // Copyright (c) 2021 Shivaram Lingamneni. 3 | // 4 | // Redistribution and use in source and binary forms, with or without 5 | // modification, are permitted provided that the following conditions are 6 | // met: 7 | // 8 | // * Redistributions of source code must retain the above copyright 9 | // notice, this list of conditions and the following disclaimer. 10 | // * Redistributions in binary form must reproduce the above 11 | // copyright notice, this list of conditions and the following disclaimer 12 | // in the documentation and/or other materials provided with the 13 | // distribution. 14 | // * Neither the name of Google Inc. nor the names of its 15 | // contributors may be used to endorse or promote products derived from 16 | // this software without specific prior written permission. 17 | // 18 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /ircevent/README.markdown: -------------------------------------------------------------------------------- 1 | Description 2 | ----------- 3 | 4 | This is an event-based IRC client library. It is a fork of [thoj/go-ircevent](https://github.com/thoj-ircevent). 5 | 6 | Features 7 | -------- 8 | * Event-based: register callbacks for IRC commands 9 | * Handles reconnections 10 | * Supports SASL 11 | * Supports requesting [IRCv3 capabilities](https://ircv3.net/specs/core/capability-negotiation) 12 | * Advanced IRCv3 support, including [batch](https://ircv3.net/specs/extensions/batch) and [labeled-response](https://ircv3.net/specs/extensions/labeled-response) 13 | 14 | Example 15 | ------- 16 | See [examples/simple.go](examples/simple.go) for a working example, but this illustrates the API: 17 | 18 | ```go 19 | irc := ircevent.Connection{ 20 | Server: "testnet.ergo.chat:6697", 21 | UseTLS: true, 22 | Nick: "ircevent-test", 23 | Debug: true, 24 | RequestCaps: []string{"server-time", "message-tags"}, 25 | } 26 | 27 | irc.AddConnectCallback(func(e ircmsg.Message) { irc.Join("#ircevent-test") }) 28 | 29 | irc.AddCallback("PRIVMSG", func(event ircmsg.Message) { 30 | // event.Source is the source; 31 | // event.Params[0] is the target (the channel or nickname the message was sent to) 32 | // and event.Params[1] is the message itself 33 | }); 34 | 35 | err := irc.Connect() 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | irc.Loop() 40 | ``` 41 | 42 | The read loop executes all callbacks in serial on a single goroutine, respecting 43 | the order in which messages are received from the server. All callbacks must 44 | complete before the next message can be processed; if your callback needs to 45 | trigger a long-running task, you should spin off a new goroutine for it. 46 | 47 | Commands 48 | -------- 49 | These methods can be used from inside callbacks, or externally: 50 | 51 | irc.Send(command, params...) 52 | irc.SendWithTags(tags, command, params...) 53 | irc.Join(channel) 54 | irc.Privmsg(target, message) 55 | irc.Privmsgf(target, formatString, params...) 56 | 57 | The `ircevent.Connection` object is synchronized internally, so these methods 58 | can be run from any goroutine without external locking. 59 | -------------------------------------------------------------------------------- /ircevent/examples/proxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "log" 6 | "net/url" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | "golang.org/x/net/proxy" 12 | 13 | "github.com/ergochat/irc-go/ircevent" 14 | "github.com/ergochat/irc-go/ircmsg" 15 | ) 16 | 17 | func getenv(key, defaultValue string) (value string) { 18 | value = os.Getenv(key) 19 | if value == "" { 20 | value = defaultValue 21 | } 22 | return 23 | } 24 | 25 | func main() { 26 | nick := getenv("IRCEVENT_NICK", "robot") 27 | server := getenv("IRCEVENT_SERVER", "testnet.oragono.io:6697") 28 | channel := getenv("IRCEVENT_CHANNEL", "#ircevent-test") 29 | saslLogin := os.Getenv("IRCEVENT_SASL_LOGIN") 30 | saslPassword := os.Getenv("IRCEVENT_SASL_PASSWORD") 31 | 32 | proxyAddr := getenv("IRCEVENT_PROXY_URL", "socks5://192.168.1.100:8080") 33 | proxyUrl, err := url.Parse(proxyAddr) 34 | if err != nil { 35 | log.Fatalf("invalid proxy URL: %v\n", err) 36 | } 37 | proxyDialer, err := proxy.FromURL(proxyUrl, proxy.Direct) 38 | if err != nil { 39 | log.Fatalf("couldn't connect to proxy server: %v\n", err) 40 | } 41 | proxyContextDialer, ok := proxyDialer.(proxy.ContextDialer) 42 | if !ok { 43 | log.Fatalf("proxy dialer does not expose DialContext(): %v\n", proxyDialer) 44 | } 45 | 46 | irc := ircevent.Connection{ 47 | Server: server, 48 | DialContext: proxyContextDialer.DialContext, 49 | Nick: nick, 50 | Debug: true, 51 | UseTLS: true, 52 | TLSConfig: &tls.Config{InsecureSkipVerify: true}, 53 | RequestCaps: []string{"server-time", "message-tags"}, 54 | SASLLogin: saslLogin, // SASL will be enabled automatically if these are set 55 | SASLPassword: saslPassword, 56 | } 57 | 58 | irc.AddConnectCallback(func(e ircmsg.Message) { 59 | // attempt to set the BOT mode on ourself: 60 | if botMode := irc.ISupport()["BOT"]; botMode != "" { 61 | irc.Send("MODE", irc.CurrentNick(), "+"+botMode) 62 | } 63 | irc.Join(channel) 64 | }) 65 | irc.AddCallback("JOIN", func(e ircmsg.Message) {}) // TODO try to rejoin if we *don't* get this 66 | irc.AddCallback("PRIVMSG", func(e ircmsg.Message) { 67 | if len(e.Params) < 2 { 68 | return 69 | } 70 | text := e.Params[1] 71 | if strings.HasPrefix(text, nick) { 72 | irc.Privmsg(e.Params[0], "don't @ me, fleshbag") 73 | } else if text == "xyzzy" { 74 | // this causes the server to disconnect us and the program to exit 75 | irc.Quit() 76 | } else if text == "plugh" { 77 | // this causes the server to disconnect us, but the client will reconnect 78 | irc.Send("QUIT", "I'LL BE BACK") 79 | } else if text == "wwssadadba" { 80 | // this line intentionally panics; the client will recover from it 81 | irc.Privmsg(e.Params[0], e.Params[2]) 82 | } 83 | }) 84 | // example client-to-client extension via message-tags: 85 | // have the bot maintain a running sum of integers 86 | var sum int64 // doesn't need synchronization as long as it's only visible from a single callback 87 | irc.AddCallback("TAGMSG", func(e ircmsg.Message) { 88 | _, tv := e.GetTag("+summand") 89 | if v, err := strconv.ParseInt(tv, 10, 64); err == nil { 90 | sum += v 91 | irc.SendWithTags(map[string]string{"+sum": strconv.FormatInt(sum, 10)}, "TAGMSG", e.Params[0]) 92 | } 93 | }) 94 | err = irc.Connect() 95 | if err != nil { 96 | log.Fatal(err) 97 | } 98 | irc.Loop() 99 | } 100 | -------------------------------------------------------------------------------- /ircevent/examples/simple.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "log" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/ergochat/irc-go/ircevent" 11 | "github.com/ergochat/irc-go/ircmsg" 12 | ) 13 | 14 | func getenv(key, defaultValue string) (value string) { 15 | value = os.Getenv(key) 16 | if value == "" { 17 | value = defaultValue 18 | } 19 | return 20 | } 21 | 22 | func main() { 23 | nick := getenv("IRCEVENT_NICK", "robot") 24 | server := getenv("IRCEVENT_SERVER", "testnet.oragono.io:6697") 25 | channel := getenv("IRCEVENT_CHANNEL", "#ircevent-test") 26 | saslLogin := os.Getenv("IRCEVENT_SASL_LOGIN") 27 | saslPassword := os.Getenv("IRCEVENT_SASL_PASSWORD") 28 | 29 | irc := ircevent.Connection{ 30 | Server: server, 31 | Nick: nick, 32 | Debug: true, 33 | UseTLS: true, 34 | TLSConfig: &tls.Config{InsecureSkipVerify: true}, 35 | RequestCaps: []string{"server-time", "message-tags"}, 36 | SASLLogin: saslLogin, // SASL PLAIN will be enabled automatically if these are set 37 | SASLPassword: saslPassword, 38 | } 39 | 40 | if certKeyFile := os.Getenv("IRCEVENT_SASL_CLIENTCERT"); certKeyFile != "" { 41 | clientCert, err := tls.LoadX509KeyPair(certKeyFile, certKeyFile) 42 | if err != nil { 43 | log.Fatalf("could not load client certificate: %v", err) 44 | } 45 | irc.TLSConfig.Certificates = []tls.Certificate{clientCert} 46 | irc.SASLMech = "EXTERNAL" // overrides automatic SASL PLAIN 47 | } 48 | 49 | irc.AddConnectCallback(func(e ircmsg.Message) { 50 | // attempt to set the BOT mode on ourself: 51 | if botMode := irc.ISupport()["BOT"]; botMode != "" { 52 | irc.Send("MODE", irc.CurrentNick(), "+"+botMode) 53 | } 54 | irc.Join(channel) 55 | }) 56 | irc.AddCallback("JOIN", func(e ircmsg.Message) {}) // TODO try to rejoin if we *don't* get this 57 | irc.AddCallback("PRIVMSG", func(e ircmsg.Message) { 58 | if len(e.Params) < 2 { 59 | return 60 | } 61 | text := e.Params[1] 62 | if strings.HasPrefix(text, nick) { 63 | irc.Privmsg(e.Params[0], "don't @ me, fleshbag") 64 | } else if text == "xyzzy" { 65 | // this causes the server to disconnect us and the program to exit 66 | irc.Quit() 67 | } else if text == "plugh" { 68 | // this causes the server to disconnect us, but the client will reconnect 69 | irc.Send("QUIT", "I'LL BE BACK") 70 | } else if text == "wwssadadba" { 71 | // this line intentionally panics; the client will recover from it 72 | irc.Privmsg(e.Params[0], e.Params[2]) 73 | } 74 | }) 75 | // example client-to-client extension via message-tags: 76 | // have the bot maintain a running sum of integers 77 | var sum int64 // doesn't need synchronization as long as it's only visible from a single callback 78 | irc.AddCallback("TAGMSG", func(e ircmsg.Message) { 79 | _, tv := e.GetTag("+summand") 80 | if v, err := strconv.ParseInt(tv, 10, 64); err == nil { 81 | sum += v 82 | irc.SendWithTags(map[string]string{"+sum": strconv.FormatInt(sum, 10)}, "TAGMSG", e.Params[0]) 83 | } 84 | }) 85 | err := irc.Connect() 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | irc.Loop() 90 | } 91 | -------------------------------------------------------------------------------- /ircevent/examples/stress.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | 8 | "net/http" 9 | _ "net/http/pprof" 10 | 11 | "github.com/ergochat/irc-go/ircevent" 12 | "github.com/ergochat/irc-go/ircmsg" 13 | ) 14 | 15 | /* 16 | Flooding stress test (responds to its own echo messages in a loop); 17 | don't run this against a real IRC server! 18 | */ 19 | 20 | func getenv(key, defaultValue string) (value string) { 21 | value = os.Getenv(key) 22 | if value == "" { 23 | value = defaultValue 24 | } 25 | return 26 | } 27 | 28 | func main() { 29 | ps := http.Server{ 30 | Addr: getenv("IRCEVENT_PPROF_LISTENER", "localhost:6077"), 31 | } 32 | go func() { 33 | if err := ps.ListenAndServe(); err != nil { 34 | log.Fatal(err) 35 | } 36 | }() 37 | 38 | nick := getenv("IRCEVENT_NICK", "chatterbox") 39 | server := getenv("IRCEVENT_SERVER", "localhost:6667") 40 | channel := getenv("IRCEVENT_CHANNEL", "#ircevent-test") 41 | limit := 0 42 | if envLimit, err := strconv.Atoi(os.Getenv("IRCEVENT_LIMIT")); err == nil { 43 | limit = envLimit 44 | } 45 | 46 | irc := ircevent.Connection{ 47 | Server: server, 48 | Nick: nick, 49 | RequestCaps: []string{"server-time", "echo-message"}, 50 | } 51 | 52 | irc.AddCallback("001", func(e ircmsg.Message) { irc.Join(channel) }) 53 | irc.AddCallback("JOIN", func(e ircmsg.Message) { irc.Privmsg(channel, "hi there friend!") }) 54 | // echo whatever we get back 55 | count := 0 56 | irc.AddCallback("PRIVMSG", func(e ircmsg.Message) { 57 | if limit != 0 && count >= limit { 58 | irc.Quit() 59 | } else { 60 | irc.Privmsg(e.Params[0], e.Params[1]) 61 | count++ 62 | } 63 | }) 64 | err := irc.Connect() 65 | if err != nil { 66 | log.Fatal(err) 67 | } 68 | irc.Loop() 69 | } 70 | -------------------------------------------------------------------------------- /ircevent/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 | Here's the concurrency design of this project (largely unchanged from thoj/go-ircevent): 7 | Connect() spawns 3 goroutines (readLoop, writeLoop, pingLoop). The client then 8 | calls Loop(), which monitors their state. Loop() will wait for them 9 | to make a clean stop and then run another Connect(). The system can be 10 | interrupted asynchronously by sending a message, e.g, with Privmsg(), or by 11 | calling Reconnect() (which disconnects and forces a reconnection), or by calling 12 | Quit(), which sends QUIT to the server and will eventually stop the Loop(). 13 | 14 | The stop mechanism is to close the (*Connection).end channel (which is only closed, 15 | never sent-on normally), so every blocking operation in the 3 loops must also 16 | select on `end` to make sure it stops in a timely fashion. 17 | */ 18 | 19 | package ircevent 20 | 21 | import ( 22 | "bytes" 23 | "context" 24 | "crypto/tls" 25 | "errors" 26 | "fmt" 27 | "log" 28 | "net" 29 | "os" 30 | "strconv" 31 | "strings" 32 | "time" 33 | 34 | "github.com/ergochat/irc-go/ircmsg" 35 | "github.com/ergochat/irc-go/ircreader" 36 | ) 37 | 38 | const ( 39 | Version = "ergochat/irc-go" 40 | 41 | // prefix for keepalive ping parameters 42 | keepalivePrefix = "KeepAlive-" 43 | 44 | maxlenTags = 8192 45 | 46 | writeQueueSize = 10 47 | 48 | defaultNick = "ircevent" 49 | 50 | CAPTimeout = time.Second * 15 51 | ) 52 | 53 | var ( 54 | ClientDisconnected = errors.New("Could not send because client is disconnected") 55 | ServerTimedOut = errors.New("Server did not respond in time") 56 | ServerDisconnected = errors.New("Disconnected by server") 57 | SASLFailed = errors.New("SASL setup timed out. Does the server support SASL?") 58 | 59 | CapabilityNotNegotiated = errors.New("The IRCv3 capability required for this was not negotiated") 60 | NoLabeledResponse = errors.New("The server failed to send a labeled response to the command") 61 | 62 | serverDidNotQuit = errors.New("server did not respond to QUIT") 63 | ClientHasQuit = errors.New("client has called Quit()") 64 | ) 65 | 66 | // Call this on an error forcing a disconnection: 67 | // record the error, then close the `end` channel, stopping all goroutines 68 | func (irc *Connection) setError(err error) { 69 | irc.stateMutex.Lock() 70 | defer irc.stateMutex.Unlock() 71 | if irc.lastError == nil { 72 | irc.lastError = err 73 | irc.closeEndNoMutex() 74 | } 75 | } 76 | 77 | func (irc *Connection) getError() error { 78 | irc.stateMutex.Lock() 79 | defer irc.stateMutex.Unlock() 80 | return irc.lastError 81 | } 82 | 83 | // Send a keepalive PING in our timestamp-based format 84 | func (irc *Connection) ping() { 85 | param := fmt.Sprintf("%s%d", keepalivePrefix, time.Now().UnixNano()) 86 | irc.Send("PING", param) 87 | } 88 | 89 | // Interpret the PONG from a keepalive ping 90 | func (irc *Connection) recordPong(param string) { 91 | ts := strings.TrimPrefix(param, keepalivePrefix) 92 | if ts == param { 93 | return 94 | } 95 | t, err := strconv.ParseInt(ts, 10, 64) 96 | if err != nil { 97 | return 98 | } 99 | if irc.Debug { 100 | pong := time.Unix(0, t) 101 | irc.Log.Printf("Lag: %v\n", time.Since(pong)) 102 | } 103 | 104 | irc.stateMutex.Lock() 105 | defer irc.stateMutex.Unlock() 106 | irc.pingSent = false 107 | } 108 | 109 | // Read data from a connection. To be used as a goroutine. 110 | func (irc *Connection) readLoop() { 111 | defer irc.wg.Done() 112 | 113 | defer func() { 114 | if irc.registered { 115 | irc.runDisconnectCallbacks() 116 | } 117 | }() 118 | 119 | msgChan := make(chan string) 120 | errChan := make(chan error) 121 | go readMsgLoop(irc.socket, irc.MaxLineLen, msgChan, errChan, irc.end) 122 | 123 | lastExpireCheck := time.Now() 124 | 125 | for { 126 | select { 127 | case <-irc.end: 128 | return 129 | case msg := <-msgChan: 130 | if irc.Debug { 131 | irc.Log.Printf("<-- %s\n", strings.TrimSpace(msg)) 132 | } 133 | 134 | parsedMsg, err := ircmsg.ParseLine(msg) 135 | if err == nil { 136 | irc.runCallbacks(parsedMsg) 137 | } else { 138 | irc.Log.Printf("invalid message from server: %v\n", err) 139 | } 140 | case err := <-errChan: 141 | irc.setError(err) 142 | return 143 | } 144 | 145 | if irc.batchNegotiated() && time.Since(lastExpireCheck) > irc.Timeout { 146 | irc.expireBatches(false) 147 | lastExpireCheck = time.Now() 148 | } 149 | } 150 | } 151 | 152 | func readMsgLoop(socket net.Conn, maxLineLen int, msgChan chan string, errChan chan error, end chan empty) { 153 | var reader ircreader.Reader 154 | reader.Initialize(socket, 1024, maxLineLen+maxlenTags) 155 | for { 156 | msgBytes, err := reader.ReadLine() 157 | if err == nil { 158 | select { 159 | case msgChan <- string(msgBytes): 160 | case <-end: 161 | return 162 | } 163 | } else { 164 | select { 165 | case errChan <- err: 166 | case <-end: 167 | } 168 | return 169 | } 170 | } 171 | } 172 | 173 | // Loop to write to a connection. To be used as a goroutine. 174 | func (irc *Connection) writeLoop() { 175 | defer irc.wg.Done() 176 | 177 | for { 178 | select { 179 | case <-irc.end: 180 | return 181 | case b := <-irc.pwrite: 182 | if len(b) == 0 { 183 | continue 184 | } 185 | 186 | if irc.Debug { 187 | irc.Log.Printf("--> %s\n", bytes.TrimSpace(b)) 188 | } 189 | 190 | if irc.Timeout != 0 { 191 | irc.socket.SetWriteDeadline(time.Now().Add(irc.Timeout)) 192 | } 193 | _, err := irc.socket.Write(b) 194 | if irc.Timeout != 0 { 195 | irc.socket.SetWriteDeadline(time.Time{}) 196 | } 197 | if err != nil { 198 | irc.setError(err) 199 | return 200 | } 201 | } 202 | } 203 | } 204 | 205 | // check the status of the connection and take appropriate action 206 | func (irc *Connection) processTick(tick int) { 207 | var err error 208 | var shouldPing, shouldRenick bool 209 | 210 | defer func() { 211 | if err != nil { 212 | irc.setError(err) 213 | return 214 | } 215 | if shouldPing { 216 | irc.ping() 217 | } 218 | if shouldRenick { 219 | irc.Send("NICK", irc.PreferredNick()) 220 | } 221 | }() 222 | 223 | irc.stateMutex.Lock() 224 | defer irc.stateMutex.Unlock() 225 | 226 | // XXX: handle the server ignoring QUIT 227 | if irc.quit && time.Since(irc.quitAt) >= irc.Timeout { 228 | err = serverDidNotQuit 229 | return 230 | } 231 | if irc.pingSent { 232 | // unacked PING is fatal 233 | err = ServerTimedOut 234 | return 235 | } 236 | pingModulus := int(irc.KeepAlive / irc.Timeout) 237 | if tick%pingModulus == 0 { 238 | shouldPing = true 239 | irc.pingSent = true 240 | if irc.currentNick != irc.Nick { 241 | shouldRenick = true 242 | } 243 | } 244 | return 245 | } 246 | 247 | // handles all periodic tasks for the connection: 248 | // 1. sending PING approximately every KeepAlive seconds, monitoring for PONG 249 | // 2. fixing up NICK if we didn't get our preferred one 250 | func (irc *Connection) pingLoop() { 251 | ticker := time.NewTicker(irc.Timeout) 252 | 253 | defer func() { 254 | irc.wg.Done() 255 | ticker.Stop() 256 | }() 257 | 258 | tick := 0 259 | for { 260 | select { 261 | case <-irc.end: 262 | return 263 | case <-ticker.C: 264 | tick++ 265 | irc.processTick(tick) 266 | } 267 | } 268 | } 269 | 270 | func (irc *Connection) isQuitting() bool { 271 | irc.stateMutex.Lock() 272 | defer irc.stateMutex.Unlock() 273 | return irc.quit 274 | } 275 | 276 | // Main loop to control the connection. 277 | func (irc *Connection) Loop() { 278 | var lastReconnect time.Time 279 | for { 280 | irc.waitForStop() 281 | 282 | if irc.isQuitting() { 283 | return 284 | } 285 | 286 | if err := irc.getError(); err != nil { 287 | irc.Log.Printf("Error, disconnected: %s\n", err) 288 | } 289 | 290 | delay := time.Until(lastReconnect.Add(irc.ReconnectFreq)) 291 | if delay > 0 { 292 | if irc.Debug { 293 | irc.Log.Printf("Waiting %v to reconnect", delay) 294 | } 295 | t := time.NewTimer(delay) 296 | select { 297 | case <-t.C: 298 | case <-irc.reconnSig: 299 | t.Stop() 300 | } 301 | } 302 | 303 | lastReconnect = time.Now() 304 | err := irc.Connect() 305 | if err != nil { 306 | // we are still stopped, the stop checks will return immediately 307 | irc.Log.Printf("Error while reconnecting: %s\n", err) 308 | } 309 | } 310 | } 311 | 312 | // wait for all goroutines to stop. XXX: this is not safe for concurrent 313 | // use, call only from Connect() and Loop() (which will be on the same 314 | // client goroutine) 315 | func (irc *Connection) waitForStop() { 316 | <-irc.end 317 | irc.wg.Wait() // wait for readLoop and pingLoop to terminate fully 318 | 319 | if irc.socket != nil { 320 | irc.socket.Close() 321 | } 322 | 323 | irc.expireBatches(true) 324 | } 325 | 326 | // Quit the current connection and disconnect from the server 327 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.1.6 328 | func (irc *Connection) Quit() { 329 | quitMessage := irc.QuitMessage 330 | if quitMessage == "" { 331 | quitMessage = irc.Version 332 | } 333 | 334 | now := time.Now() 335 | irc.stateMutex.Lock() 336 | irc.quit = true 337 | irc.quitAt = now 338 | irc.stateMutex.Unlock() 339 | 340 | // the server will respond to this by closing our connection; 341 | // if it doesn't, pingLoop will eventually notice and close it 342 | irc.Send("QUIT", quitMessage) 343 | } 344 | 345 | func (irc *Connection) sendInternal(b []byte) (err error) { 346 | // XXX ensure that (end, pwrite) are from the same instantiation of Connect; 347 | // invocations of this function from callbacks originating in readLoop 348 | // do not need this synchronization (indeed they cannot occur at a time when 349 | // `end` is closed), but invocations from outside do (even though the race window 350 | // is very small). 351 | irc.stateMutex.Lock() 352 | running := irc.running 353 | end := irc.end 354 | pwrite := irc.pwrite 355 | irc.stateMutex.Unlock() 356 | 357 | if !running { 358 | return ClientDisconnected 359 | } 360 | 361 | select { 362 | case pwrite <- b: 363 | return nil 364 | case <-end: 365 | return ClientDisconnected 366 | } 367 | } 368 | 369 | // Send a built ircmsg.Message. 370 | func (irc *Connection) SendIRCMessage(msg ircmsg.Message) error { 371 | b, err := msg.LineBytesStrict(true, irc.MaxLineLen) 372 | if err != nil && !(irc.AllowTruncation && err == ircmsg.ErrorBodyTooLong) { 373 | if irc.Debug { 374 | irc.Log.Printf("couldn't assemble message: %v\n", err) 375 | } 376 | return err 377 | } 378 | return irc.sendInternal(b) 379 | } 380 | 381 | // Send an IRC message with tags. 382 | func (irc *Connection) SendWithTags(tags map[string]string, command string, params ...string) error { 383 | return irc.SendIRCMessage(ircmsg.MakeMessage(tags, "", command, params...)) 384 | } 385 | 386 | // Send an IRC message without tags. 387 | func (irc *Connection) Send(command string, params ...string) error { 388 | return irc.SendWithTags(nil, command, params...) 389 | } 390 | 391 | // SendWithLabel sends an IRC message using the IRCv3 labeled-response specification. 392 | // Instead of being processed by normal event handlers, the server response to the 393 | // command will be collected into a *Batch and passed to the provided callback. 394 | // If the server fails to respond correctly, the callback will be invoked with `nil` 395 | // as the argument. 396 | func (irc *Connection) SendWithLabel(callback func(*Batch), tags map[string]string, command string, params ...string) error { 397 | if !irc.labelNegotiated() { 398 | return CapabilityNotNegotiated 399 | } 400 | 401 | label := irc.registerLabel(callback) 402 | 403 | msg := ircmsg.MakeMessage(tags, "", command, params...) 404 | msg.SetTag("label", label) 405 | err := irc.SendIRCMessage(msg) 406 | if err != nil { 407 | irc.unregisterLabel(label) 408 | } 409 | return err 410 | } 411 | 412 | // GetLabeledResponse sends an IRC message using the IRCv3 labeled-response 413 | // specification, then synchronously waits for the response, which is returned 414 | // as a *Batch. If the server fails to respond correctly, an error will be 415 | // returned. 416 | func (irc *Connection) GetLabeledResponse(tags map[string]string, command string, params ...string) (batch *Batch, err error) { 417 | done := make(chan empty) 418 | err = irc.SendWithLabel(func(b *Batch) { 419 | batch = b 420 | close(done) 421 | }, tags, command, params...) 422 | if err != nil { 423 | return 424 | } 425 | <-done 426 | if batch == nil { 427 | err = NoLabeledResponse 428 | } 429 | return 430 | } 431 | 432 | // Send a raw string. 433 | func (irc *Connection) SendRaw(message string) error { 434 | mlen := len(message) 435 | buf := make([]byte, mlen+2) 436 | copy(buf[:mlen], message[:]) 437 | copy(buf[mlen:], "\r\n") 438 | return irc.sendInternal(buf) 439 | } 440 | 441 | // Use the connection to join a given channel. 442 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.2.1 443 | func (irc *Connection) Join(channel string) error { 444 | return irc.Send("JOIN", channel) 445 | } 446 | 447 | // Leave a given channel. 448 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.2.2 449 | func (irc *Connection) Part(channel string) error { 450 | return irc.Send("PART", channel) 451 | } 452 | 453 | // Send a notification to a nickname. This is similar to Privmsg but must not receive replies. 454 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.4.2 455 | func (irc *Connection) Notice(target, message string) error { 456 | return irc.Send("NOTICE", target, message) 457 | } 458 | 459 | // Send a formated notification to a nickname. 460 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.4.2 461 | func (irc *Connection) Noticef(target, format string, a ...interface{}) error { 462 | return irc.Notice(target, fmt.Sprintf(format, a...)) 463 | } 464 | 465 | // Send (private) message to a target (channel or nickname). 466 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.4.1 467 | func (irc *Connection) Privmsg(target, message string) error { 468 | return irc.Send("PRIVMSG", target, message) 469 | } 470 | 471 | // Send formated string to specified target (channel or nickname). 472 | func (irc *Connection) Privmsgf(target, format string, a ...interface{}) error { 473 | return irc.Privmsg(target, fmt.Sprintf(format, a...)) 474 | } 475 | 476 | // Send (action) message to a target (channel or nickname). 477 | // No clear RFC on this one... 478 | func (irc *Connection) Action(target, message string) error { 479 | return irc.Privmsg(target, fmt.Sprintf("\001ACTION %s\001", message)) 480 | } 481 | 482 | // Send formatted (action) message to a target (channel or nickname). 483 | func (irc *Connection) Actionf(target, format string, a ...interface{}) error { 484 | return irc.Action(target, fmt.Sprintf(format, a...)) 485 | } 486 | 487 | // Set (new) nickname. 488 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.1.2 489 | func (irc *Connection) SetNick(n string) { 490 | irc.stateMutex.Lock() 491 | irc.Nick = n 492 | irc.stateMutex.Unlock() 493 | 494 | irc.Send("NICK", n) 495 | } 496 | 497 | // Determine nick currently used with the connection. 498 | func (irc *Connection) CurrentNick() string { 499 | irc.stateMutex.Lock() 500 | defer irc.stateMutex.Unlock() 501 | return irc.currentNick 502 | } 503 | 504 | // Returns the expected or desired nickname for the connection; 505 | // if the real nickname is different, the client will periodically 506 | // attempt to change to this one. 507 | func (irc *Connection) PreferredNick() string { 508 | irc.stateMutex.Lock() 509 | defer irc.stateMutex.Unlock() 510 | return irc.Nick 511 | } 512 | 513 | func (irc *Connection) setCurrentNick(nick string) { 514 | irc.stateMutex.Lock() 515 | defer irc.stateMutex.Unlock() 516 | irc.currentNick = nick 517 | } 518 | 519 | // Return IRCv3 CAPs actually enabled on the connection, together 520 | // with their values if applicable. The resulting map is shared, 521 | // so do not modify it. 522 | func (irc *Connection) AcknowledgedCaps() (result map[string]string) { 523 | irc.stateMutex.Lock() 524 | defer irc.stateMutex.Unlock() 525 | return irc.capsAcked 526 | } 527 | 528 | // Returns the 005 RPL_ISUPPORT tokens sent by the server when the 529 | // connection was initiated, parsed into key-value form as a map. 530 | // The resulting map is shared, so do not modify it. 531 | func (irc *Connection) ISupport() (result map[string]string) { 532 | irc.stateMutex.Lock() 533 | defer irc.stateMutex.Unlock() 534 | // XXX modifications to isupport are not permitted after registration 535 | return irc.isupport 536 | } 537 | 538 | // Returns true if the connection is connected to an IRC server. 539 | func (irc *Connection) Connected() bool { 540 | irc.stateMutex.Lock() 541 | defer irc.stateMutex.Unlock() 542 | return irc.running 543 | } 544 | 545 | // Reconnect forces the client to reconnect to the server. 546 | // TODO try to ensure buffered messages are sent? 547 | func (irc *Connection) Reconnect() { 548 | irc.closeEnd() 549 | select { 550 | case irc.reconnSig <- empty{}: 551 | default: 552 | } 553 | } 554 | 555 | func (irc *Connection) closeEnd() { 556 | irc.stateMutex.Lock() 557 | defer irc.stateMutex.Unlock() 558 | irc.closeEndNoMutex() 559 | } 560 | 561 | func (irc *Connection) closeEndNoMutex() { 562 | if irc.running { 563 | irc.running = false 564 | close(irc.end) 565 | } 566 | } 567 | 568 | func (irc *Connection) dial() (socket net.Conn, err error) { 569 | if irc.DialContext == nil { 570 | irc.DialContext = (&net.Dialer{}).DialContext 571 | } 572 | ctx, cancel := context.WithTimeout(context.Background(), irc.Timeout) 573 | defer cancel() 574 | socket, err = irc.DialContext(ctx, "tcp", irc.Server) 575 | if err != nil { 576 | return 577 | } 578 | if !irc.UseTLS { 579 | return 580 | } 581 | 582 | // see tls.DialWithDialer 583 | if irc.TLSConfig == nil { 584 | irc.TLSConfig = &tls.Config{} 585 | } 586 | if irc.TLSConfig.ServerName == "" && !irc.TLSConfig.InsecureSkipVerify { 587 | host, _, err := net.SplitHostPort(irc.Server) 588 | if err == nil { 589 | irc.TLSConfig.ServerName = host 590 | } else { 591 | irc.TLSConfig.ServerName = irc.Server 592 | } 593 | } 594 | tlsSocket := tls.Client(socket, irc.TLSConfig) 595 | err = tlsSocket.HandshakeContext(ctx) 596 | if err != nil { 597 | socket.Close() 598 | return nil, err 599 | } 600 | return tlsSocket, nil 601 | } 602 | 603 | // Connect to a given server using the current connection configuration. 604 | // This function also takes care of identification if a password is provided. 605 | // RFC 1459 details: https://tools.ietf.org/html/rfc1459#section-4.1 606 | func (irc *Connection) Connect() (err error) { 607 | // invariant: after Connect we are in one of two states: 608 | // (a) success: return nil, socket open, goroutines launched, ready for Loop 609 | // (b) failure: return error, socket closed, goroutines stopped, 610 | // ready for another call to Connect (possibly from Loop) 611 | err = func() error { 612 | irc.stateMutex.Lock() 613 | defer irc.stateMutex.Unlock() 614 | 615 | if irc.quit { 616 | return ClientHasQuit // check this again in case of Quit() while we were asleep 617 | } 618 | 619 | // mark Server as stopped since there can be an error during connect 620 | irc.running = false 621 | irc.socket = nil 622 | irc.currentNick = "" 623 | irc.lastError = nil 624 | irc.pingSent = false 625 | 626 | if irc.Server == "" { 627 | return errors.New("No server provided") 628 | } 629 | if len(irc.Nick) == 0 { 630 | irc.Nick = defaultNick 631 | } 632 | if irc.User == "" { 633 | irc.User = irc.Nick 634 | } 635 | if irc.Log == nil { 636 | irc.Log = log.New(os.Stdout, "", log.LstdFlags) 637 | } 638 | if irc.KeepAlive == 0 { 639 | irc.KeepAlive = 4 * time.Minute 640 | } 641 | if irc.Timeout == 0 { 642 | irc.Timeout = 1 * time.Minute 643 | } 644 | if irc.KeepAlive < irc.Timeout { 645 | return errors.New("KeepAlive must be at least Timeout") 646 | } 647 | if irc.ReconnectFreq == 0 { 648 | irc.ReconnectFreq = 2 * time.Minute 649 | } 650 | if irc.SASLLogin != "" && irc.SASLPassword != "" { 651 | irc.UseSASL = true 652 | } 653 | if irc.UseSASL { 654 | // ensure 'sasl' is in the cap list if necessary 655 | if !sliceContains("sasl", irc.RequestCaps) { 656 | irc.RequestCaps = append(irc.RequestCaps, "sasl") 657 | } 658 | } 659 | if irc.SASLMech == "" { 660 | irc.SASLMech = "PLAIN" 661 | } 662 | if !(irc.SASLMech == "PLAIN" || irc.SASLMech == "EXTERNAL") { 663 | return fmt.Errorf("unsupported SASL mechanism %s", irc.SASLMech) 664 | } 665 | if irc.MaxLineLen == 0 { 666 | irc.MaxLineLen = 512 667 | } 668 | if irc.Version == "" { 669 | irc.Version = Version 670 | } 671 | // this only runs on first Connect() invocation; 672 | // unlike other synch primitives it is shared across reconnections: 673 | if irc.reconnSig == nil { 674 | irc.reconnSig = make(chan empty) 675 | } 676 | return nil 677 | }() 678 | 679 | if err != nil { 680 | return err 681 | } 682 | 683 | irc.setupCallbacks() 684 | 685 | if irc.Debug { 686 | irc.Log.Printf("Connecting to %s (TLS: %t)\n", irc.Server, irc.UseTLS) 687 | } 688 | 689 | socket, err := irc.dial() 690 | if err != nil { 691 | return err 692 | } 693 | 694 | if irc.Debug { 695 | irc.Log.Printf("Connected to %s (%s)\n", irc.Server, socket.RemoteAddr()) 696 | } 697 | 698 | // reset all connection state 699 | irc.stateMutex.Lock() 700 | irc.socket = socket 701 | irc.running = true 702 | irc.end = make(chan empty) 703 | irc.pwrite = make(chan []byte, writeQueueSize) 704 | irc.wg.Add(3) 705 | irc.capsChan = make(chan capResult, len(irc.RequestCaps)) 706 | irc.saslChan = make(chan saslResult, 1) 707 | irc.welcomeChan = make(chan empty, 1) 708 | irc.registered = false 709 | irc.isupportPartial = make(map[string]string) 710 | irc.isupport = nil 711 | irc.capsAcked = make(map[string]string) 712 | irc.capsAdvertised = nil 713 | irc.stateMutex.Unlock() 714 | irc.batchMutex.Lock() 715 | irc.batches = make(map[string]batchInProgress) 716 | irc.labelCallbacks = make(map[int64]pendingLabel) 717 | irc.labelCounter = 0 718 | irc.batchMutex.Unlock() 719 | 720 | go irc.readLoop() 721 | go irc.writeLoop() 722 | go irc.pingLoop() 723 | 724 | // now we have an open socket and goroutines; we need to clean up 725 | // if there's a layer 7 failure 726 | defer func() { 727 | if err != nil { 728 | irc.closeEnd() 729 | irc.waitForStop() 730 | } 731 | }() 732 | 733 | if len(irc.WebIRC) > 0 { 734 | irc.Send("WEBIRC", irc.WebIRC...) 735 | } 736 | 737 | if len(irc.Password) > 0 { 738 | irc.Send("PASS", irc.Password) 739 | } 740 | 741 | err = irc.negotiateCaps() 742 | if err != nil { 743 | return err 744 | } 745 | 746 | realname := irc.User 747 | if irc.RealName != "" { 748 | realname = irc.RealName 749 | } 750 | irc.Send("NICK", irc.PreferredNick()) 751 | irc.Send("USER", irc.User, "s", "e", realname) 752 | timeout := time.NewTimer(irc.Timeout) 753 | defer timeout.Stop() 754 | select { 755 | case <-irc.welcomeChan: 756 | case <-irc.end: 757 | err = ServerDisconnected 758 | case <-timeout.C: 759 | err = ServerTimedOut 760 | } 761 | return 762 | } 763 | 764 | // Negotiate IRCv3 capabilities 765 | func (irc *Connection) negotiateCaps() error { 766 | if len(irc.RequestCaps) == 0 { 767 | return nil 768 | } 769 | 770 | var acknowledgedCaps []string 771 | defer func() { 772 | irc.processAckedCaps(acknowledgedCaps) 773 | }() 774 | 775 | irc.Send("CAP", "LS", "302") 776 | defer func() { 777 | irc.Send("CAP", "END") 778 | }() 779 | 780 | remaining_caps := len(irc.RequestCaps) 781 | 782 | timer := time.NewTimer(CAPTimeout) 783 | for remaining_caps > 0 { 784 | select { 785 | case result := <-irc.capsChan: 786 | timer.Stop() 787 | remaining_caps-- 788 | if result.ack { 789 | acknowledgedCaps = append(acknowledgedCaps, result.capName) 790 | } 791 | case <-timer.C: 792 | // The server probably doesn't implement CAP LS, which is "normal". 793 | return nil 794 | case <-irc.end: 795 | return ServerDisconnected 796 | } 797 | } 798 | 799 | saslError := func(err error) error { 800 | if !irc.SASLOptional { 801 | return err 802 | } else { 803 | return nil 804 | } 805 | } 806 | 807 | if irc.UseSASL { 808 | if !sliceContains("sasl", acknowledgedCaps) { 809 | return saslError(SASLFailed) 810 | } else { 811 | irc.Send("AUTHENTICATE", irc.SASLMech) 812 | } 813 | timeout := time.NewTimer(CAPTimeout) 814 | defer timeout.Stop() 815 | select { 816 | case res := <-irc.saslChan: 817 | if res.Failed { 818 | return saslError(res.Err) 819 | } 820 | case <-timeout.C: 821 | // if we expect to be able to SASL, failure to SASL should be treated 822 | // as a connection error: 823 | return saslError(SASLFailed) 824 | case <-irc.end: 825 | return ServerDisconnected 826 | } 827 | } 828 | 829 | return nil 830 | } 831 | -------------------------------------------------------------------------------- /ircevent/irc_callback.go: -------------------------------------------------------------------------------- 1 | package ircevent 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "runtime/debug" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/ergochat/irc-go/ircmsg" 12 | ) 13 | 14 | const ( 15 | // fake events that we manage specially 16 | registrationEvent = "\x00REGISTRATION" 17 | disconnectEvent = "\x00DISCONNECT" 18 | ) 19 | 20 | // Tuple type for uniquely identifying callbacks 21 | type CallbackID struct { 22 | command string 23 | id uint64 24 | } 25 | 26 | // Register a callback to handle an IRC command (or numeric). A callback is a 27 | // function which takes only an ircmsg.Message object as parameter. Valid commands 28 | // are all IRC commands, including numerics. This function returns the ID of the 29 | // registered callback for later management. 30 | func (irc *Connection) AddCallback(command string, callback func(ircmsg.Message)) CallbackID { 31 | return irc.addCallback(command, Callback(callback), false, 0) 32 | } 33 | 34 | func (irc *Connection) addCallback(command string, callback Callback, prepend bool, idNum uint64) CallbackID { 35 | command = strings.ToUpper(command) 36 | if command == "" || strings.HasPrefix(command, "*") || command == "BATCH" { 37 | return CallbackID{} 38 | } 39 | 40 | irc.eventsMutex.Lock() 41 | defer irc.eventsMutex.Unlock() 42 | 43 | if irc.events == nil { 44 | irc.events = make(map[string][]callbackPair) 45 | } 46 | 47 | if idNum == 0 { 48 | idNum = irc.callbackCounter 49 | irc.callbackCounter++ 50 | } 51 | id := CallbackID{command: command, id: idNum} 52 | newPair := callbackPair{id: id.id, callback: callback} 53 | current := irc.events[command] 54 | newList := make([]callbackPair, len(current)+1) 55 | start := 0 56 | if prepend { 57 | newList[start] = newPair 58 | start++ 59 | } 60 | copy(newList[start:], current) 61 | if !prepend { 62 | newList[len(newList)-1] = newPair 63 | } 64 | irc.events[command] = newList 65 | return id 66 | } 67 | 68 | // Remove callback i (ID) from the given event code. 69 | func (irc *Connection) RemoveCallback(id CallbackID) { 70 | irc.eventsMutex.Lock() 71 | defer irc.eventsMutex.Unlock() 72 | switch id.command { 73 | case registrationEvent: 74 | irc.removeCallbackNoMutex(RPL_ENDOFMOTD, id.id) 75 | irc.removeCallbackNoMutex(ERR_NOMOTD, id.id) 76 | case "BATCH": 77 | irc.removeBatchCallbackNoMutex(id.id) 78 | default: 79 | irc.removeCallbackNoMutex(id.command, id.id) 80 | } 81 | } 82 | 83 | func (irc *Connection) removeCallbackNoMutex(code string, id uint64) { 84 | current := irc.events[code] 85 | if len(current) == 0 { 86 | return 87 | } 88 | newList := make([]callbackPair, 0, len(current)-1) 89 | for _, p := range current { 90 | if p.id != id { 91 | newList = append(newList, p) 92 | } 93 | } 94 | irc.events[code] = newList 95 | } 96 | 97 | // Remove all callbacks from a given event code. 98 | func (irc *Connection) ClearCallback(command string) { 99 | command = strings.ToUpper(command) 100 | 101 | irc.eventsMutex.Lock() 102 | defer irc.eventsMutex.Unlock() 103 | delete(irc.events, command) 104 | } 105 | 106 | // Replace callback i (ID) associated with a given event code with a new callback function. 107 | func (irc *Connection) ReplaceCallback(id CallbackID, callback func(ircmsg.Message)) bool { 108 | irc.eventsMutex.Lock() 109 | defer irc.eventsMutex.Unlock() 110 | 111 | list := irc.events[id.command] 112 | for i, p := range list { 113 | if p.id == id.id { 114 | list[i] = callbackPair{id: id.id, callback: callback} 115 | return true 116 | } 117 | } 118 | return false 119 | } 120 | 121 | // AddBatchCallback adds a callback for handling BATCH'ed server responses. 122 | // All available BATCH callbacks will be invoked in an undefined order, 123 | // stopping at the first one to return a value of true (indicating successful 124 | // processing). If no batch callback returns true, the batch will be "flattened" 125 | // (i.e., its messages will be processed individually by the normal event 126 | // handlers). Batch callbacks can be removed as usual with RemoveCallback. 127 | func (irc *Connection) AddBatchCallback(callback func(*Batch) bool) CallbackID { 128 | irc.eventsMutex.Lock() 129 | defer irc.eventsMutex.Unlock() 130 | 131 | idNum := irc.callbackCounter 132 | irc.callbackCounter++ 133 | nbc := make([]batchCallbackPair, len(irc.batchCallbacks)+1) 134 | copy(nbc, irc.batchCallbacks) 135 | nbc[len(nbc)-1] = batchCallbackPair{id: idNum, callback: callback} 136 | irc.batchCallbacks = nbc 137 | return CallbackID{command: "BATCH", id: idNum} 138 | } 139 | 140 | func (irc *Connection) removeBatchCallbackNoMutex(idNum uint64) { 141 | current := irc.batchCallbacks 142 | if len(current) == 0 { 143 | return 144 | } 145 | newList := make([]batchCallbackPair, 0, len(current)-1) 146 | for _, p := range current { 147 | if p.id != idNum { 148 | newList = append(newList, p) 149 | } 150 | } 151 | irc.batchCallbacks = newList 152 | } 153 | 154 | // Convenience function to add a callback that will be called once the 155 | // connection is completed (this is traditionally referred to as "connection 156 | // registration"). 157 | func (irc *Connection) AddConnectCallback(callback func(ircmsg.Message)) (id CallbackID) { 158 | // XXX: forcibly use the same ID number for both copies of the callback 159 | id376 := irc.AddCallback(RPL_ENDOFMOTD, callback) 160 | irc.addCallback(ERR_NOMOTD, callback, false, id376.id) 161 | return CallbackID{command: registrationEvent, id: id376.id} 162 | } 163 | 164 | // Adds a callback to be run when disconnection from the server is detected; 165 | // this may be a connectivity failure, a server-initiated disconnection, or 166 | // a client-initiated Quit(). These callbacks are run after the last message 167 | // from the server is processed, before any reconnection attempt. The contents 168 | // of the Message object supplied to the callback are undefined. 169 | func (irc *Connection) AddDisconnectCallback(callback func(ircmsg.Message)) (id CallbackID) { 170 | return irc.AddCallback(disconnectEvent, callback) 171 | } 172 | 173 | func (irc *Connection) getCallbacks(code string) (result []callbackPair) { 174 | code = strings.ToUpper(code) 175 | 176 | irc.eventsMutex.Lock() 177 | defer irc.eventsMutex.Unlock() 178 | return irc.events[code] 179 | } 180 | 181 | func (irc *Connection) getBatchCallbacks() (result []batchCallbackPair) { 182 | irc.eventsMutex.Lock() 183 | defer irc.eventsMutex.Unlock() 184 | 185 | return irc.batchCallbacks 186 | } 187 | 188 | var ( 189 | // ad-hoc internal errors for batch processing 190 | // these indicate invalid data from the server (or else local corruption) 191 | errorDuplicateBatchID = errors.New("found duplicate batch ID") 192 | errorNoParentBatchID = errors.New("parent batch ID not found") 193 | errorBatchNotOpen = errors.New("tried to close batch, but batch ID not found") 194 | errorUnknownLabel = errors.New("received labeled response from server, but we don't recognize the label") 195 | ) 196 | 197 | func (irc *Connection) handleBatchCommand(msg ircmsg.Message) { 198 | if len(msg.Params) < 1 || len(msg.Params[0]) < 2 { 199 | irc.Log.Printf("Invalid BATCH command from server\n") 200 | return 201 | } 202 | 203 | start := msg.Params[0][0] == '+' 204 | if !start && msg.Params[0][0] != '-' { 205 | irc.Log.Printf("Invalid BATCH ID from server: %s\n", msg.Params[0]) 206 | return 207 | } 208 | batchID := msg.Params[0][1:] 209 | isNested, parentBatchID := msg.GetTag("batch") 210 | var label int64 211 | if start { 212 | if present, labelStr := msg.GetTag("label"); present { 213 | label = deserializeLabel(labelStr) 214 | } 215 | } 216 | 217 | finishedBatch, callback, err := func() (finishedBatch *Batch, callback LabelCallback, err error) { 218 | irc.batchMutex.Lock() 219 | defer irc.batchMutex.Unlock() 220 | 221 | if start { 222 | if _, ok := irc.batches[batchID]; ok { 223 | err = errorDuplicateBatchID 224 | return 225 | } 226 | batchObj := new(Batch) 227 | batchObj.Message = msg 228 | irc.batches[batchID] = batchInProgress{ 229 | createdAt: time.Now(), 230 | batch: batchObj, 231 | label: label, 232 | } 233 | if isNested { 234 | parentBip := irc.batches[parentBatchID] 235 | if parentBip.batch == nil { 236 | err = errorNoParentBatchID 237 | return 238 | } 239 | parentBip.batch.Items = append(parentBip.batch.Items, batchObj) 240 | } 241 | } else { 242 | bip := irc.batches[batchID] 243 | if bip.batch == nil { 244 | err = errorBatchNotOpen 245 | return 246 | } 247 | delete(irc.batches, batchID) 248 | if !isNested { 249 | finishedBatch = bip.batch 250 | if bip.label != 0 { 251 | callback = irc.getLabelCallbackNoMutex(bip.label) 252 | if callback == nil { 253 | err = errorUnknownLabel 254 | } 255 | 256 | } 257 | } 258 | } 259 | return 260 | }() 261 | 262 | if err != nil { 263 | irc.Log.Printf("batch error: %v (batchID=`%s`, parentBatchID=`%s`)", err, batchID, parentBatchID) 264 | } else if callback != nil { 265 | callback(finishedBatch) 266 | } else if finishedBatch != nil { 267 | irc.HandleBatch(finishedBatch) 268 | } 269 | } 270 | 271 | func (irc *Connection) getLabelCallbackNoMutex(label int64) (callback LabelCallback) { 272 | if lc, ok := irc.labelCallbacks[label]; ok { 273 | callback = lc.callback 274 | delete(irc.labelCallbacks, label) 275 | } 276 | return 277 | } 278 | 279 | func (irc *Connection) getLabelCallback(label int64) (callback LabelCallback) { 280 | irc.batchMutex.Lock() 281 | defer irc.batchMutex.Unlock() 282 | return irc.getLabelCallbackNoMutex(label) 283 | } 284 | 285 | // HandleBatch handles a *Batch using available handlers, "flattening" it if 286 | // no handler succeeds. This can be used in a batch or labeled-response callback 287 | // to process inner batches. 288 | func (irc *Connection) HandleBatch(batch *Batch) { 289 | if batch == nil { 290 | return 291 | } 292 | 293 | success := false 294 | for _, bh := range irc.getBatchCallbacks() { 295 | if bh.callback(batch) { 296 | success = true 297 | break 298 | } 299 | } 300 | if !success { 301 | irc.handleBatchNaively(batch) 302 | } 303 | } 304 | 305 | // recursively "flatten" the nested batch; process every command individually 306 | func (irc *Connection) handleBatchNaively(batch *Batch) { 307 | if batch.Command != "BATCH" { 308 | irc.HandleMessage(batch.Message) 309 | } 310 | for _, item := range batch.Items { 311 | irc.handleBatchNaively(item) 312 | } 313 | } 314 | 315 | func (irc *Connection) handleBatchedCommand(msg ircmsg.Message, batchID string) { 316 | irc.batchMutex.Lock() 317 | defer irc.batchMutex.Unlock() 318 | 319 | bip := irc.batches[batchID] 320 | if bip.batch == nil { 321 | irc.Log.Printf("ignoring command with unknown batch ID %s\n", batchID) 322 | return 323 | } 324 | bip.batch.Items = append(bip.batch.Items, &Batch{Message: msg}) 325 | } 326 | 327 | // Execute all callbacks associated with a given event. 328 | func (irc *Connection) runCallbacks(msg ircmsg.Message) { 329 | if !irc.AllowPanic { 330 | defer irc.handleCallbackPanic() 331 | } 332 | 333 | // handle batch start or end 334 | if irc.batchNegotiated() { 335 | if msg.Command == "BATCH" { 336 | irc.handleBatchCommand(msg) 337 | return 338 | } else if hasBatchTag, batchID := msg.GetTag("batch"); hasBatchTag { 339 | irc.handleBatchedCommand(msg, batchID) 340 | return 341 | } 342 | } 343 | 344 | // handle labeled single command, or labeled ACK 345 | if irc.labelNegotiated() { 346 | if hasLabel, labelStr := msg.GetTag("label"); hasLabel { 347 | var labelCallback LabelCallback 348 | if label := deserializeLabel(labelStr); label != 0 { 349 | labelCallback = irc.getLabelCallback(label) 350 | } 351 | if labelCallback == nil { 352 | irc.Log.Printf("received unrecognized label from server: %s\n", labelStr) 353 | return 354 | } else { 355 | labelCallback(&Batch{ 356 | Message: msg, 357 | }) 358 | } 359 | return 360 | } 361 | } 362 | 363 | // OK, it's a normal IRC command 364 | irc.HandleMessage(msg) 365 | } 366 | 367 | func (irc *Connection) handleCallbackPanic() { 368 | if r := recover(); r != nil { 369 | irc.Log.Printf("Caught panic in callback: %v\n%s", r, debug.Stack()) 370 | } 371 | } 372 | 373 | func (irc *Connection) runDisconnectCallbacks() { 374 | if !irc.AllowPanic { 375 | defer irc.handleCallbackPanic() 376 | } 377 | 378 | callbackPairs := irc.getCallbacks(disconnectEvent) 379 | for _, pair := range callbackPairs { 380 | pair.callback(ircmsg.Message{}) 381 | } 382 | } 383 | 384 | // HandleMessage handles an IRC line using the available handlers. This can be 385 | // used in a batch or labeled-response callback to process an individual line. 386 | func (irc *Connection) HandleMessage(event ircmsg.Message) { 387 | if irc.EnableCTCP { 388 | eventRewriteCTCP(&event) 389 | } 390 | 391 | callbackPairs := irc.getCallbacks(event.Command) 392 | 393 | // just run the callbacks in serial, since it's not safe for them 394 | // to take a long time to execute in any case 395 | for _, pair := range callbackPairs { 396 | pair.callback(event) 397 | } 398 | } 399 | 400 | // Set up some initial callbacks to handle the IRC/CTCP protocol. 401 | func (irc *Connection) setupCallbacks() { 402 | irc.stateMutex.Lock() 403 | needBaseCallbacks := !irc.hasBaseCallbacks 404 | irc.hasBaseCallbacks = true 405 | irc.stateMutex.Unlock() 406 | 407 | if !needBaseCallbacks { 408 | return 409 | } 410 | 411 | // PING: we must respond with the correct PONG 412 | irc.AddCallback("PING", func(e ircmsg.Message) { irc.Send("PONG", lastParam(&e)) }) 413 | 414 | // PONG: record time to make sure the server is responding to us 415 | irc.AddCallback("PONG", func(e ircmsg.Message) { irc.recordPong(lastParam(&e)) }) 416 | 417 | // 433: ERR_NICKNAMEINUSE " :Nickname is already in use" 418 | // 437: ERR_UNAVAILRESOURCE " :Nick/channel is temporarily unavailable" 419 | irc.AddCallback(ERR_NICKNAMEINUSE, irc.handleUnavailableNick) 420 | irc.AddCallback(ERR_UNAVAILRESOURCE, irc.handleUnavailableNick) 421 | 422 | // 001: RPL_WELCOME "Welcome to the Internet Relay Network !@" 423 | // Set irc.currentNick to the actually used nick in this connection. 424 | irc.AddCallback(RPL_WELCOME, irc.handleRplWelcome) 425 | 426 | // 005: RPL_ISUPPORT, conveys supported server features 427 | irc.AddCallback(RPL_ISUPPORT, irc.handleISupport) 428 | 429 | // respond to NICK from the server (in response to our own NICK, or sent unprompted) 430 | // #84: prepend so this runs before the client's own NICK callbacks 431 | irc.addCallback("NICK", func(e ircmsg.Message) { 432 | if e.Nick() == irc.CurrentNick() && len(e.Params) > 0 { 433 | irc.setCurrentNick(e.Params[0]) 434 | } 435 | }, true, 0) 436 | 437 | irc.AddCallback("ERROR", func(e ircmsg.Message) { 438 | if !irc.isQuitting() { 439 | irc.Log.Printf("ERROR received from server: %s", strings.Join(e.Params, " ")) 440 | } 441 | }) 442 | 443 | irc.AddCallback("CAP", irc.handleCAP) 444 | 445 | if irc.UseSASL { 446 | irc.setupSASLCallbacks() 447 | } 448 | 449 | if irc.EnableCTCP { 450 | irc.setupCTCPCallbacks() 451 | } 452 | 453 | // prepend our own callbacks for the end of registration, 454 | // so they happen before any client-added callbacks 455 | irc.addCallback(RPL_ENDOFMOTD, irc.handleRegistration, true, 0) 456 | irc.addCallback(ERR_NOMOTD, irc.handleRegistration, true, 0) 457 | 458 | irc.AddCallback("FAIL", irc.handleStandardReplies) 459 | irc.AddCallback("WARN", irc.handleStandardReplies) 460 | irc.AddCallback("NOTE", irc.handleStandardReplies) 461 | } 462 | 463 | func (irc *Connection) handleRplWelcome(e ircmsg.Message) { 464 | irc.stateMutex.Lock() 465 | defer irc.stateMutex.Unlock() 466 | 467 | // set the nickname we actually received from the server 468 | if len(e.Params) > 0 { 469 | irc.currentNick = e.Params[0] 470 | } 471 | } 472 | 473 | func (irc *Connection) handleRegistration(e ircmsg.Message) { 474 | // wake up Connect() if applicable 475 | defer func() { 476 | select { 477 | case irc.welcomeChan <- empty{}: 478 | default: 479 | } 480 | }() 481 | 482 | irc.stateMutex.Lock() 483 | defer irc.stateMutex.Unlock() 484 | 485 | if irc.registered { 486 | return 487 | } 488 | irc.registered = true 489 | 490 | // mark the isupport complete 491 | irc.isupport = irc.isupportPartial 492 | irc.isupportPartial = nil 493 | 494 | } 495 | 496 | func (irc *Connection) handleUnavailableNick(e ircmsg.Message) { 497 | // only try to change the nick if we're not registered yet, 498 | // otherwise we'll change in response to pingLoop unsuccessfully 499 | // trying to restore the intended nick (swapping one undesired nick 500 | // for another) 501 | var nickToTry string 502 | irc.stateMutex.Lock() 503 | if irc.currentNick == "" { 504 | nickToTry = fmt.Sprintf("%s_%d", irc.Nick, irc.nickCounter) 505 | irc.nickCounter++ 506 | } 507 | irc.stateMutex.Unlock() 508 | 509 | if nickToTry != "" { 510 | irc.Send("NICK", nickToTry) 511 | } 512 | } 513 | 514 | func (irc *Connection) handleISupport(e ircmsg.Message) { 515 | irc.stateMutex.Lock() 516 | defer irc.stateMutex.Unlock() 517 | 518 | // TODO handle 005 changes after registration 519 | if irc.isupportPartial == nil { 520 | return 521 | } 522 | if len(e.Params) < 3 { 523 | return 524 | } 525 | for _, token := range e.Params[1 : len(e.Params)-1] { 526 | equalsIdx := strings.IndexByte(token, '=') 527 | if equalsIdx == -1 { 528 | irc.isupportPartial[token] = "" // no value 529 | } else { 530 | irc.isupportPartial[token[:equalsIdx]] = unescapeISupportValue(token[equalsIdx+1:]) 531 | } 532 | } 533 | } 534 | 535 | func unescapeISupportValue(in string) (out string) { 536 | if strings.IndexByte(in, '\\') == -1 { 537 | return in 538 | } 539 | var buf strings.Builder 540 | for i := 0; i < len(in); { 541 | if in[i] == '\\' && i+3 < len(in) && in[i+1] == 'x' { 542 | hex := in[i+2 : i+4] 543 | if octet, err := strconv.ParseInt(hex, 16, 8); err == nil { 544 | buf.WriteByte(byte(octet)) 545 | i += 4 546 | continue 547 | } 548 | } 549 | buf.WriteByte(in[i]) 550 | i++ 551 | } 552 | return buf.String() 553 | } 554 | 555 | func (irc *Connection) handleCAP(e ircmsg.Message) { 556 | if len(e.Params) < 3 { 557 | return 558 | } 559 | ack := false 560 | // CAP PARAMS... 561 | switch e.Params[1] { 562 | case "LS": 563 | irc.handleCAPLS(e.Params[2:]) 564 | case "ACK": 565 | ack = true 566 | fallthrough 567 | case "NAK": 568 | for _, token := range strings.Fields(e.Params[2]) { 569 | name, _ := splitCAPToken(token) 570 | if sliceContains(name, irc.RequestCaps) { 571 | select { 572 | case irc.capsChan <- capResult{capName: name, ack: ack}: 573 | default: 574 | } 575 | } 576 | } 577 | } 578 | } 579 | 580 | func (irc *Connection) handleCAPLS(params []string) { 581 | var capsToReq, capsNotFound []string 582 | defer func() { 583 | for _, c := range capsToReq { 584 | irc.Send("CAP", "REQ", c) 585 | } 586 | for _, c := range capsNotFound { 587 | select { 588 | case irc.capsChan <- capResult{capName: c, ack: false}: 589 | default: 590 | } 591 | } 592 | }() 593 | 594 | irc.stateMutex.Lock() 595 | defer irc.stateMutex.Unlock() 596 | 597 | if irc.registered { 598 | // TODO server could probably trick us into panic here by sending 599 | // additional LS before the end of registration 600 | return 601 | } 602 | 603 | if irc.capsAdvertised == nil { 604 | irc.capsAdvertised = make(map[string]string) 605 | } 606 | 607 | // multiline responses to CAP LS 302 start with a 4-parameter form: 608 | // CAP * LS * :account-notify away-notify [...] 609 | // and end with a 3-parameter form: 610 | // CAP * LS :userhost-in-names znc.in/playback [...] 611 | final := len(params) == 1 612 | for _, token := range strings.Fields(params[len(params)-1]) { 613 | name, value := splitCAPToken(token) 614 | irc.capsAdvertised[name] = value 615 | } 616 | 617 | if final { 618 | for _, c := range irc.RequestCaps { 619 | if _, ok := irc.capsAdvertised[c]; ok { 620 | capsToReq = append(capsToReq, c) 621 | } else { 622 | capsNotFound = append(capsNotFound, c) 623 | } 624 | } 625 | } 626 | } 627 | 628 | // labeled-response 629 | 630 | func (irc *Connection) registerLabel(callback LabelCallback) string { 631 | irc.batchMutex.Lock() 632 | defer irc.batchMutex.Unlock() 633 | irc.labelCounter++ // increment first: 0 is an invalid label 634 | label := irc.labelCounter 635 | irc.labelCallbacks[label] = pendingLabel{ 636 | createdAt: time.Now(), 637 | callback: callback, 638 | } 639 | return serializeLabel(label) 640 | } 641 | 642 | func (irc *Connection) unregisterLabel(labelStr string) { 643 | label := deserializeLabel(labelStr) 644 | if label == 0 { 645 | return 646 | } 647 | irc.batchMutex.Lock() 648 | defer irc.batchMutex.Unlock() 649 | delete(irc.labelCallbacks, label) 650 | } 651 | 652 | // expire open batches from the server that weren't closed in a 653 | // timely fashion. `force` expires all label callbacks regardless 654 | // of time created (so they can be cleaned up when the connection 655 | // fails). 656 | func (irc *Connection) expireBatches(force bool) { 657 | var failedCallbacks []LabelCallback 658 | defer func() { 659 | for _, bcb := range failedCallbacks { 660 | bcb(nil) 661 | } 662 | }() 663 | 664 | irc.batchMutex.Lock() 665 | defer irc.batchMutex.Unlock() 666 | now := time.Now() 667 | 668 | for label, lcb := range irc.labelCallbacks { 669 | if force || now.Sub(lcb.createdAt) > irc.KeepAlive { 670 | failedCallbacks = append(failedCallbacks, lcb.callback) 671 | delete(irc.labelCallbacks, label) 672 | } 673 | } 674 | 675 | for batchID, bip := range irc.batches { 676 | if now.Sub(bip.createdAt) > irc.KeepAlive { 677 | delete(irc.batches, batchID) 678 | } 679 | } 680 | } 681 | 682 | func splitCAPToken(token string) (name, value string) { 683 | equalIdx := strings.IndexByte(token, '=') 684 | if equalIdx == -1 { 685 | return token, "" 686 | } else { 687 | return token[:equalIdx], token[equalIdx+1:] 688 | } 689 | } 690 | 691 | func (irc *Connection) handleStandardReplies(e ircmsg.Message) { 692 | // unconditionally print messages for FAIL and WARN; 693 | // re. NOTE, if debug is enabled, we print the raw line anyway 694 | switch e.Command { 695 | case "FAIL", "WARN": 696 | irc.Log.Printf("Received error code from server: %s %s\n", e.Command, strings.Join(e.Params, " ")) 697 | } 698 | } 699 | 700 | const ( 701 | labelBase = 32 702 | ) 703 | 704 | func serializeLabel(label int64) string { 705 | return strconv.FormatInt(label, labelBase) 706 | } 707 | 708 | func deserializeLabel(str string) int64 { 709 | if p, err := strconv.ParseInt(str, labelBase, 64); err == nil { 710 | return p 711 | } 712 | return 0 713 | } 714 | -------------------------------------------------------------------------------- /ircevent/irc_ctcp.go: -------------------------------------------------------------------------------- 1 | package ircevent 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/ergochat/irc-go/ircmsg" 9 | ) 10 | 11 | func eventRewriteCTCP(event *ircmsg.Message) { 12 | // XXX rewrite event.Command for CTCP 13 | if !(event.Command == "PRIVMSG" && len(event.Params) == 2 && strings.HasPrefix(event.Params[1], "\x01")) { 14 | return 15 | } 16 | 17 | msg := event.Params[1] 18 | event.Command = "CTCP" //Unknown CTCP 19 | 20 | if i := strings.LastIndex(msg, "\x01"); i > 0 { 21 | msg = msg[1:i] 22 | } else { 23 | return 24 | } 25 | 26 | if msg == "VERSION" { 27 | event.Command = "CTCP_VERSION" 28 | } else if msg == "TIME" { 29 | event.Command = "CTCP_TIME" 30 | } else if strings.HasPrefix(msg, "PING") { 31 | event.Command = "CTCP_PING" 32 | } else if msg == "USERINFO" { 33 | event.Command = "CTCP_USERINFO" 34 | } else if msg == "CLIENTINFO" { 35 | event.Command = "CTCP_CLIENTINFO" 36 | } else if strings.HasPrefix(msg, "ACTION") { 37 | event.Command = "CTCP_ACTION" 38 | if len(msg) > 6 { 39 | msg = msg[7:] 40 | } else { 41 | msg = "" 42 | } 43 | } 44 | 45 | event.Params[len(event.Params)-1] = msg 46 | } 47 | 48 | func (irc *Connection) setupCTCPCallbacks() { 49 | irc.AddCallback("CTCP_VERSION", func(e ircmsg.Message) { 50 | irc.SendRaw(fmt.Sprintf("NOTICE %s :\x01VERSION %s\x01", e.Nick(), irc.Version)) 51 | }) 52 | 53 | irc.AddCallback("CTCP_USERINFO", func(e ircmsg.Message) { 54 | irc.SendRaw(fmt.Sprintf("NOTICE %s :\x01USERINFO %s\x01", e.Nick(), irc.User)) 55 | }) 56 | 57 | irc.AddCallback("CTCP_CLIENTINFO", func(e ircmsg.Message) { 58 | irc.SendRaw(fmt.Sprintf("NOTICE %s :\x01CLIENTINFO PING VERSION TIME USERINFO CLIENTINFO\x01", e.Nick())) 59 | }) 60 | 61 | irc.AddCallback("CTCP_TIME", func(e ircmsg.Message) { 62 | irc.SendRaw(fmt.Sprintf("NOTICE %s :\x01TIME %s\x01", e.Nick(), time.Now().UTC().Format(time.RFC1123))) 63 | }) 64 | 65 | irc.AddCallback("CTCP_PING", func(e ircmsg.Message) { 66 | irc.SendRaw(fmt.Sprintf("NOTICE %s :\x01%s\x01", e.Nick(), e.Params[1])) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /ircevent/irc_labeledresponse_test.go: -------------------------------------------------------------------------------- 1 | package ircevent 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "fmt" 8 | "testing" 9 | 10 | "github.com/ergochat/irc-go/ircmsg" 11 | ) 12 | 13 | const ( 14 | multilineName = "draft/multiline" 15 | chathistoryName = "draft/chathistory" 16 | concatTag = "draft/multiline-concat" 17 | playbackCap = "draft/event-playback" 18 | ) 19 | 20 | func TestLabeledResponse(t *testing.T) { 21 | irccon := connForTesting("go-eventirc", "go-eventirc", false) 22 | irccon.Debug = true 23 | irccon.RequestCaps = []string{"message-tags", "batch", "labeled-response"} 24 | irccon.RealName = "ecf61da38b58" 25 | results := make(map[string]string) 26 | irccon.AddConnectCallback(func(e ircmsg.Message) { 27 | irccon.SendWithLabel(func(batch *Batch) { 28 | if batch == nil { 29 | return 30 | } 31 | for _, line := range batch.Items { 32 | results[line.Command] = line.Params[len(line.Params)-1] 33 | } 34 | irccon.Quit() 35 | }, nil, "WHOIS", irccon.CurrentNick()) 36 | }) 37 | err := irccon.Connect() 38 | if err != nil { 39 | t.Fatalf("labeled response connection failed: %s", err) 40 | } 41 | irccon.Loop() 42 | 43 | // RPL_WHOISUSER, last param is the realname 44 | assertEqual(results["311"], "ecf61da38b58") 45 | if _, ok := results["379"]; !ok { 46 | t.Errorf("Expected 379 RPL_WHOISMODES in response, but not received") 47 | } 48 | assertEqual(len(irccon.batches), 0) 49 | } 50 | 51 | func TestLabeledResponseNoCaps(t *testing.T) { 52 | irccon := connForTesting("go-eventirc", "go-eventirc", false) 53 | irccon.Debug = true 54 | irccon.RequestCaps = []string{"message-tags"} 55 | irccon.RealName = "ecf61da38b58" 56 | 57 | err := irccon.Connect() 58 | if err != nil { 59 | t.Fatalf("labeled response connection failed: %s", err) 60 | } 61 | go irccon.Loop() 62 | 63 | results := make(map[string]string) 64 | err = irccon.SendWithLabel(func(batch *Batch) { 65 | if batch == nil { 66 | return 67 | } 68 | for _, line := range batch.Items { 69 | results[line.Command] = line.Params[len(line.Params)-1] 70 | } 71 | irccon.Quit() 72 | }, nil, "WHOIS", irccon.CurrentNick()) 73 | if err != CapabilityNotNegotiated { 74 | t.Errorf("expected capability negotiation error, got %v", err) 75 | } 76 | assertEqual(len(irccon.batches), 0) 77 | irccon.Quit() 78 | } 79 | 80 | // test labeled single-line response, and labeled ACK 81 | func TestLabeledResponseSingleResponse(t *testing.T) { 82 | irc := connForTesting("go-eventirc", "go-eventirc", false) 83 | irc.Debug = true 84 | irc.RequestCaps = []string{"message-tags", "batch", "labeled-response"} 85 | 86 | err := irc.Connect() 87 | if err != nil { 88 | t.Fatalf("labeled response connection failed: %s", err) 89 | } 90 | go irc.Loop() 91 | 92 | channel := fmt.Sprintf("#%s", randomString()) 93 | irc.Join(channel) 94 | event := make(chan empty) 95 | err = irc.SendWithLabel(func(batch *Batch) { 96 | if !(batch != nil && batch.Command == "PONG" && batch.Params[len(batch.Params)-1] == "asdf") { 97 | t.Errorf("expected labeled PONG, got %#v", batch) 98 | } 99 | close(event) 100 | }, nil, "PING", "asdf") 101 | <-event 102 | 103 | // no-op JOIN will send labeled ACK 104 | event = make(chan empty) 105 | err = irc.SendWithLabel(func(batch *Batch) { 106 | if !(batch != nil && batch.Command == "ACK") { 107 | t.Errorf("expected labeled ACK, got %#v", batch) 108 | } 109 | close(event) 110 | }, nil, "JOIN", channel) 111 | <-event 112 | 113 | assertEqual(len(irc.batches), 0) 114 | irc.Quit() 115 | } 116 | 117 | func randomString() string { 118 | buf := make([]byte, 8) 119 | rand.Read(buf) 120 | return hex.EncodeToString(buf) 121 | } 122 | 123 | func TestNestedBatch(t *testing.T) { 124 | irc := connForTesting("go-eventirc", "go-eventirc", false) 125 | irc.Debug = true 126 | irc.RequestCaps = []string{"message-tags", "batch", "labeled-response", "server-time", multilineName, chathistoryName, playbackCap} 127 | channel := fmt.Sprintf("#%s", randomString()) 128 | 129 | err := irc.Connect() 130 | if err != nil { 131 | t.Fatalf("labeled response connection failed: %s", err) 132 | } 133 | go irc.Loop() 134 | 135 | irc.Join(channel) 136 | irc.Privmsg(channel, "hi") 137 | irc.Send("BATCH", "+123", "draft/multiline", channel) 138 | irc.SendWithTags(map[string]string{"batch": "123"}, "PRIVMSG", channel, "hello") 139 | irc.SendWithTags(map[string]string{"batch": "123"}, "PRIVMSG", channel, "") 140 | irc.SendWithTags(map[string]string{"batch": "123", concatTag: ""}, "PRIVMSG", channel, "how is ") 141 | irc.SendWithTags(map[string]string{"batch": "123"}, "PRIVMSG", channel, "everyone?") 142 | irc.Send("BATCH", "-123") 143 | 144 | var historyBatch *Batch 145 | event := make(chan empty) 146 | irc.SendWithLabel(func(batch *Batch) { 147 | historyBatch = batch 148 | close(event) 149 | }, nil, "CHATHISTORY", "LATEST", channel, "*", "10") 150 | 151 | <-event 152 | assertEqual(len(irc.labelCallbacks), 0) 153 | 154 | if historyBatch == nil { 155 | t.Errorf("received nil history batch") 156 | } 157 | 158 | // history should contain the JOIN, the PRIVMSG, and the multiline batch as a single item 159 | if !(historyBatch.Command == "BATCH" && len(historyBatch.Items) == 3) { 160 | t.Errorf("chathistory must send a real batch, got %#v", historyBatch) 161 | } 162 | var privmsg, multiline *Batch 163 | for _, item := range historyBatch.Items { 164 | switch item.Command { 165 | case "PRIVMSG": 166 | privmsg = item 167 | case "BATCH": 168 | multiline = item 169 | } 170 | } 171 | if !(privmsg.Command == "PRIVMSG" && privmsg.Params[0] == channel && privmsg.Params[1] == "hi") { 172 | t.Errorf("expected echo of individual privmsg, got %#v", privmsg) 173 | } 174 | if !(multiline.Command == "BATCH" && len(multiline.Items) == 4 && multiline.Items[3].Command == "PRIVMSG" && multiline.Items[3].Params[1] == "everyone?") { 175 | t.Errorf("expected multiline in history, got %#v\n", multiline) 176 | } 177 | 178 | assertEqual(len(irc.batches), 0) 179 | irc.Quit() 180 | } 181 | 182 | func TestBatchHandlers(t *testing.T) { 183 | alice := connForTesting("alice", "go-eventirc", false) 184 | alice.Debug = true 185 | alice.RequestCaps = []string{"message-tags", "batch", "labeled-response", "server-time", "echo-message", multilineName, chathistoryName, playbackCap} 186 | channel := fmt.Sprintf("#%s", randomString()) 187 | 188 | aliceUnderstandsBatches := true 189 | var aliceBatchCount, alicePrivmsgCount int 190 | alice.AddBatchCallback(func(batch *Batch) bool { 191 | if aliceUnderstandsBatches { 192 | aliceBatchCount++ 193 | return true 194 | } 195 | return false 196 | }) 197 | alice.AddCallback("PRIVMSG", func(e ircmsg.Message) { 198 | alicePrivmsgCount++ 199 | }) 200 | 201 | err := alice.Connect() 202 | if err != nil { 203 | t.Fatalf("labeled response connection failed: %s", err) 204 | } 205 | go alice.Loop() 206 | alice.Join(channel) 207 | synchronize(alice) 208 | 209 | bob := connForTesting("bob", "go-eventirc", false) 210 | bob.Debug = true 211 | bob.RequestCaps = []string{"message-tags", "batch", "labeled-response", "server-time", "echo-message", multilineName, chathistoryName, playbackCap} 212 | var buf bytes.Buffer 213 | bob.AddBatchCallback(func(b *Batch) bool { 214 | if !(len(b.Params) >= 3 && b.Params[1] == multilineName) { 215 | return false 216 | } 217 | for i, item := range b.Items { 218 | if item.Command == "PRIVMSG" { 219 | buf.WriteString(item.Params[1]) 220 | if !(item.HasTag(concatTag) || i == len(b.Items)-1) { 221 | buf.WriteByte('\n') 222 | } 223 | } 224 | } 225 | return true 226 | }) 227 | 228 | err = bob.Connect() 229 | if err != nil { 230 | t.Fatalf("labeled response connection failed: %s", err) 231 | } 232 | go bob.Loop() 233 | bob.Join(channel) 234 | synchronize(bob) 235 | 236 | sendMultiline := func() { 237 | alice.Send("BATCH", "+123", "draft/multiline", channel) 238 | alice.SendWithTags(map[string]string{"batch": "123"}, "PRIVMSG", channel, "hello") 239 | alice.SendWithTags(map[string]string{"batch": "123"}, "PRIVMSG", channel, "") 240 | alice.SendWithTags(map[string]string{"batch": "123", concatTag: ""}, "PRIVMSG", channel, "how is ") 241 | alice.SendWithTags(map[string]string{"batch": "123"}, "PRIVMSG", channel, "everyone?") 242 | alice.Send("BATCH", "-123") 243 | synchronize(alice) 244 | synchronize(bob) 245 | } 246 | multilineMessageValue := "hello\n\nhow is everyone?" 247 | 248 | sendMultiline() 249 | 250 | assertEqual(alicePrivmsgCount, 0) 251 | alicePrivmsgCount = 0 252 | assertEqual(aliceBatchCount, 1) 253 | aliceBatchCount = 0 254 | 255 | assertEqual(buf.String(), multilineMessageValue) 256 | buf.Reset() 257 | 258 | aliceUnderstandsBatches = false 259 | sendMultiline() 260 | 261 | // disabled alice's batch handler, she should see a flattened batch 262 | assertEqual(alicePrivmsgCount, 4) 263 | assertEqual(aliceBatchCount, 0) 264 | 265 | assertEqual(buf.String(), multilineMessageValue) 266 | 267 | assertEqual(len(alice.batches), 0) 268 | assertEqual(len(bob.batches), 0) 269 | alice.Quit() 270 | bob.Quit() 271 | } 272 | 273 | func synchronize(irc *Connection) { 274 | event := make(chan empty) 275 | irc.SendWithLabel(func(b *Batch) { 276 | close(event) 277 | }, nil, "PING", "!") 278 | <-event 279 | } 280 | 281 | func TestSynchronousLabeledResponse(t *testing.T) { 282 | irccon := connForTesting("go-eventirc", "go-eventirc", false) 283 | irccon.Debug = true 284 | irccon.RequestCaps = []string{"message-tags", "batch", "labeled-response"} 285 | irccon.RealName = "Al_b6AHLrxh8TZb5kNO1gw" 286 | err := irccon.Connect() 287 | if err != nil { 288 | t.Fatalf("labeled response connection failed: %s", err) 289 | } 290 | go irccon.Loop() 291 | 292 | batch, err := irccon.GetLabeledResponse(nil, "WHOIS", irccon.CurrentNick()) 293 | if err != nil { 294 | t.Fatalf("labeled response failed: %v", err) 295 | } 296 | assertEqual(batch.Command, "BATCH") 297 | results := make(map[string]string) 298 | for _, line := range batch.Items { 299 | results[line.Command] = line.Params[len(line.Params)-1] 300 | } 301 | 302 | // RPL_WHOISUSER, last param is the realname 303 | assertEqual(results["311"], "Al_b6AHLrxh8TZb5kNO1gw") 304 | if _, ok := results["379"]; !ok { 305 | t.Errorf("Expected 379 RPL_WHOISMODES in response, but not received") 306 | } 307 | assertEqual(len(irccon.batches), 0) 308 | } 309 | -------------------------------------------------------------------------------- /ircevent/irc_parse_test.go: -------------------------------------------------------------------------------- 1 | package ircevent 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestParse(t *testing.T) { 10 | source := "nick!~user@host" 11 | nick, user, host := SplitNUH(source) 12 | 13 | if nick != "nick" { 14 | t.Fatal("Parse failed: nick") 15 | } 16 | if user != "~user" { 17 | t.Fatal("Parse failed: user") 18 | } 19 | if host != "host" { 20 | t.Fatal("Parse failed: host") 21 | } 22 | } 23 | 24 | func assertEqual(found, expected interface{}) { 25 | if !reflect.DeepEqual(found, expected) { 26 | panic(fmt.Sprintf("expected `%#v`, got `%#v`\n", expected, found)) 27 | } 28 | } 29 | 30 | func TestUnescapeIsupport(t *testing.T) { 31 | assertEqual(unescapeISupportValue(""), "") 32 | assertEqual(unescapeISupportValue("a"), "a") 33 | assertEqual(unescapeISupportValue(`\x20`), " ") 34 | assertEqual(unescapeISupportValue(`\x20b`), " b") 35 | assertEqual(unescapeISupportValue(`a\x20`), "a ") 36 | assertEqual(unescapeISupportValue(`a\x20b`), "a b") 37 | assertEqual(unescapeISupportValue(`\x20\x20`), " ") 38 | } 39 | -------------------------------------------------------------------------------- /ircevent/irc_sasl.go: -------------------------------------------------------------------------------- 1 | package ircevent 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | 7 | "github.com/ergochat/irc-go/ircmsg" 8 | "github.com/ergochat/irc-go/ircutils" 9 | ) 10 | 11 | type saslResult struct { 12 | Failed bool 13 | Err error 14 | } 15 | 16 | func sliceContains(str string, list []string) bool { 17 | for _, x := range list { 18 | if x == str { 19 | return true 20 | } 21 | } 22 | return false 23 | } 24 | 25 | func (irc *Connection) submitSASLResult(r saslResult) { 26 | select { 27 | case irc.saslChan <- r: 28 | default: 29 | } 30 | } 31 | 32 | func (irc *Connection) composeSaslPlainResponse() []byte { 33 | var buf bytes.Buffer 34 | buf.WriteString(irc.SASLLogin) // optional authzid, included for compatibility 35 | buf.WriteByte('\x00') 36 | buf.WriteString(irc.SASLLogin) // authcid 37 | buf.WriteByte('\x00') 38 | buf.WriteString(irc.SASLPassword) // passwd 39 | return buf.Bytes() 40 | } 41 | 42 | func (irc *Connection) setupSASLCallbacks() { 43 | irc.AddCallback("AUTHENTICATE", func(e ircmsg.Message) { 44 | switch irc.SASLMech { 45 | case "PLAIN": 46 | for _, resp := range ircutils.EncodeSASLResponse(irc.composeSaslPlainResponse()) { 47 | irc.Send("AUTHENTICATE", resp) 48 | } 49 | case "EXTERNAL": 50 | irc.Send("AUTHENTICATE", "+") 51 | default: 52 | // impossible, nothing to do 53 | } 54 | }) 55 | 56 | irc.AddCallback(RPL_LOGGEDOUT, func(e ircmsg.Message) { 57 | irc.SendRaw("CAP END") 58 | irc.SendRaw("QUIT") 59 | irc.submitSASLResult(saslResult{true, errors.New(e.Params[1])}) 60 | }) 61 | 62 | irc.AddCallback(ERR_NICKLOCKED, func(e ircmsg.Message) { 63 | irc.SendRaw("CAP END") 64 | irc.SendRaw("QUIT") 65 | irc.submitSASLResult(saslResult{true, errors.New(e.Params[1])}) 66 | }) 67 | 68 | irc.AddCallback(RPL_SASLSUCCESS, func(e ircmsg.Message) { 69 | irc.submitSASLResult(saslResult{false, nil}) 70 | }) 71 | 72 | irc.AddCallback(ERR_SASLFAIL, func(e ircmsg.Message) { 73 | irc.SendRaw("CAP END") 74 | irc.SendRaw("QUIT") 75 | irc.submitSASLResult(saslResult{true, errors.New(e.Params[1])}) 76 | }) 77 | 78 | // this could potentially happen with auto-login via certfp? 79 | irc.AddCallback(ERR_SASLALREADY, func(e ircmsg.Message) { 80 | irc.submitSASLResult(saslResult{false, nil}) 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /ircevent/irc_sasl_test.go: -------------------------------------------------------------------------------- 1 | package ircevent 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/ergochat/irc-go/ircmsg" 10 | ) 11 | 12 | const ( 13 | serverEnvVar = "IRCEVENT_SERVER" 14 | saslAccVar = "IRCEVENT_SASL_LOGIN" 15 | saslPassVar = "IRCEVENT_SASL_PASSWORD" 16 | ) 17 | 18 | func setSaslTestCreds(irc *Connection, t *testing.T) { 19 | acc := os.Getenv(saslAccVar) 20 | if acc == "" { 21 | t.Fatalf("define %s and %s environment variables to test SASL", saslAccVar, saslPassVar) 22 | } 23 | irc.SASLLogin = acc 24 | irc.SASLPassword = os.Getenv(saslPassVar) 25 | } 26 | 27 | func getenv(key, defaultValue string) (value string) { 28 | value = os.Getenv(key) 29 | if value == "" { 30 | value = defaultValue 31 | } 32 | return 33 | } 34 | 35 | func getServer(sasl bool) string { 36 | port := 6667 37 | if sasl { 38 | port = 6697 39 | } 40 | return fmt.Sprintf("%s:%d", getenv(serverEnvVar, "localhost"), port) 41 | } 42 | 43 | // set SASLLogin and SASLPassword environment variables before testing 44 | func runCAPTest(caps []string, useSASL bool, t *testing.T) { 45 | irccon := connForTesting("go-eventirc", "go-eventirc", true) 46 | irccon.Debug = true 47 | irccon.UseTLS = true 48 | if useSASL { 49 | setSaslTestCreds(irccon, t) 50 | } 51 | irccon.RequestCaps = caps 52 | irccon.TLSConfig = &tls.Config{InsecureSkipVerify: true} 53 | irccon.AddCallback("001", func(e ircmsg.Message) { irccon.Join("#go-eventirc") }) 54 | 55 | irccon.AddCallback("366", func(e ircmsg.Message) { 56 | irccon.Privmsg("#go-eventirc", "Test Message SASL") 57 | irccon.Quit() 58 | }) 59 | 60 | err := irccon.Connect() 61 | if err != nil { 62 | t.Fatalf("SASL failed: %s", err) 63 | } 64 | irccon.Loop() 65 | } 66 | 67 | func TestConnectionSASL(t *testing.T) { 68 | runCAPTest(nil, true, t) 69 | } 70 | 71 | func TestConnectionSASLWithAdditionalCaps(t *testing.T) { 72 | runCAPTest([]string{"server-time", "message-tags", "batch", "labeled-response", "echo-message"}, true, t) 73 | } 74 | 75 | func TestConnectionSASLWithNonexistentCaps(t *testing.T) { 76 | runCAPTest([]string{"server-time", "message-tags", "batch", "labeled-response", "echo-message", "oragono.io/xyzzy"}, true, t) 77 | } 78 | 79 | func TestConnectionSASLWithNonexistentCapsOnly(t *testing.T) { 80 | runCAPTest([]string{"oragono.io/xyzzy"}, true, t) 81 | } 82 | 83 | func TestConnectionNonexistentCAPOnly(t *testing.T) { 84 | runCAPTest([]string{"oragono.io/xyzzy"}, false, t) 85 | } 86 | 87 | func TestConnectionNonexistentCAPs(t *testing.T) { 88 | runCAPTest([]string{"oragono.io/xyzzy", "server-time", "message-tags"}, false, t) 89 | } 90 | 91 | func TestConnectionGoodCAPs(t *testing.T) { 92 | runCAPTest([]string{"server-time", "message-tags"}, false, t) 93 | } 94 | 95 | func TestSASLFail(t *testing.T) { 96 | irccon := connForTesting("go-eventirc", "go-eventirc", true) 97 | irccon.Debug = true 98 | irccon.UseTLS = true 99 | setSaslTestCreds(irccon, t) 100 | irccon.TLSConfig = &tls.Config{InsecureSkipVerify: true} 101 | irccon.AddCallback("001", func(e ircmsg.Message) { irccon.Join("#go-eventirc") }) 102 | // intentionally break the password 103 | irccon.SASLPassword = irccon.SASLPassword + "_" 104 | err := irccon.Connect() 105 | if err == nil { 106 | t.Errorf("successfully connected with invalid password") 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /ircevent/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 ircevent 6 | 7 | import ( 8 | "context" 9 | "crypto/tls" 10 | "log" 11 | "net" 12 | "strings" 13 | "sync" 14 | "sync/atomic" 15 | "time" 16 | 17 | "github.com/ergochat/irc-go/ircmsg" 18 | ) 19 | 20 | type empty struct{} 21 | 22 | type Callback func(ircmsg.Message) 23 | 24 | type callbackPair struct { 25 | id uint64 26 | callback Callback 27 | } 28 | 29 | type BatchCallback func(*Batch) bool 30 | 31 | type batchCallbackPair struct { 32 | id uint64 33 | callback BatchCallback 34 | } 35 | 36 | type LabelCallback func(*Batch) 37 | 38 | type capResult struct { 39 | capName string 40 | ack bool 41 | } 42 | 43 | type Connection struct { 44 | // config data, user-settable 45 | Server string 46 | TLSConfig *tls.Config 47 | Nick string 48 | User string 49 | RealName string // IRC realname/gecos 50 | WebIRC []string // parameters for the WEBIRC command 51 | Password string // server password (PASS command) 52 | RequestCaps []string // IRCv3 capabilities to request (failure is non-fatal) 53 | SASLLogin string // SASL credentials to log in with (failure is fatal by default) 54 | SASLPassword string 55 | SASLMech string 56 | SASLOptional bool // make SASL failure non-fatal 57 | QuitMessage string 58 | Version string 59 | Timeout time.Duration 60 | KeepAlive time.Duration 61 | ReconnectFreq time.Duration 62 | MaxLineLen int // maximum line length, not including tags 63 | UseTLS bool 64 | UseSASL bool 65 | EnableCTCP bool 66 | Debug bool 67 | AllowPanic bool // if set, don't recover() from panics in callbacks 68 | AllowTruncation bool // if set, truncate lines exceeding MaxLineLen and send them 69 | // set this to configure how the connection is made (e.g. via a proxy server): 70 | DialContext func(ctx context.Context, network, addr string) (net.Conn, error) 71 | 72 | // networking and synchronization 73 | stateMutex sync.Mutex // innermost mutex: don't block while holding this 74 | end chan empty // closing this causes the goroutines to exit 75 | pwrite chan []byte // receives IRC lines to be sent to the socket 76 | reconnSig chan empty // interrupts sleep in between reconnects (#79) 77 | wg sync.WaitGroup // after closing end, wait on this for all the goroutines to stop 78 | socket net.Conn 79 | lastError error 80 | quitAt time.Time // time Quit() was called 81 | running bool // is a connection active? is `end` open? 82 | quit bool // user called Quit, do not reconnect 83 | pingSent bool // we sent PING and are waiting for PONG 84 | 85 | // IRC protocol connection state 86 | currentNick string // nickname assigned by the server, empty before registration 87 | capsAdvertised map[string]string 88 | capsAcked map[string]string 89 | isupport map[string]string 90 | isupportPartial map[string]string 91 | nickCounter int 92 | registered bool 93 | // Connect() builds these with sufficient capacity to receive all expected 94 | // responses during negotiation. Sends to them are nonblocking, so anything 95 | // sent outside of negotiation will not cause the relevant callbacks to block. 96 | welcomeChan chan empty // signals that we got 001 and we are now connected 97 | saslChan chan saslResult // transmits the final outcome of SASL negotiation 98 | capsChan chan capResult // transmits the final status of each CAP negotiated 99 | capFlags uint32 100 | 101 | // callback state 102 | eventsMutex sync.Mutex 103 | events map[string][]callbackPair 104 | // we assign ID numbers to callbacks so they can be removed. normally 105 | // the ID number is globally unique (generated by incrementing this counter). 106 | // if we add a callback in two places we might reuse the number (XXX) 107 | callbackCounter uint64 108 | // did we initialize the callbacks needed for the library itself? 109 | batchCallbacks []batchCallbackPair 110 | hasBaseCallbacks bool 111 | 112 | batchMutex sync.Mutex 113 | batches map[string]batchInProgress 114 | labelCallbacks map[int64]pendingLabel 115 | labelCounter int64 116 | 117 | Log *log.Logger 118 | } 119 | 120 | type batchInProgress struct { 121 | createdAt time.Time 122 | label int64 123 | // needs to be heap-allocated so we can append to batch.Items: 124 | batch *Batch 125 | } 126 | 127 | type pendingLabel struct { 128 | createdAt time.Time 129 | callback LabelCallback 130 | } 131 | 132 | // Batch represents an IRCv3 batch, or a line within one. There are 133 | // two cases: 134 | // 1. (Batch).Command == "BATCH". This indicates the start of an IRCv3 135 | // batch; the embedded Message is the initial BATCH command, which 136 | // may contain tags that pertain to the batch as a whole. (Batch).Items 137 | // contains zero or more *Batch elements, pointing to the contents of 138 | // the batch in order. 139 | // 2. (Batch).Command != "BATCH". This is an ordinary IRC line; its 140 | // tags, command, and parameters are available as members of the embedded 141 | // Message. 142 | // In the context of labeled-response, there is a third case: a `nil` 143 | // value of *Batch indicates that the server failed to respond in time. 144 | type Batch struct { 145 | ircmsg.Message 146 | Items []*Batch 147 | } 148 | 149 | const ( 150 | capFlagBatch uint32 = 1 << iota 151 | capFlagMessageTags 152 | capFlagLabeledResponse 153 | capFlagMultiline 154 | ) 155 | 156 | func (irc *Connection) processAckedCaps(acknowledgedCaps []string) { 157 | irc.stateMutex.Lock() 158 | defer irc.stateMutex.Unlock() 159 | var hasBatch, hasLabel, hasTags, hasMultiline bool 160 | for _, c := range acknowledgedCaps { 161 | irc.capsAcked[c] = irc.capsAdvertised[c] 162 | switch c { 163 | case "batch": 164 | hasBatch = true 165 | case "labeled-response": 166 | hasLabel = true 167 | case "message-tags": 168 | hasTags = true 169 | case "draft/multiline", "multiline": 170 | hasMultiline = true 171 | } 172 | } 173 | 174 | var capFlags uint32 175 | if hasBatch { 176 | capFlags |= capFlagBatch 177 | } 178 | if hasBatch && hasLabel { 179 | capFlags |= capFlagLabeledResponse 180 | } 181 | if hasTags { 182 | capFlags |= capFlagMessageTags 183 | } 184 | if hasTags && hasBatch && hasMultiline { 185 | capFlags |= capFlagMultiline 186 | } 187 | 188 | atomic.StoreUint32(&irc.capFlags, capFlags) 189 | } 190 | 191 | func (irc *Connection) batchNegotiated() bool { 192 | return atomic.LoadUint32(&irc.capFlags)&capFlagBatch != 0 193 | } 194 | 195 | func (irc *Connection) labelNegotiated() bool { 196 | return atomic.LoadUint32(&irc.capFlags)&capFlagLabeledResponse != 0 197 | } 198 | 199 | // GetReplyTarget attempts to determine where replies to a PRIVMSG or NOTICE 200 | // should be sent (a channel if the message was sent to a channel, a nick 201 | // if the message was a direct message from a valid nickname). If no valid 202 | // reply target can be determined, it returns the empty string. 203 | func (irc *Connection) GetReplyTarget(msg ircmsg.Message) string { 204 | switch msg.Command { 205 | case "PRIVMSG", "NOTICE", "TAGMSG": 206 | if len(msg.Params) == 0 { 207 | return "" 208 | } 209 | target := msg.Params[0] 210 | chanTypes := irc.ISupport()["CHANTYPES"] 211 | if chanTypes == "" { 212 | chanTypes = "#" 213 | } 214 | for i := 0; i < len(chanTypes); i++ { 215 | if strings.HasPrefix(target, chanTypes[i:i+1]) { 216 | return target 217 | } 218 | } 219 | // this was not a channel message: attempt to reply to the source 220 | if nuh, err := msg.NUH(); err == nil { 221 | if strings.IndexByte(nuh.Name, '.') == -1 { 222 | return nuh.Name 223 | } else { 224 | // this is probably a server name 225 | return "" 226 | } 227 | } else { 228 | return "" 229 | } 230 | default: 231 | return "" 232 | } 233 | } 234 | 235 | // Deprecated; use (*ircmsg.Message).Nick() instead 236 | func ExtractNick(source string) string { 237 | nuh, err := ircmsg.ParseNUH(source) 238 | if err == nil { 239 | return nuh.Name 240 | } 241 | return "" 242 | } 243 | 244 | // Deprecated; use (*ircmsg.Message).NUH() instead 245 | func SplitNUH(source string) (nick, user, host string) { 246 | nuh, err := ircmsg.ParseNUH(source) 247 | if err == nil { 248 | return nuh.Name, nuh.User, nuh.Host 249 | } 250 | return 251 | } 252 | 253 | func lastParam(msg *ircmsg.Message) (result string) { 254 | if 0 < len(msg.Params) { 255 | return msg.Params[len(msg.Params)-1] 256 | } 257 | return 258 | } 259 | -------------------------------------------------------------------------------- /ircevent/irc_test.go: -------------------------------------------------------------------------------- 1 | package ircevent 2 | 3 | import ( 4 | "crypto/tls" 5 | "math/rand" 6 | "sort" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/ergochat/irc-go/ircmsg" 12 | ) 13 | 14 | const channel = "#go-eventirc-test" 15 | const dict = "abcdefghijklmnopqrstuvwxyz" 16 | 17 | // Spammy 18 | const verbose_tests = false 19 | const debug_tests = true 20 | 21 | func connForTesting(nick, user string, tls bool) *Connection { 22 | irc := &Connection{ 23 | Nick: nick, 24 | User: user, 25 | Server: getServer(tls), 26 | } 27 | return irc 28 | } 29 | 30 | func mockEvent(command string) ircmsg.Message { 31 | return ircmsg.MakeMessage(nil, ":server.name", command) 32 | } 33 | 34 | func TestRemoveCallback(t *testing.T) { 35 | irccon := connForTesting("go-eventirc", "go-eventirc", false) 36 | debugTest(irccon) 37 | 38 | done := make(chan int, 10) 39 | 40 | irccon.AddCallback("TEST", func(e ircmsg.Message) { done <- 1 }) 41 | id := irccon.AddCallback("TEST", func(e ircmsg.Message) { done <- 2 }) 42 | irccon.AddCallback("TEST", func(e ircmsg.Message) { done <- 3 }) 43 | 44 | // Should remove callback at index 1 45 | irccon.RemoveCallback(id) 46 | 47 | irccon.runCallbacks(mockEvent("TEST")) 48 | 49 | var results []int 50 | 51 | results = append(results, <-done) 52 | results = append(results, <-done) 53 | 54 | if !compareResults(results, 1, 3) { 55 | t.Error("Callback 2 not removed") 56 | } 57 | } 58 | 59 | func TestClearCallback(t *testing.T) { 60 | irccon := connForTesting("go-eventirc", "go-eventirc", false) 61 | debugTest(irccon) 62 | 63 | done := make(chan int, 10) 64 | 65 | irccon.AddCallback("TEST", func(e ircmsg.Message) { done <- 0 }) 66 | irccon.AddCallback("TEST", func(e ircmsg.Message) { done <- 1 }) 67 | irccon.ClearCallback("TEST") 68 | irccon.AddCallback("TEST", func(e ircmsg.Message) { done <- 2 }) 69 | irccon.AddCallback("TEST", func(e ircmsg.Message) { done <- 3 }) 70 | 71 | irccon.runCallbacks(mockEvent("TEST")) 72 | 73 | var results []int 74 | 75 | results = append(results, <-done) 76 | results = append(results, <-done) 77 | 78 | if !compareResults(results, 2, 3) { 79 | t.Error("Callbacks not cleared") 80 | } 81 | } 82 | 83 | func TestIRCemptyNick(t *testing.T) { 84 | irccon := connForTesting("", "go-eventirc", false) 85 | irccon = nil 86 | if irccon != nil { 87 | t.Error("empty nick didn't result in error") 88 | t.Fail() 89 | } 90 | } 91 | 92 | func TestConnection(t *testing.T) { 93 | if testing.Short() { 94 | t.Skip("skipping test in short mode.") 95 | } 96 | rand.Seed(time.Now().UnixNano()) 97 | ircnick1 := randStr(8) 98 | ircnick2 := randStr(8) 99 | ircnick2orig := ircnick2 100 | irccon1 := connForTesting(ircnick1, "IRCTest1", false) 101 | debugTest(irccon1) 102 | 103 | irccon2 := connForTesting(ircnick2, "IRCTest2", false) 104 | debugTest(irccon2) 105 | 106 | teststr := randStr(20) 107 | testmsgok := make(chan bool, 1) 108 | 109 | irccon1.AddCallback("001", func(e ircmsg.Message) { irccon1.Join(channel) }) 110 | irccon2.AddCallback("001", func(e ircmsg.Message) { irccon2.Join(channel) }) 111 | irccon1.AddCallback("366", func(e ircmsg.Message) { 112 | go func(e ircmsg.Message) { 113 | tick := time.NewTicker(1 * time.Second) 114 | i := 10 115 | for { 116 | select { 117 | case <-tick.C: 118 | irccon1.Privmsgf(channel, "%s", teststr) 119 | if i == 0 { 120 | t.Errorf("Timeout while wating for test message from the other thread.") 121 | return 122 | } 123 | 124 | case <-testmsgok: 125 | tick.Stop() 126 | irccon1.Quit() 127 | return 128 | } 129 | i -= 1 130 | } 131 | }(e) 132 | }) 133 | 134 | irccon2.AddCallback("366", func(e ircmsg.Message) { 135 | ircnick2 = randStr(8) 136 | irccon2.SetNick(ircnick2) 137 | }) 138 | 139 | irccon2.AddCallback("PRIVMSG", func(e ircmsg.Message) { 140 | if e.Params[1] == teststr { 141 | if e.Nick() == ircnick1 { 142 | testmsgok <- true 143 | irccon2.Quit() 144 | } else { 145 | t.Errorf("Test message came from an unexpected nickname") 146 | } 147 | } else { 148 | //this may fail if there are other incoming messages, unlikely. 149 | t.Errorf("Test message mismatch") 150 | } 151 | }) 152 | 153 | irccon2.AddCallback("NICK", func(e ircmsg.Message) { 154 | if !(e.Nick() == ircnick2orig && e.Params[0] == ircnick2) { 155 | t.Errorf("Nick change did not work!") 156 | } 157 | }) 158 | 159 | err := irccon1.Connect() 160 | if err != nil { 161 | t.Log(err.Error()) 162 | t.Errorf("Can't connect to freenode.") 163 | } 164 | err = irccon2.Connect() 165 | if err != nil { 166 | t.Log(err.Error()) 167 | t.Errorf("Can't connect to freenode.") 168 | } 169 | 170 | go irccon2.Loop() 171 | irccon1.Loop() 172 | } 173 | 174 | func runReconnectTest(useSASL bool, t *testing.T) { 175 | ircnick1 := randStr(8) 176 | irccon := connForTesting(ircnick1, "IRCTestRe", false) 177 | irccon.ReconnectFreq = time.Second * 1 178 | if useSASL { 179 | setSaslTestCreds(irccon, t) 180 | } 181 | debugTest(irccon) 182 | 183 | connects := 0 184 | irccon.AddCallback("001", func(e ircmsg.Message) { irccon.Join(channel) }) 185 | 186 | irccon.AddCallback("366", func(e ircmsg.Message) { 187 | connects += 1 188 | if connects > 2 { 189 | irccon.Privmsgf(channel, "Connection nr %d (test done)", connects) 190 | go irccon.Quit() 191 | } else { 192 | irccon.Privmsgf(channel, "Connection nr %d", connects) 193 | // XXX: wait for the message to actually send before we hang up 194 | // (can this be avoided?) 195 | time.Sleep(100 * time.Millisecond) 196 | go irccon.Reconnect() 197 | } 198 | }) 199 | 200 | err := irccon.Connect() 201 | if err != nil { 202 | t.Log(err.Error()) 203 | t.Errorf("Can't connect to freenode.") 204 | } 205 | 206 | irccon.Loop() 207 | if connects != 3 { 208 | t.Errorf("Reconnect test failed. Connects = %d", connects) 209 | } 210 | } 211 | 212 | func TestReconnect(t *testing.T) { 213 | runReconnectTest(false, t) 214 | } 215 | 216 | func TestReconnectWithSASL(t *testing.T) { 217 | runReconnectTest(true, t) 218 | } 219 | 220 | func TestConnectionSSL(t *testing.T) { 221 | if testing.Short() { 222 | t.Skip("skipping test in short mode.") 223 | } 224 | ircnick1 := randStr(8) 225 | irccon := connForTesting(ircnick1, "IRCTestSSL", true) 226 | debugTest(irccon) 227 | irccon.UseTLS = true 228 | irccon.TLSConfig = &tls.Config{InsecureSkipVerify: true} 229 | irccon.AddCallback("001", func(e ircmsg.Message) { irccon.Join(channel) }) 230 | 231 | irccon.AddCallback("366", func(e ircmsg.Message) { 232 | irccon.Privmsg(channel, "Test Message from SSL") 233 | irccon.Quit() 234 | }) 235 | 236 | err := irccon.Connect() 237 | if err != nil { 238 | t.Log(err.Error()) 239 | t.Errorf("Can't connect to freenode.") 240 | } 241 | 242 | irccon.Loop() 243 | } 244 | 245 | // Helper Functions 246 | func randStr(n int) string { 247 | b := make([]byte, n) 248 | for i := range b { 249 | b[i] = dict[rand.Intn(len(dict))] 250 | } 251 | return string(b) 252 | } 253 | 254 | func debugTest(irccon *Connection) *Connection { 255 | irccon.Debug = debug_tests 256 | return irccon 257 | } 258 | 259 | func compareResults(received []int, desired ...int) bool { 260 | if len(desired) != len(received) { 261 | return false 262 | } 263 | sort.IntSlice(desired).Sort() 264 | sort.IntSlice(received).Sort() 265 | for i := 0; i < len(desired); i++ { 266 | if desired[i] != received[i] { 267 | return false 268 | } 269 | } 270 | return true 271 | } 272 | 273 | func TestConnectionNickInUse(t *testing.T) { 274 | rand.Seed(time.Now().UnixNano()) 275 | ircnick := randStr(8) 276 | irccon1 := connForTesting(ircnick, "IRCTest1", false) 277 | 278 | debugTest(irccon1) 279 | 280 | irccon2 := connForTesting(ircnick, "IRCTest2", false) 281 | debugTest(irccon2) 282 | 283 | n1 := make(chan string, 1) 284 | n2 := make(chan string, 1) 285 | 286 | // check the actual nick after 001 is processed 287 | irccon1.AddCallback("002", func(e ircmsg.Message) { n1 <- irccon1.CurrentNick() }) 288 | irccon2.AddCallback("002", func(e ircmsg.Message) { n2 <- irccon2.CurrentNick() }) 289 | 290 | err := irccon1.Connect() 291 | if err != nil { 292 | panic(err) 293 | } 294 | err = irccon2.Connect() 295 | if err != nil { 296 | panic(err) 297 | } 298 | 299 | go irccon2.Loop() 300 | go irccon1.Loop() 301 | nick1 := <-n1 302 | nick2 := <-n2 303 | irccon1.Quit() 304 | irccon2.Quit() 305 | // we should have gotten two different nicks, one a prefix of the other 306 | if nick1 == ircnick && len(nick1) < len(nick2) && strings.HasPrefix(nick2, nick1) { 307 | return 308 | } 309 | if nick2 == ircnick && len(nick2) < len(nick1) && strings.HasPrefix(nick1, nick2) { 310 | return 311 | } 312 | t.Errorf("expected %s and a suffixed version, got %s and %s", ircnick, nick1, nick2) 313 | } 314 | 315 | func TestConnectionCallbacks(t *testing.T) { 316 | rand.Seed(time.Now().UnixNano()) 317 | ircnick := randStr(8) 318 | irccon1 := connForTesting(ircnick, "IRCTest1", false) 319 | debugTest(irccon1) 320 | resultChan := make(chan map[string]string, 1) 321 | disconnectCalled := false 322 | irccon1.AddConnectCallback(func(e ircmsg.Message) { 323 | resultChan <- irccon1.ISupport() 324 | }) 325 | irccon1.AddDisconnectCallback(func(e ircmsg.Message) { 326 | disconnectCalled = true 327 | }) 328 | err := irccon1.Connect() 329 | if err != nil { 330 | panic(err) 331 | } 332 | loopExited := make(chan empty) 333 | go func() { 334 | irccon1.Loop() 335 | close(loopExited) 336 | }() 337 | isupport := <-resultChan 338 | if casemapping := isupport["CASEMAPPING"]; casemapping == "" { 339 | t.Errorf("casemapping not detected in 005 RPL_ISUPPORT output; this is unheard of") 340 | } 341 | assertEqual(disconnectCalled, false) 342 | irccon1.Quit() 343 | <-loopExited 344 | assertEqual(disconnectCalled, true) 345 | } 346 | 347 | func mustParse(line string) ircmsg.Message { 348 | msg, err := ircmsg.ParseLine(line) 349 | if err != nil { 350 | panic(err) 351 | } 352 | return msg 353 | } 354 | 355 | func TestGetReplyTarget(t *testing.T) { 356 | irc := Connection{} 357 | assertEqual(irc.GetReplyTarget(mustParse(":shivaram!~u@vjsnqp44px9sc.irc PRIVMSG #ergo :hi")), "#ergo") 358 | assertEqual(irc.GetReplyTarget(mustParse(":shivaram!~u@vjsnqp44px9sc.irc PRIVMSG titlebot :hi")), "shivaram") 359 | irc.isupport = map[string]string{ 360 | "CHANTYPES": "#&", 361 | } 362 | assertEqual(irc.GetReplyTarget(mustParse(":shivaram!~u@vjsnqp44px9sc.irc PRIVMSG #ergo :hi")), "#ergo") 363 | assertEqual(irc.GetReplyTarget(mustParse(":shivaram!~u@vjsnqp44px9sc.irc PRIVMSG &ergo :hi")), "&ergo") 364 | assertEqual(irc.GetReplyTarget(mustParse(":shivaram!~u@vjsnqp44px9sc.irc PRIVMSG titlebot :hi")), "shivaram") 365 | assertEqual(irc.GetReplyTarget(mustParse(":irc.ergo.chat NOTICE titlebot :Server is shutting down")), "") 366 | 367 | // no source but it's a channel message 368 | assertEqual(irc.GetReplyTarget(mustParse("PRIVMSG #ergo :hi")), "#ergo") 369 | // no source but it's a DM (no way to reply) 370 | assertEqual(irc.GetReplyTarget(mustParse("PRIVMSG titlebot :hi")), "") 371 | // invalid messages 372 | assertEqual(irc.GetReplyTarget(mustParse(":shivaram!~u@vjsnqp44px9sc.irc PRIVMSG")), "") 373 | assertEqual(irc.GetReplyTarget(mustParse("PRIVMSG")), "") 374 | assertEqual(irc.GetReplyTarget(mustParse("PRIVMSG :")), "") 375 | // not a PRIVMSG 376 | assertEqual(irc.GetReplyTarget(mustParse(":testnet.ergo.chat 371 shivaram :This is Ergo version 2.13.0.")), "") 377 | } 378 | -------------------------------------------------------------------------------- /ircevent/numerics.go: -------------------------------------------------------------------------------- 1 | package ircevent 2 | 3 | // I tried to include only relatively uncontroversial numerics; 4 | // however, inclusion here should not be considered an endorsement. 5 | 6 | const ( 7 | RPL_WELCOME = "001" 8 | RPL_YOURHOST = "002" 9 | RPL_CREATED = "003" 10 | RPL_MYINFO = "004" 11 | RPL_ISUPPORT = "005" 12 | RPL_SNOMASKIS = "008" 13 | RPL_BOUNCE = "010" 14 | RPL_TRACELINK = "200" 15 | RPL_TRACECONNECTING = "201" 16 | RPL_TRACEHANDSHAKE = "202" 17 | RPL_TRACEUNKNOWN = "203" 18 | RPL_TRACEOPERATOR = "204" 19 | RPL_TRACEUSER = "205" 20 | RPL_TRACESERVER = "206" 21 | RPL_TRACESERVICE = "207" 22 | RPL_TRACENEWTYPE = "208" 23 | RPL_TRACECLASS = "209" 24 | RPL_TRACERECONNECT = "210" 25 | RPL_STATSLINKINFO = "211" 26 | RPL_STATSCOMMANDS = "212" 27 | RPL_ENDOFSTATS = "219" 28 | RPL_UMODEIS = "221" 29 | RPL_SERVLIST = "234" 30 | RPL_SERVLISTEND = "235" 31 | RPL_STATSUPTIME = "242" 32 | RPL_STATSOLINE = "243" 33 | RPL_LUSERCLIENT = "251" 34 | RPL_LUSEROP = "252" 35 | RPL_LUSERUNKNOWN = "253" 36 | RPL_LUSERCHANNELS = "254" 37 | RPL_LUSERME = "255" 38 | RPL_ADMINME = "256" 39 | RPL_ADMINLOC1 = "257" 40 | RPL_ADMINLOC2 = "258" 41 | RPL_ADMINEMAIL = "259" 42 | RPL_TRACELOG = "261" 43 | RPL_TRACEEND = "262" 44 | RPL_TRYAGAIN = "263" 45 | RPL_LOCALUSERS = "265" 46 | RPL_GLOBALUSERS = "266" 47 | RPL_WHOISCERTFP = "276" 48 | RPL_AWAY = "301" 49 | RPL_USERHOST = "302" 50 | RPL_ISON = "303" 51 | RPL_UNAWAY = "305" 52 | RPL_NOWAWAY = "306" 53 | RPL_WHOISUSER = "311" 54 | RPL_WHOISSERVER = "312" 55 | RPL_WHOISOPERATOR = "313" 56 | RPL_WHOWASUSER = "314" 57 | RPL_ENDOFWHO = "315" 58 | RPL_WHOISIDLE = "317" 59 | RPL_ENDOFWHOIS = "318" 60 | RPL_WHOISCHANNELS = "319" 61 | RPL_LIST = "322" 62 | RPL_LISTEND = "323" 63 | RPL_CHANNELMODEIS = "324" 64 | RPL_UNIQOPIS = "325" 65 | RPL_CREATIONTIME = "329" 66 | RPL_WHOISACCOUNT = "330" 67 | RPL_NOTOPIC = "331" 68 | RPL_TOPIC = "332" 69 | RPL_TOPICTIME = "333" 70 | RPL_WHOISBOT = "335" 71 | RPL_WHOISACTUALLY = "338" 72 | RPL_INVITING = "341" 73 | RPL_SUMMONING = "342" 74 | RPL_INVITELIST = "346" 75 | RPL_ENDOFINVITELIST = "347" 76 | RPL_EXCEPTLIST = "348" 77 | RPL_ENDOFEXCEPTLIST = "349" 78 | RPL_VERSION = "351" 79 | RPL_WHOREPLY = "352" 80 | RPL_NAMREPLY = "353" 81 | RPL_WHOSPCRPL = "354" 82 | RPL_LINKS = "364" 83 | RPL_ENDOFLINKS = "365" 84 | RPL_ENDOFNAMES = "366" 85 | RPL_BANLIST = "367" 86 | RPL_ENDOFBANLIST = "368" 87 | RPL_ENDOFWHOWAS = "369" 88 | RPL_INFO = "371" 89 | RPL_MOTD = "372" 90 | RPL_ENDOFINFO = "374" 91 | RPL_MOTDSTART = "375" 92 | RPL_ENDOFMOTD = "376" 93 | RPL_WHOISMODES = "379" 94 | RPL_YOUREOPER = "381" 95 | RPL_REHASHING = "382" 96 | RPL_YOURESERVICE = "383" 97 | RPL_TIME = "391" 98 | RPL_USERSSTART = "392" 99 | RPL_USERS = "393" 100 | RPL_ENDOFUSERS = "394" 101 | RPL_NOUSERS = "395" 102 | ERR_UNKNOWNERROR = "400" 103 | ERR_NOSUCHNICK = "401" 104 | ERR_NOSUCHSERVER = "402" 105 | ERR_NOSUCHCHANNEL = "403" 106 | ERR_CANNOTSENDTOCHAN = "404" 107 | ERR_TOOMANYCHANNELS = "405" 108 | ERR_WASNOSUCHNICK = "406" 109 | ERR_TOOMANYTARGETS = "407" 110 | ERR_NOSUCHSERVICE = "408" 111 | ERR_NOORIGIN = "409" 112 | ERR_NORECIPIENT = "411" 113 | ERR_NOTEXTTOSEND = "412" 114 | ERR_NOTOPLEVEL = "413" 115 | ERR_WILDTOPLEVEL = "414" 116 | ERR_BADMASK = "415" 117 | ERR_INPUTTOOLONG = "417" 118 | ERR_UNKNOWNCOMMAND = "421" 119 | ERR_NOMOTD = "422" 120 | ERR_NOADMININFO = "423" 121 | ERR_FILEERROR = "424" 122 | ERR_NONICKNAMEGIVEN = "431" 123 | ERR_ERRONEUSNICKNAME = "432" 124 | ERR_NICKNAMEINUSE = "433" 125 | ERR_NICKCOLLISION = "436" 126 | ERR_UNAVAILRESOURCE = "437" 127 | ERR_USERNOTINCHANNEL = "441" 128 | ERR_NOTONCHANNEL = "442" 129 | ERR_USERONCHANNEL = "443" 130 | ERR_NOLOGIN = "444" 131 | ERR_SUMMONDISABLED = "445" 132 | ERR_USERSDISABLED = "446" 133 | ERR_NOTREGISTERED = "451" 134 | ERR_NEEDMOREPARAMS = "461" 135 | ERR_ALREADYREGISTRED = "462" 136 | ERR_NOPERMFORHOST = "463" 137 | ERR_PASSWDMISMATCH = "464" 138 | ERR_YOUREBANNEDCREEP = "465" 139 | ERR_YOUWILLBEBANNED = "466" 140 | ERR_KEYSET = "467" 141 | ERR_INVALIDUSERNAME = "468" 142 | ERR_LINKCHANNEL = "470" 143 | ERR_CHANNELISFULL = "471" 144 | ERR_UNKNOWNMODE = "472" 145 | ERR_INVITEONLYCHAN = "473" 146 | ERR_BANNEDFROMCHAN = "474" 147 | ERR_BADCHANNELKEY = "475" 148 | ERR_BADCHANMASK = "476" 149 | ERR_NEEDREGGEDNICK = "477" // conflicted with ERR_NOCHANMODES 150 | ERR_BANLISTFULL = "478" 151 | ERR_NOPRIVILEGES = "481" 152 | ERR_CHANOPRIVSNEEDED = "482" 153 | ERR_CANTKILLSERVER = "483" 154 | ERR_RESTRICTED = "484" 155 | ERR_UNIQOPPRIVSNEEDED = "485" 156 | ERR_NOOPERHOST = "491" 157 | ERR_UMODEUNKNOWNFLAG = "501" 158 | ERR_USERSDONTMATCH = "502" 159 | ERR_HELPNOTFOUND = "524" 160 | ERR_CANNOTSENDRP = "573" 161 | RPL_WHOWASIP = "652" 162 | RPL_WHOISSECURE = "671" 163 | ERR_INVALIDMODEPARAM = "696" 164 | ERR_LISTMODEALREADYSET = "697" 165 | ERR_LISTMODENOTSET = "698" 166 | RPL_HELPSTART = "704" 167 | RPL_HELPTXT = "705" 168 | RPL_ENDOFHELP = "706" 169 | ERR_NOPRIVS = "723" 170 | RPL_MONONLINE = "730" 171 | RPL_MONOFFLINE = "731" 172 | RPL_MONLIST = "732" 173 | RPL_ENDOFMONLIST = "733" 174 | ERR_MONLISTFULL = "734" 175 | RPL_LOGGEDIN = "900" 176 | RPL_LOGGEDOUT = "901" 177 | ERR_NICKLOCKED = "902" 178 | RPL_SASLSUCCESS = "903" 179 | ERR_SASLFAIL = "904" 180 | ERR_SASLTOOLONG = "905" 181 | ERR_SASLABORTED = "906" 182 | ERR_SASLALREADY = "907" 183 | RPL_SASLMECHS = "908" 184 | ) 185 | -------------------------------------------------------------------------------- /ircfmt/doc.go: -------------------------------------------------------------------------------- 1 | // written by Daniel Oaks 2 | // released under the ISC license 3 | 4 | /* 5 | Package ircfmt handles IRC formatting codes, escaping and unescaping. 6 | 7 | This allows for a simpler representation of strings that contain colour codes, 8 | bold codes, and such, without having to write and handle raw bytes when 9 | assembling outgoing messages. 10 | 11 | This lets you turn raw IRC messages into our escaped versions, and turn escaped 12 | versions back into raw messages suitable for sending on IRC connections. This 13 | is designed to be used on things like PRIVMSG / NOTICE commands, MOTD blocks, 14 | and such. 15 | 16 | The escape character we use in this library is the dollar sign ("$"), along 17 | with the given escape characters: 18 | 19 | -------------------------------- 20 | Name | Escape | Raw 21 | -------------------------------- 22 | Dollarsign | $$ | $ 23 | Bold | $b | 0x02 24 | Colour | $c | 0x03 25 | Monospace | $m | 0x11 26 | Reverse Colour | $v | 0x16 27 | Italic | $i | 0x1d 28 | Strikethrough | $s | 0x1e 29 | Underscore | $u | 0x1f 30 | Reset | $r | 0x0f 31 | -------------------------------- 32 | 33 | Colours are escaped in a slightly different way, using the actual names of them 34 | rather than just the raw numbers. 35 | 36 | In our escaped format, the colours for the fore and background are contained in 37 | square brackets after the colour ("$c") escape. For example: 38 | 39 | Red foreground: 40 | Escaped: This is a $c[red]cool message! 41 | Raw: This is a 0x034cool message! 42 | 43 | Blue foreground, green background: 44 | Escaped: This is a $c[blue,green]rad message! 45 | Raw: This is a 0x032,3rad message! 46 | 47 | When assembling a raw message, we make sure to use the full colour code 48 | ("02" vs just "2") when it could become confused due to numbers just after the 49 | colour escape code. For instance, lines like this will be unescaped correctly: 50 | 51 | No number after colour escape: 52 | Escaped: This is a $c[red]cool message! 53 | Raw: This is a 0x034cool message! 54 | 55 | Number after colour escape: 56 | Escaped: This is $c[blue]20% cooler! 57 | Raw: This is 0x030220% cooler 58 | 59 | Here are the colour names and codes we recognise: 60 | 61 | -------------------- 62 | Code | Name 63 | -------------------- 64 | 00 | white 65 | 01 | black 66 | 02 | blue 67 | 03 | green 68 | 04 | red 69 | 05 | brown 70 | 06 | magenta 71 | 07 | orange 72 | 08 | yellow 73 | 09 | light green 74 | 10 | cyan 75 | 11 | light cyan 76 | 12 | light blue 77 | 13 | pink 78 | 14 | grey 79 | 15 | light grey 80 | 99 | default 81 | -------------------- 82 | 83 | These other colours aren't given names: 84 | https://modern.ircdocs.horse/formatting.html#colors-16-98 85 | */ 86 | package ircfmt 87 | -------------------------------------------------------------------------------- /ircfmt/ircfmt.go: -------------------------------------------------------------------------------- 1 | // written by Daniel Oaks 2 | // released under the ISC license 3 | 4 | package ircfmt 5 | 6 | import ( 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | // raw bytes and strings to do replacing with 14 | bold string = "\x02" 15 | colour string = "\x03" 16 | monospace string = "\x11" 17 | reverseColour string = "\x16" 18 | italic string = "\x1d" 19 | strikethrough string = "\x1e" 20 | underline string = "\x1f" 21 | reset string = "\x0f" 22 | 23 | metacharacters = (bold + colour + monospace + reverseColour + italic + strikethrough + underline + reset) 24 | ) 25 | 26 | // ColorCode is a normalized representation of an IRC color code, 27 | // as per this de facto specification: https://modern.ircdocs.horse/formatting.html#color 28 | // The zero value of the type represents a default or unset color, 29 | // whereas ColorCode{true, 0} represents the color white. 30 | type ColorCode struct { 31 | IsSet bool 32 | Value uint8 33 | } 34 | 35 | // ParseColor converts a string representation of an IRC color code, e.g. "04", 36 | // into a normalized ColorCode, e.g. ColorCode{true, 4}. 37 | func ParseColor(str string) (color ColorCode) { 38 | // "99 - Default Foreground/Background - Not universally supported." 39 | // normalize 99 to ColorCode{} meaning "unset": 40 | if code, err := strconv.ParseUint(str, 10, 8); err == nil && code < 99 { 41 | color.IsSet = true 42 | color.Value = uint8(code) 43 | } 44 | return 45 | } 46 | 47 | // FormattedSubstring represents a section of an IRC message with associated 48 | // formatting data. 49 | type FormattedSubstring struct { 50 | Content string 51 | ForegroundColor ColorCode 52 | BackgroundColor ColorCode 53 | Bold bool 54 | Monospace bool 55 | Strikethrough bool 56 | Underline bool 57 | Italic bool 58 | ReverseColor bool 59 | } 60 | 61 | // IsFormatted returns whether the section has any formatting flags switched on. 62 | func (f *FormattedSubstring) IsFormatted() bool { 63 | // could rely on value receiver but if this is to be a public API, 64 | // let's make it a pointer receiver 65 | g := *f 66 | g.Content = "" 67 | return g != FormattedSubstring{} 68 | } 69 | 70 | var ( 71 | // "If there are two ASCII digits available where a is allowed, 72 | // then two characters MUST always be read for it and displayed as described below." 73 | // we rely on greedy matching to implement this for both forms: 74 | // (\x03)00,01 75 | colorForeBackRe = regexp.MustCompile(`^([0-9]{1,2}),([0-9]{1,2})`) 76 | // (\x03)00 77 | colorForeRe = regexp.MustCompile(`^([0-9]{1,2})`) 78 | ) 79 | 80 | // Split takes an IRC message (typically a PRIVMSG or NOTICE final parameter) 81 | // containing IRC formatting control codes, and splits it into substrings with 82 | // associated formatting information. 83 | func Split(raw string) (result []FormattedSubstring) { 84 | var chunk FormattedSubstring 85 | for { 86 | // skip to the next metacharacter, or the end of the string 87 | if idx := strings.IndexAny(raw, metacharacters); idx != 0 { 88 | if idx == -1 { 89 | idx = len(raw) 90 | } 91 | chunk.Content = raw[:idx] 92 | if len(chunk.Content) != 0 { 93 | result = append(result, chunk) 94 | } 95 | raw = raw[idx:] 96 | } 97 | 98 | if len(raw) == 0 { 99 | return 100 | } 101 | 102 | // we're at a metacharacter. by default, all previous formatting carries over 103 | metacharacter := raw[0] 104 | raw = raw[1:] 105 | switch metacharacter { 106 | case bold[0]: 107 | chunk.Bold = !chunk.Bold 108 | case monospace[0]: 109 | chunk.Monospace = !chunk.Monospace 110 | case strikethrough[0]: 111 | chunk.Strikethrough = !chunk.Strikethrough 112 | case underline[0]: 113 | chunk.Underline = !chunk.Underline 114 | case italic[0]: 115 | chunk.Italic = !chunk.Italic 116 | case reverseColour[0]: 117 | chunk.ReverseColor = !chunk.ReverseColor 118 | case reset[0]: 119 | chunk = FormattedSubstring{} 120 | case colour[0]: 121 | // preferentially match the "\x0399,01" form, then "\x0399"; 122 | // if neither of those matches, then it's a reset 123 | if matches := colorForeBackRe.FindStringSubmatch(raw); len(matches) != 0 { 124 | chunk.ForegroundColor = ParseColor(matches[1]) 125 | chunk.BackgroundColor = ParseColor(matches[2]) 126 | raw = raw[len(matches[0]):] 127 | } else if matches := colorForeRe.FindStringSubmatch(raw); len(matches) != 0 { 128 | chunk.ForegroundColor = ParseColor(matches[1]) 129 | raw = raw[len(matches[0]):] 130 | } else { 131 | chunk.ForegroundColor = ColorCode{} 132 | chunk.BackgroundColor = ColorCode{} 133 | } 134 | default: 135 | // should be impossible, but just ignore it 136 | } 137 | } 138 | } 139 | 140 | var ( 141 | // valtoescape replaces most of IRC characters with our escapes. 142 | valtoescape = strings.NewReplacer("$", "$$", colour, "$c", reverseColour, "$v", bold, "$b", italic, "$i", strikethrough, "$s", underline, "$u", monospace, "$m", reset, "$r") 143 | 144 | // escapetoval contains most of our escapes and how they map to real IRC characters. 145 | // intentionally skips colour, since that's handled elsewhere. 146 | escapetoval = map[rune]string{ 147 | '$': "$", 148 | 'b': bold, 149 | 'i': italic, 150 | 'v': reverseColour, 151 | 's': strikethrough, 152 | 'u': underline, 153 | 'm': monospace, 154 | 'r': reset, 155 | } 156 | 157 | // valid colour codes 158 | numtocolour = map[string]string{ 159 | "99": "default", 160 | "15": "light grey", 161 | "14": "grey", 162 | "13": "pink", 163 | "12": "light blue", 164 | "11": "light cyan", 165 | "10": "cyan", 166 | "09": "light green", 167 | "08": "yellow", 168 | "07": "orange", 169 | "06": "magenta", 170 | "05": "brown", 171 | "04": "red", 172 | "03": "green", 173 | "02": "blue", 174 | "01": "black", 175 | "00": "white", 176 | "9": "light green", 177 | "8": "yellow", 178 | "7": "orange", 179 | "6": "magenta", 180 | "5": "brown", 181 | "4": "red", 182 | "3": "green", 183 | "2": "blue", 184 | "1": "black", 185 | "0": "white", 186 | } 187 | 188 | colourcodesTruncated = map[string]string{ 189 | "white": "0", 190 | "black": "1", 191 | "blue": "2", 192 | "green": "3", 193 | "red": "4", 194 | "brown": "5", 195 | "magenta": "6", 196 | "orange": "7", 197 | "yellow": "8", 198 | "light green": "9", 199 | "cyan": "10", 200 | "light cyan": "11", 201 | "light blue": "12", 202 | "pink": "13", 203 | "grey": "14", 204 | "gray": "14", 205 | "light grey": "15", 206 | "light gray": "15", 207 | "default": "99", 208 | } 209 | 210 | bracketedExpr = regexp.MustCompile(`^\[.*?\]`) 211 | colourDigits = regexp.MustCompile(`^[0-9]{1,2}$`) 212 | ) 213 | 214 | // Escape takes a raw IRC string and returns it with our escapes. 215 | // 216 | // IE, it turns this: "This is a \x02cool\x02, \x034red\x0f message!" 217 | // into: "This is a $bcool$b, $c[red]red$r message!" 218 | func Escape(in string) string { 219 | // replace all our usual escapes 220 | in = valtoescape.Replace(in) 221 | 222 | inRunes := []rune(in) 223 | //var out string 224 | out := strings.Builder{} 225 | for 0 < len(inRunes) { 226 | if 1 < len(inRunes) && inRunes[0] == '$' && inRunes[1] == 'c' { 227 | // handle colours 228 | out.WriteString("$c") 229 | inRunes = inRunes[2:] // strip colour code chars 230 | 231 | if len(inRunes) < 1 || !isDigit(inRunes[0]) { 232 | out.WriteString("[]") 233 | continue 234 | } 235 | 236 | var foreBuffer, backBuffer string 237 | foreBuffer += string(inRunes[0]) 238 | inRunes = inRunes[1:] 239 | if 0 < len(inRunes) && isDigit(inRunes[0]) { 240 | foreBuffer += string(inRunes[0]) 241 | inRunes = inRunes[1:] 242 | } 243 | if 1 < len(inRunes) && inRunes[0] == ',' && isDigit(inRunes[1]) { 244 | backBuffer += string(inRunes[1]) 245 | inRunes = inRunes[2:] 246 | if 0 < len(inRunes) && isDigit(inRunes[1]) { 247 | backBuffer += string(inRunes[0]) 248 | inRunes = inRunes[1:] 249 | } 250 | } 251 | 252 | foreName, exists := numtocolour[foreBuffer] 253 | if !exists { 254 | foreName = foreBuffer 255 | } 256 | backName, exists := numtocolour[backBuffer] 257 | if !exists { 258 | backName = backBuffer 259 | } 260 | 261 | out.WriteRune('[') 262 | out.WriteString(foreName) 263 | if backName != "" { 264 | out.WriteRune(',') 265 | out.WriteString(backName) 266 | } 267 | out.WriteRune(']') 268 | 269 | } else { 270 | // special case for $$c 271 | if len(inRunes) > 2 && inRunes[0] == '$' && inRunes[1] == '$' && inRunes[2] == 'c' { 272 | out.WriteRune(inRunes[0]) 273 | out.WriteRune(inRunes[1]) 274 | out.WriteRune(inRunes[2]) 275 | inRunes = inRunes[3:] 276 | } else { 277 | out.WriteRune(inRunes[0]) 278 | inRunes = inRunes[1:] 279 | } 280 | } 281 | } 282 | 283 | return out.String() 284 | } 285 | 286 | func isDigit(r rune) bool { 287 | return '0' <= r && r <= '9' // don't use unicode.IsDigit, it includes non-ASCII numerals 288 | } 289 | 290 | // Strip takes a raw IRC string and removes it with all formatting codes removed 291 | // IE, it turns this: "This is a \x02cool\x02, \x034red\x0f message!" 292 | // into: "This is a cool, red message!" 293 | func Strip(in string) string { 294 | splitChunks := Split(in) 295 | if len(splitChunks) == 0 { 296 | return "" 297 | } else if len(splitChunks) == 1 { 298 | return splitChunks[0].Content 299 | } else { 300 | var buf strings.Builder 301 | buf.Grow(len(in)) 302 | for _, chunk := range splitChunks { 303 | buf.WriteString(chunk.Content) 304 | } 305 | return buf.String() 306 | } 307 | } 308 | 309 | // resolve "light blue" to "12", "12" to "12", "asdf" to "", etc. 310 | func resolveToColourCode(str string) (result string) { 311 | str = strings.ToLower(strings.TrimSpace(str)) 312 | if colourDigits.MatchString(str) { 313 | return str 314 | } 315 | return colourcodesTruncated[str] 316 | } 317 | 318 | // resolve "[light blue, black]" to ("13, "1") 319 | func resolveToColourCodes(namedColors string) (foreground, background string) { 320 | // cut off the brackets 321 | namedColors = strings.TrimPrefix(namedColors, "[") 322 | namedColors = strings.TrimSuffix(namedColors, "]") 323 | 324 | var foregroundStr, backgroundStr string 325 | commaIdx := strings.IndexByte(namedColors, ',') 326 | if commaIdx != -1 { 327 | foregroundStr = namedColors[:commaIdx] 328 | backgroundStr = namedColors[commaIdx+1:] 329 | } else { 330 | foregroundStr = namedColors 331 | } 332 | 333 | return resolveToColourCode(foregroundStr), resolveToColourCode(backgroundStr) 334 | } 335 | 336 | // Unescape takes our escaped string and returns a raw IRC string. 337 | // 338 | // IE, it turns this: "This is a $bcool$b, $c[red]red$r message!" 339 | // into this: "This is a \x02cool\x02, \x034red\x0f message!" 340 | func Unescape(in string) string { 341 | var out strings.Builder 342 | 343 | remaining := in 344 | for len(remaining) != 0 { 345 | char := remaining[0] 346 | remaining = remaining[1:] 347 | 348 | if char != '$' || len(remaining) == 0 { 349 | // not an escape 350 | out.WriteByte(char) 351 | continue 352 | } 353 | 354 | // ingest the next character of the escape 355 | char = remaining[0] 356 | remaining = remaining[1:] 357 | 358 | if char == 'c' { 359 | out.WriteString(colour) 360 | 361 | namedColors := bracketedExpr.FindString(remaining) 362 | if namedColors == "" { 363 | // for a non-bracketed color code, output the following characters directly, 364 | // e.g., `$c1,8` will become `\x031,8` 365 | continue 366 | } 367 | // process bracketed color codes: 368 | remaining = remaining[len(namedColors):] 369 | followedByDigit := len(remaining) != 0 && ('0' <= remaining[0] && remaining[0] <= '9') 370 | 371 | foreground, background := resolveToColourCodes(namedColors) 372 | if foreground != "" { 373 | if len(foreground) == 1 && background == "" && followedByDigit { 374 | out.WriteByte('0') 375 | } 376 | out.WriteString(foreground) 377 | if background != "" { 378 | out.WriteByte(',') 379 | if len(background) == 1 && followedByDigit { 380 | out.WriteByte('0') 381 | } 382 | out.WriteString(background) 383 | } 384 | } 385 | } else { 386 | val, exists := escapetoval[rune(char)] 387 | if exists { 388 | out.WriteString(val) 389 | } else { 390 | // invalid escape, use the raw char 391 | out.WriteByte(char) 392 | } 393 | } 394 | } 395 | 396 | return out.String() 397 | } 398 | -------------------------------------------------------------------------------- /ircfmt/ircfmt_test.go: -------------------------------------------------------------------------------- 1 | package ircfmt 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | type testcase struct { 9 | escaped string 10 | unescaped string 11 | } 12 | 13 | var tests = []testcase{ 14 | {"te$bst", "te\x02st"}, 15 | {"te$c[green]st", "te\x033st"}, 16 | {"te$c[red,green]st", "te\x034,3st"}, 17 | {"te$c[green]4st", "te\x03034st"}, 18 | {"te$c[red,green]9st", "te\x034,039st"}, 19 | {" ▀█▄▀▪.▀ ▀ ▀ ▀ ·▀▀▀▀ ▀█▄▀ ▀▀ █▪ ▀█▄▀▪", " ▀█▄▀▪.▀ ▀ ▀ ▀ ·▀▀▀▀ ▀█▄▀ ▀▀ █▪ ▀█▄▀▪"}, 20 | {"test $$c", "test $c"}, 21 | {"test $c[]", "test \x03"}, 22 | {"test $$", "test $"}, 23 | } 24 | 25 | var escapetests = []testcase{ 26 | {"te$c[]st", "te\x03st"}, 27 | {"test$c[]", "test\x03"}, 28 | } 29 | 30 | var unescapetests = []testcase{ 31 | {"te$xt", "text"}, 32 | {"te$st", "te\x1et"}, 33 | {"test$c", "test\x03"}, 34 | {"te$c[red velvet", "te\x03[red velvet"}, 35 | {"te$c[[red velvet", "te\x03[[red velvet"}, 36 | {"test$c[light blue,black]asdf", "test\x0312,1asdf"}, 37 | {"test$c[light blue, black]asdf", "test\x0312,1asdf"}, 38 | {"test$c[light gray]asdf", "test\x0315asdf"}, 39 | {"te$c[4,3]st", "te\x034,3st"}, 40 | {"te$c[4]1st", "te\x03041st"}, 41 | {"te$c[4,3]9st", "te\x034,039st"}, 42 | {"te$c[04,03]9st", "te\x0304,039st"}, 43 | {"te$c[asdf !23a fd4*#]st", "te\x03st"}, 44 | {"te$c[asdf , !2,3a fd4*#]st", "te\x03st"}, 45 | {"Client opered up $c[grey][$r%s$c[grey], $r%s$c[grey]]", "Client opered up \x0314[\x0f%s\x0314, \x0f%s\x0314]"}, 46 | {"Client opered up $c[grey][$r%s$c[gray], $r%s$c[grey]]", "Client opered up \x0314[\x0f%s\x0314, \x0f%s\x0314]"}, 47 | } 48 | 49 | var stripTests = []testcase{ 50 | {"te\x02st", "test"}, 51 | {"te\x033st", "test"}, 52 | {"te\x034,3st", "test"}, 53 | {"te\x03034st", "te4st"}, 54 | {"te\x034,039st", "te9st"}, 55 | {" ▀█▄▀▪.▀ ▀ ▀ ▀ ·▀▀▀▀ ▀█▄▀ ▀▀ █▪ ▀█▄▀▪", " ▀█▄▀▪.▀ ▀ ▀ ▀ ·▀▀▀▀ ▀█▄▀ ▀▀ █▪ ▀█▄▀▪"}, 56 | {"test\x02case", "testcase"}, 57 | {"", ""}, 58 | {"test string", "test string"}, 59 | {"test \x03", "test "}, 60 | {"test \x031x", "test x"}, 61 | {"test \x031,11x", "test x"}, 62 | {"test \x0311,0x", "test x"}, 63 | {"test \x039,99", "test "}, 64 | {"test \x0301string", "test string"}, 65 | {"test\x031,2 string", "test string"}, 66 | {"test\x0301,02 string", "test string"}, 67 | {"test\x03, string", "test, string"}, 68 | {"test\x03,12 string", "test,12 string"}, 69 | {"\x02\x031,2\x11\x16\x1d\x1e\x0f\x1f", ""}, 70 | {"\x03", ""}, 71 | {"\x03,", ","}, 72 | {"\x031,2", ""}, 73 | {"\x0315,1234", "34"}, 74 | {"\x03151234", "1234"}, 75 | {"\x03\x03\x03\x03\x03\x03\x03", ""}, 76 | {"\x03\x03\x03\x03\x03\x03\x03\x03", ""}, 77 | {"\x03,\x031\x0312\x0334,\x0356,\x0378,90\x031234", ",,,34"}, 78 | {"\x0312,12\x03121212\x0311,333\x03,3\x038\x0399\x0355\x03test", "12123,3test"}, 79 | } 80 | 81 | type splitTestCase struct { 82 | input string 83 | output []FormattedSubstring 84 | } 85 | 86 | var splitTestCases = []splitTestCase{ 87 | {"", nil}, 88 | {"a", []FormattedSubstring{ 89 | {Content: "a"}, 90 | }}, 91 | {"\x02", nil}, 92 | {"\x02\x03", nil}, 93 | {"\x0311", nil}, 94 | {"\x0311,13", nil}, 95 | {"\x02a", []FormattedSubstring{ 96 | {Content: "a", Bold: true}, 97 | }}, 98 | {"\x02ab", []FormattedSubstring{ 99 | {Content: "ab", Bold: true}, 100 | }}, 101 | {"c\x02ab", []FormattedSubstring{ 102 | {Content: "c"}, 103 | {Content: "ab", Bold: true}, 104 | }}, 105 | {"\x02a\x02", []FormattedSubstring{ 106 | {Content: "a", Bold: true}, 107 | }}, 108 | {"\x02a\x02b", []FormattedSubstring{ 109 | {Content: "a", Bold: true}, 110 | {Content: "b", Bold: false}, 111 | }}, 112 | {"\x02\x1fa", []FormattedSubstring{ 113 | {Content: "a", Bold: true, Underline: true}, 114 | }}, 115 | {"\x1e\x1fa\x1fb", []FormattedSubstring{ 116 | {Content: "a", Strikethrough: true, Underline: true}, 117 | {Content: "b", Strikethrough: true, Underline: false}, 118 | }}, 119 | {"\x02\x031,0a\x0f", []FormattedSubstring{ 120 | {Content: "a", Bold: true, ForegroundColor: ColorCode{true, 1}, BackgroundColor: ColorCode{true, 0}}, 121 | }}, 122 | {"\x02\x0301,0a\x0f", []FormattedSubstring{ 123 | {Content: "a", Bold: true, ForegroundColor: ColorCode{true, 1}, BackgroundColor: ColorCode{true, 0}}, 124 | }}, 125 | {"\x02\x031,00a\x0f", []FormattedSubstring{ 126 | {Content: "a", Bold: true, ForegroundColor: ColorCode{true, 1}, BackgroundColor: ColorCode{true, 0}}, 127 | }}, 128 | {"\x02\x0301,00a\x0f", []FormattedSubstring{ 129 | {Content: "a", Bold: true, ForegroundColor: ColorCode{true, 1}, BackgroundColor: ColorCode{true, 0}}, 130 | }}, 131 | {"\x02\x031,0a\x0fb", []FormattedSubstring{ 132 | {Content: "a", Bold: true, ForegroundColor: ColorCode{true, 1}, BackgroundColor: ColorCode{true, 0}}, 133 | {Content: "b"}, 134 | }}, 135 | {"\x02\x031,0a\x02b", []FormattedSubstring{ 136 | {Content: "a", Bold: true, ForegroundColor: ColorCode{true, 1}, BackgroundColor: ColorCode{true, 0}}, 137 | {Content: "b", Bold: false, ForegroundColor: ColorCode{true, 1}, BackgroundColor: ColorCode{true, 0}}, 138 | }}, 139 | {"\x031,", []FormattedSubstring{ 140 | {Content: ",", ForegroundColor: ColorCode{true, 1}}, 141 | }}, 142 | {"\x0311,", []FormattedSubstring{ 143 | {Content: ",", ForegroundColor: ColorCode{true, 11}}, 144 | }}, 145 | {"\x0311,13ab", []FormattedSubstring{ 146 | {Content: "ab", ForegroundColor: ColorCode{true, 11}, BackgroundColor: ColorCode{true, 13}}, 147 | }}, 148 | {"\x0399,04the quick \t brown fox", []FormattedSubstring{ 149 | {Content: "the quick \t brown fox", BackgroundColor: ColorCode{true, 4}}, 150 | }}, 151 | } 152 | 153 | func TestSplit(t *testing.T) { 154 | for i, testCase := range splitTestCases { 155 | actual := Split(testCase.input) 156 | if !reflect.DeepEqual(actual, testCase.output) { 157 | t.Errorf("Test case %d failed: expected %#v, got %#v", i, testCase.output, actual) 158 | } 159 | } 160 | } 161 | 162 | func TestEscape(t *testing.T) { 163 | for _, pair := range tests { 164 | val := Escape(pair.unescaped) 165 | 166 | if val != pair.escaped { 167 | t.Error( 168 | "For", pair.unescaped, 169 | "expected", pair.escaped, 170 | "got", val, 171 | ) 172 | } 173 | } 174 | for _, pair := range escapetests { 175 | val := Escape(pair.unescaped) 176 | 177 | if val != pair.escaped { 178 | t.Error( 179 | "For", pair.unescaped, 180 | "expected", pair.escaped, 181 | "got", val, 182 | ) 183 | } 184 | } 185 | } 186 | 187 | func TestChain(t *testing.T) { 188 | for _, pair := range tests { 189 | escaped := Escape(pair.unescaped) 190 | unescaped := Unescape(escaped) 191 | if unescaped != pair.unescaped { 192 | t.Errorf("for %q expected %q got %q", pair.unescaped, pair.unescaped, unescaped) 193 | } 194 | } 195 | } 196 | 197 | func TestUnescape(t *testing.T) { 198 | for _, pair := range tests { 199 | val := Unescape(pair.escaped) 200 | 201 | if val != pair.unescaped { 202 | t.Error( 203 | "For", pair.escaped, 204 | "expected", pair.unescaped, 205 | "got", val, 206 | ) 207 | } 208 | } 209 | for _, pair := range unescapetests { 210 | val := Unescape(pair.escaped) 211 | 212 | if val != pair.unescaped { 213 | t.Error( 214 | "For", pair.escaped, 215 | "expected", pair.unescaped, 216 | "got", val, 217 | ) 218 | } 219 | } 220 | } 221 | 222 | func TestStrip(t *testing.T) { 223 | for _, pair := range stripTests { 224 | val := Strip(pair.escaped) 225 | if val != pair.unescaped { 226 | t.Error( 227 | "For", pair.escaped, 228 | "expected", pair.unescaped, 229 | "got", val, 230 | ) 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /ircmsg/doc.go: -------------------------------------------------------------------------------- 1 | // written by Daniel Oaks 2 | // released under the ISC license 3 | 4 | /* 5 | Package ircmsg helps parse and create lines for IRC connections. 6 | */ 7 | package ircmsg 8 | -------------------------------------------------------------------------------- /ircmsg/message.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2019 Daniel Oaks 2 | // Copyright (c) 2018-2019 Shivaram Lingamneni 3 | 4 | // released under the ISC license 5 | 6 | package ircmsg 7 | 8 | import ( 9 | "bytes" 10 | "errors" 11 | "strings" 12 | "unicode/utf8" 13 | ) 14 | 15 | const ( 16 | // "The size limit for message tags is 8191 bytes, including the leading 17 | // '@' (0x40) and trailing space ' ' (0x20) characters." 18 | MaxlenTags = 8191 19 | 20 | // MaxlenTags - ('@' + ' ') 21 | MaxlenTagData = MaxlenTags - 2 22 | 23 | // "Clients MUST NOT send messages with tag data exceeding 4094 bytes, 24 | // this includes tags with or without the client-only prefix." 25 | MaxlenClientTagData = 4094 26 | 27 | // "Servers MUST NOT add tag data exceeding 4094 bytes to messages." 28 | MaxlenServerTagData = 4094 29 | 30 | // '@' + MaxlenClientTagData + ' ' 31 | // this is the analogue of MaxlenTags when the source of the message is a client 32 | MaxlenTagsFromClient = MaxlenClientTagData + 2 33 | ) 34 | 35 | var ( 36 | // ErrorLineIsEmpty indicates that the given IRC line was empty. 37 | ErrorLineIsEmpty = errors.New("Line is empty") 38 | 39 | // ErrorLineContainsBadChar indicates that the line contained invalid characters 40 | ErrorLineContainsBadChar = errors.New("Line contains invalid characters") 41 | 42 | // ErrorBodyTooLong indicates that the message body exceeded the specified 43 | // length limit (typically 512 bytes). This error is non-fatal; if encountered 44 | // when parsing a message, the message is parsed up to the length limit, and 45 | // if encountered when serializing a message, the message is truncated to the limit. 46 | ErrorBodyTooLong = errors.New("Line body exceeded the specified length limit; outgoing messages will be truncated") 47 | 48 | // ErrorTagsTooLong indicates that the message exceeded the maximum tag length 49 | // (the specified response on the server side is 417 ERR_INPUTTOOLONG). 50 | ErrorTagsTooLong = errors.New("Line could not be processed because its tag data exceeded the length limit") 51 | 52 | // ErrorInvalidTagContent indicates that a tag name or value was invalid 53 | ErrorInvalidTagContent = errors.New("Line could not be processed because it contained an invalid tag name or value") 54 | 55 | // ErrorCommandMissing indicates that an IRC message was invalid because it lacked a command. 56 | ErrorCommandMissing = errors.New("IRC messages MUST have a command") 57 | 58 | // ErrorBadParam indicates that an IRC message could not be serialized because 59 | // its parameters violated the syntactic constraints on IRC parameters: 60 | // non-final parameters cannot be empty, contain a space, or start with `:`. 61 | ErrorBadParam = errors.New("Cannot have an empty param, a param with spaces, or a param that starts with ':' before the last parameter") 62 | ) 63 | 64 | // Message represents an IRC message, as defined by the RFCs and as 65 | // extended by the IRCv3 Message Tags specification with the introduction 66 | // of message tags. 67 | type Message struct { 68 | Source string 69 | Command string 70 | Params []string 71 | forceTrailing bool 72 | tags map[string]string 73 | clientOnlyTags map[string]string 74 | } 75 | 76 | // ForceTrailing ensures that when the message is serialized, the final parameter 77 | // will be encoded as a "trailing parameter" (preceded by a colon). This is 78 | // almost never necessary and should not be used except when having to interact 79 | // with broken implementations that don't correctly interpret IRC messages. 80 | func (msg *Message) ForceTrailing() { 81 | msg.forceTrailing = true 82 | } 83 | 84 | // GetTag returns whether a tag is present, and if so, what its value is. 85 | func (msg *Message) GetTag(tagName string) (present bool, value string) { 86 | if len(tagName) == 0 { 87 | return 88 | } else if tagName[0] == '+' { 89 | value, present = msg.clientOnlyTags[tagName] 90 | return 91 | } else { 92 | value, present = msg.tags[tagName] 93 | return 94 | } 95 | } 96 | 97 | // HasTag returns whether a tag is present. 98 | func (msg *Message) HasTag(tagName string) (present bool) { 99 | present, _ = msg.GetTag(tagName) 100 | return 101 | } 102 | 103 | // SetTag sets a tag. 104 | func (msg *Message) SetTag(tagName, tagValue string) { 105 | if len(tagName) == 0 { 106 | return 107 | } else if tagName[0] == '+' { 108 | if msg.clientOnlyTags == nil { 109 | msg.clientOnlyTags = make(map[string]string) 110 | } 111 | msg.clientOnlyTags[tagName] = tagValue 112 | } else { 113 | if msg.tags == nil { 114 | msg.tags = make(map[string]string) 115 | } 116 | msg.tags[tagName] = tagValue 117 | } 118 | } 119 | 120 | // DeleteTag deletes a tag. 121 | func (msg *Message) DeleteTag(tagName string) { 122 | if len(tagName) == 0 { 123 | return 124 | } else if tagName[0] == '+' { 125 | delete(msg.clientOnlyTags, tagName) 126 | } else { 127 | delete(msg.tags, tagName) 128 | } 129 | } 130 | 131 | // UpdateTags is a convenience to set multiple tags at once. 132 | func (msg *Message) UpdateTags(tags map[string]string) { 133 | for name, value := range tags { 134 | msg.SetTag(name, value) 135 | } 136 | } 137 | 138 | // AllTags returns all tags as a single map. 139 | func (msg *Message) AllTags() (result map[string]string) { 140 | result = make(map[string]string, len(msg.tags)+len(msg.clientOnlyTags)) 141 | for name, value := range msg.tags { 142 | result[name] = value 143 | } 144 | for name, value := range msg.clientOnlyTags { 145 | result[name] = value 146 | } 147 | return 148 | } 149 | 150 | // ClientOnlyTags returns the client-only tags (the tags with the + prefix). 151 | // The returned map may be internal storage of the Message object and 152 | // should not be modified. 153 | func (msg *Message) ClientOnlyTags() map[string]string { 154 | return msg.clientOnlyTags 155 | } 156 | 157 | // Nick returns the name component of the message source (typically a nickname, 158 | // but possibly a server name). 159 | func (msg *Message) Nick() (nick string) { 160 | nuh, err := ParseNUH(msg.Source) 161 | if err == nil { 162 | return nuh.Name 163 | } 164 | return 165 | } 166 | 167 | // NUH returns the source of the message as a parsed NUH ("nick-user-host"); 168 | // if the source is not well-formed as a NUH, it returns an error. 169 | func (msg *Message) NUH() (nuh NUH, err error) { 170 | return ParseNUH(msg.Source) 171 | } 172 | 173 | // ParseLine creates and returns a message from the given IRC line. 174 | func ParseLine(line string) (ircmsg Message, err error) { 175 | return parseLine(line, 0, 0) 176 | } 177 | 178 | // ParseLineStrict creates and returns an Message from the given IRC line, 179 | // taking the maximum length into account and truncating the message as appropriate. 180 | // If fromClient is true, it enforces the client limit on tag data length (4094 bytes), 181 | // allowing the server to return ERR_INPUTTOOLONG as appropriate. If truncateLen is 182 | // nonzero, it is the length at which the non-tag portion of the message is truncated. 183 | func ParseLineStrict(line string, fromClient bool, truncateLen int) (ircmsg Message, err error) { 184 | maxTagDataLength := MaxlenTagData 185 | if fromClient { 186 | maxTagDataLength = MaxlenClientTagData 187 | } 188 | return parseLine(line, maxTagDataLength, truncateLen) 189 | } 190 | 191 | // slice off any amount of ' ' from the front of the string 192 | func trimInitialSpaces(str string) string { 193 | var i int 194 | for i = 0; i < len(str) && str[i] == ' '; i++ { 195 | } 196 | return str[i:] 197 | } 198 | 199 | func isASCII(str string) bool { 200 | for i := 0; i < len(str); i++ { 201 | if str[i] > 127 { 202 | return false 203 | } 204 | } 205 | return true 206 | } 207 | 208 | func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg Message, err error) { 209 | // remove either \n or \r\n from the end of the line: 210 | line = strings.TrimSuffix(line, "\n") 211 | line = strings.TrimSuffix(line, "\r") 212 | // whether we removed them ourselves, or whether they were removed previously, 213 | // they count against the line limit: 214 | if truncateLen != 0 { 215 | if truncateLen <= 2 { 216 | return ircmsg, ErrorLineIsEmpty 217 | } 218 | truncateLen -= 2 219 | } 220 | // now validate for the 3 forbidden bytes: 221 | if strings.IndexByte(line, '\x00') != -1 || strings.IndexByte(line, '\n') != -1 || strings.IndexByte(line, '\r') != -1 { 222 | return ircmsg, ErrorLineContainsBadChar 223 | } 224 | 225 | if len(line) < 1 { 226 | return ircmsg, ErrorLineIsEmpty 227 | } 228 | 229 | // tags 230 | if line[0] == '@' { 231 | tagEnd := strings.IndexByte(line, ' ') 232 | if tagEnd == -1 { 233 | return ircmsg, ErrorLineIsEmpty 234 | } 235 | tags := line[1:tagEnd] 236 | if 0 < maxTagDataLength && maxTagDataLength < len(tags) { 237 | return ircmsg, ErrorTagsTooLong 238 | } 239 | err = ircmsg.parseTags(tags) 240 | if err != nil { 241 | return 242 | } 243 | // skip over the tags and the separating space 244 | line = line[tagEnd+1:] 245 | } 246 | 247 | // truncate if desired 248 | if truncateLen != 0 && truncateLen < len(line) { 249 | err = ErrorBodyTooLong 250 | line = TruncateUTF8Safe(line, truncateLen) 251 | } 252 | 253 | // modern: "These message parts, and parameters themselves, are separated 254 | // by one or more ASCII SPACE characters" 255 | line = trimInitialSpaces(line) 256 | 257 | // source 258 | if 0 < len(line) && line[0] == ':' { 259 | sourceEnd := strings.IndexByte(line, ' ') 260 | if sourceEnd == -1 { 261 | return ircmsg, ErrorLineIsEmpty 262 | } 263 | ircmsg.Source = line[1:sourceEnd] 264 | // skip over the source and the separating space 265 | line = line[sourceEnd+1:] 266 | } 267 | 268 | line = trimInitialSpaces(line) 269 | 270 | // command 271 | commandEnd := strings.IndexByte(line, ' ') 272 | paramStart := commandEnd + 1 273 | if commandEnd == -1 { 274 | commandEnd = len(line) 275 | paramStart = len(line) 276 | } 277 | baseCommand := line[:commandEnd] 278 | if len(baseCommand) == 0 { 279 | return ircmsg, ErrorLineIsEmpty 280 | } 281 | // technically this must be either letters or a 3-digit numeric: 282 | if !isASCII(baseCommand) { 283 | return ircmsg, ErrorLineContainsBadChar 284 | } 285 | // normalize command to uppercase: 286 | ircmsg.Command = strings.ToUpper(baseCommand) 287 | line = line[paramStart:] 288 | 289 | for { 290 | line = trimInitialSpaces(line) 291 | if len(line) == 0 { 292 | break 293 | } 294 | // handle trailing 295 | if line[0] == ':' { 296 | ircmsg.Params = append(ircmsg.Params, line[1:]) 297 | break 298 | } 299 | paramEnd := strings.IndexByte(line, ' ') 300 | if paramEnd == -1 { 301 | ircmsg.Params = append(ircmsg.Params, line) 302 | break 303 | } 304 | ircmsg.Params = append(ircmsg.Params, line[:paramEnd]) 305 | line = line[paramEnd+1:] 306 | } 307 | 308 | return ircmsg, err 309 | } 310 | 311 | // helper to parse tags 312 | func (ircmsg *Message) parseTags(tags string) (err error) { 313 | for 0 < len(tags) { 314 | tagEnd := strings.IndexByte(tags, ';') 315 | endPos := tagEnd 316 | nextPos := tagEnd + 1 317 | if tagEnd == -1 { 318 | endPos = len(tags) 319 | nextPos = len(tags) 320 | } 321 | tagPair := tags[:endPos] 322 | equalsIndex := strings.IndexByte(tagPair, '=') 323 | var tagName, tagValue string 324 | if equalsIndex == -1 { 325 | // tag with no value 326 | tagName = tagPair 327 | } else { 328 | tagName, tagValue = tagPair[:equalsIndex], tagPair[equalsIndex+1:] 329 | } 330 | // "Implementations [...] MUST NOT perform any validation that would 331 | // reject the message if an invalid tag key name is used." 332 | if validateTagName(tagName) { 333 | if !validateTagValue(tagValue) { 334 | return ErrorInvalidTagContent 335 | } 336 | ircmsg.SetTag(tagName, UnescapeTagValue(tagValue)) 337 | } 338 | // skip over the tag just processed, plus the delimiting ; if any 339 | tags = tags[nextPos:] 340 | } 341 | return nil 342 | } 343 | 344 | // MakeMessage provides a simple way to create a new Message. 345 | func MakeMessage(tags map[string]string, source string, command string, params ...string) (ircmsg Message) { 346 | ircmsg.Source = source 347 | ircmsg.Command = command 348 | ircmsg.Params = params 349 | ircmsg.UpdateTags(tags) 350 | return ircmsg 351 | } 352 | 353 | // Line returns a sendable line created from an Message. 354 | func (ircmsg *Message) Line() (result string, err error) { 355 | bytes, err := ircmsg.line(0, 0, 0, 0) 356 | if err == nil { 357 | result = string(bytes) 358 | } 359 | return 360 | } 361 | 362 | // LineBytes returns a sendable line created from an Message. 363 | func (ircmsg *Message) LineBytes() (result []byte, err error) { 364 | result, err = ircmsg.line(0, 0, 0, 0) 365 | return 366 | } 367 | 368 | // LineBytesStrict returns a sendable line, as a []byte, created from an Message. 369 | // fromClient controls whether the server-side or client-side tag length limit 370 | // is enforced. If truncateLen is nonzero, it is the length at which the 371 | // non-tag portion of the message is truncated. 372 | func (ircmsg *Message) LineBytesStrict(fromClient bool, truncateLen int) ([]byte, error) { 373 | var tagLimit, clientOnlyTagDataLimit, serverAddedTagDataLimit int 374 | if fromClient { 375 | // enforce client max tags: 376 | // (4096) :: '@' ' ' 377 | tagLimit = MaxlenTagsFromClient 378 | } else { 379 | // on the server side, enforce separate client-only and server-added tag budgets: 380 | // "Servers MUST NOT add tag data exceeding 4094 bytes to messages." 381 | // (8191) :: '@' ';' ' ' 382 | clientOnlyTagDataLimit = MaxlenClientTagData 383 | serverAddedTagDataLimit = MaxlenServerTagData 384 | } 385 | return ircmsg.line(tagLimit, clientOnlyTagDataLimit, serverAddedTagDataLimit, truncateLen) 386 | } 387 | 388 | func paramRequiresTrailing(param string) bool { 389 | return len(param) == 0 || strings.IndexByte(param, ' ') != -1 || param[0] == ':' 390 | } 391 | 392 | // line returns a sendable line created from an Message. 393 | func (ircmsg *Message) line(tagLimit, clientOnlyTagDataLimit, serverAddedTagDataLimit, truncateLen int) (result []byte, err error) { 394 | if len(ircmsg.Command) == 0 { 395 | return nil, ErrorCommandMissing 396 | } 397 | 398 | var buf bytes.Buffer 399 | 400 | // write the tags, computing the budgets for client-only tags and regular tags 401 | var lenRegularTags, lenClientOnlyTags, lenTags int 402 | if 0 < len(ircmsg.tags) || 0 < len(ircmsg.clientOnlyTags) { 403 | var tagError error 404 | buf.WriteByte('@') 405 | firstTag := true 406 | writeTags := func(tags map[string]string) { 407 | for tag, val := range tags { 408 | if !(validateTagName(tag) && validateTagValue(val)) { 409 | tagError = ErrorInvalidTagContent 410 | } 411 | if !firstTag { 412 | buf.WriteByte(';') // delimiter 413 | } 414 | buf.WriteString(tag) 415 | if val != "" { 416 | buf.WriteByte('=') 417 | buf.WriteString(EscapeTagValue(val)) 418 | } 419 | firstTag = false 420 | } 421 | } 422 | writeTags(ircmsg.tags) 423 | lenRegularTags = buf.Len() - 1 // '@' is not counted 424 | writeTags(ircmsg.clientOnlyTags) 425 | lenClientOnlyTags = (buf.Len() - 1) - lenRegularTags // '@' is not counted 426 | if lenRegularTags != 0 { 427 | // semicolon between regular and client-only tags is not counted 428 | lenClientOnlyTags -= 1 429 | } 430 | buf.WriteByte(' ') 431 | if tagError != nil { 432 | return nil, tagError 433 | } 434 | } 435 | lenTags = buf.Len() 436 | 437 | if 0 < tagLimit && tagLimit < buf.Len() { 438 | return nil, ErrorTagsTooLong 439 | } 440 | if (0 < clientOnlyTagDataLimit && clientOnlyTagDataLimit < lenClientOnlyTags) || (0 < serverAddedTagDataLimit && serverAddedTagDataLimit < lenRegularTags) { 441 | return nil, ErrorTagsTooLong 442 | } 443 | 444 | if len(ircmsg.Source) > 0 { 445 | buf.WriteByte(':') 446 | buf.WriteString(ircmsg.Source) 447 | buf.WriteByte(' ') 448 | } 449 | 450 | buf.WriteString(ircmsg.Command) 451 | 452 | for i, param := range ircmsg.Params { 453 | buf.WriteByte(' ') 454 | requiresTrailing := paramRequiresTrailing(param) 455 | lastParam := i == len(ircmsg.Params)-1 456 | if (requiresTrailing || ircmsg.forceTrailing) && lastParam { 457 | buf.WriteByte(':') 458 | } else if requiresTrailing && !lastParam { 459 | return nil, ErrorBadParam 460 | } 461 | buf.WriteString(param) 462 | } 463 | 464 | // truncate if desired; leave 2 bytes over for \r\n: 465 | if truncateLen != 0 && (truncateLen-2) < (buf.Len()-lenTags) { 466 | err = ErrorBodyTooLong 467 | newBufLen := lenTags + (truncateLen - 2) 468 | buf.Truncate(newBufLen) 469 | // XXX: we may have truncated in the middle of a UTF8-encoded codepoint; 470 | // if so, remove additional bytes, stopping when the sequence either 471 | // ends in a valid codepoint, or we have removed 3 bytes (the maximum 472 | // length of the remnant of a once-valid, truncated codepoint; we don't 473 | // want to truncate the entire message if it wasn't UTF8 in the first 474 | // place). 475 | for i := 0; i < (utf8.UTFMax - 1); i++ { 476 | r, n := utf8.DecodeLastRune(buf.Bytes()) 477 | if r == utf8.RuneError && n <= 1 { 478 | newBufLen-- 479 | buf.Truncate(newBufLen) 480 | } else { 481 | break 482 | } 483 | } 484 | } 485 | buf.WriteString("\r\n") 486 | 487 | result = buf.Bytes() 488 | toValidate := result[:len(result)-2] 489 | if bytes.IndexByte(toValidate, '\x00') != -1 || bytes.IndexByte(toValidate, '\r') != -1 || bytes.IndexByte(toValidate, '\n') != -1 { 490 | return nil, ErrorLineContainsBadChar 491 | } 492 | return result, err 493 | } 494 | -------------------------------------------------------------------------------- /ircmsg/message_test.go: -------------------------------------------------------------------------------- 1 | package ircmsg 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | "unicode/utf8" 10 | ) 11 | 12 | type testcode struct { 13 | raw string 14 | message Message 15 | } 16 | type testcodewithlen struct { 17 | raw string 18 | length int 19 | message Message 20 | truncateExpected bool 21 | } 22 | 23 | var decodelentests = []testcodewithlen{ 24 | {":dan-!d@localhost PRIVMSG dan #test :What a cool message\r\n", 22, 25 | MakeMessage(nil, "dan-!d@localhost", "PR"), true}, 26 | {"@time=12732;re TEST *\r\n", 512, 27 | MakeMessage(map[string]string{"time": "12732", "re": ""}, "", "TEST", "*"), false}, 28 | {"@time=12732;re TEST *\r\n", 512, 29 | MakeMessage(map[string]string{"time": "12732", "re": ""}, "", "TEST", "*"), false}, 30 | {":dan- TESTMSG\r\n", 2048, 31 | MakeMessage(nil, "dan-", "TESTMSG"), false}, 32 | {":dan- TESTMSG dan \r\n", 14, 33 | MakeMessage(nil, "dan-", "TESTMS"), true}, 34 | {"TESTMSG\r\n", 6, 35 | MakeMessage(nil, "", "TEST"), true}, 36 | {"TESTMSG\r\n", 7, 37 | MakeMessage(nil, "", "TESTM"), true}, 38 | {"TESTMSG\r\n", 8, 39 | MakeMessage(nil, "", "TESTMS"), true}, 40 | {"TESTMSG\r\n", 9, 41 | MakeMessage(nil, "", "TESTMSG"), false}, 42 | {"PRIVMSG #chat :12 345🐬", 27, 43 | MakeMessage(nil, "", "PRIVMSG", "#chat", "12 345🐬"), false}, 44 | // test utf-8 aware truncation: 45 | {"PRIVMSG #chat :12 345🐬", 26, 46 | MakeMessage(nil, "", "PRIVMSG", "#chat", "12 345"), true}, 47 | {"PRIVMSG #chat :12 345", 23, 48 | MakeMessage(nil, "", "PRIVMSG", "#chat", "12 345"), false}, 49 | } 50 | 51 | // map[string]string{"time": "12732", "re": ""} 52 | var decodetests = []testcode{ 53 | {":dan-!d@localhost PRIVMSG dan #test :What a cool message\r\n", 54 | MakeMessage(nil, "dan-!d@localhost", "PRIVMSG", "dan", "#test", "What a cool message")}, 55 | {"@time=2848 :dan-!d@localhost LIST\r\n", 56 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "LIST")}, 57 | {"@time=2848 LIST\r\n", 58 | MakeMessage(map[string]string{"time": "2848"}, "", "LIST")}, 59 | {"LIST\r\n", 60 | MakeMessage(nil, "", "LIST")}, 61 | {"@time=12732;re TEST *a asda:fs :fhye tegh\r\n", 62 | MakeMessage(map[string]string{"time": "12732", "re": ""}, "", "TEST", "*a", "asda:fs", "fhye tegh")}, 63 | {"@time=12732;re TEST *\r\n", 64 | MakeMessage(map[string]string{"time": "12732", "re": ""}, "", "TEST", "*")}, 65 | {":dan- TESTMSG\r\n", 66 | MakeMessage(nil, "dan-", "TESTMSG")}, 67 | {":dan- TESTMSG dan \r\n", 68 | MakeMessage(nil, "dan-", "TESTMSG", "dan")}, 69 | {"@time=2019-02-28T19:30:01.727Z ping HiThere!\r\n", 70 | MakeMessage(map[string]string{"time": "2019-02-28T19:30:01.727Z"}, "", "PING", "HiThere!")}, 71 | {"@+draft/test=hi\\nthere PING HiThere!\r\n", 72 | MakeMessage(map[string]string{"+draft/test": "hi\nthere"}, "", "PING", "HiThere!")}, 73 | {"ping asdf\n", 74 | MakeMessage(nil, "", "PING", "asdf")}, 75 | {"JoIN #channel\n", 76 | MakeMessage(nil, "", "JOIN", "#channel")}, 77 | {"@draft/label=l join #channel\n", 78 | MakeMessage(map[string]string{"draft/label": "l"}, "", "JOIN", "#channel")}, 79 | {"list", 80 | MakeMessage(nil, "", "LIST")}, 81 | {"list ", 82 | MakeMessage(nil, "", "LIST")}, 83 | {"list ", 84 | MakeMessage(nil, "", "LIST")}, 85 | {"@time=2848 :dan-!d@localhost LIST \r\n", 86 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "LIST")}, 87 | {"@time=2848 :dan-!d@localhost PRIVMSG a:b :\r\n", 88 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "PRIVMSG", "a:b", "")}, 89 | {"@time=2848 :dan-!d@localhost PRIVMSG a:b ::\r\n", 90 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "PRIVMSG", "a:b", ":")}, 91 | {"@time=2848 :dan-!d@localhost PRIVMSG a:b ::hi\r\n", 92 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "PRIVMSG", "a:b", ":hi")}, 93 | {"@time=2848 :dan-!d@localhost PRIVMSG a:b :hi\r\n", 94 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "PRIVMSG", "a:b", "hi")}, 95 | // invalid UTF8: 96 | {"@time=2848 :dan-!d@localhost PRIVMSG a:b :hi\xf0\xf0\r\n", 97 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "PRIVMSG", "a:b", "hi\xf0\xf0")}, 98 | {"@time=2848 :dan-!d@localhost PRIVMSG a:b :\xf0hi\xf0\r\n", 99 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "PRIVMSG", "a:b", "\xf0hi\xf0")}, 100 | {"@time=2848 :dan-!d@localhost PRIVMSG a:b :\xff\r\n", 101 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "PRIVMSG", "a:b", "\xff")}, 102 | {"@time=2848 :dan-!d@localhost PRIVMSG a:b :\xf9g\xa6=\xcf6s\xb2\xe2\xaf\xa0kSN?\x95\r\n", 103 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "PRIVMSG", "a:b", "\xf9g\xa6=\xcf6s\xb2\xe2\xaf\xa0kSN?\x95")}, 104 | {"@time=2848 :dan-!d@localhost PRIVMSG a:b \xf9g\xa6=\xcf6s\xb2\xe2\xaf\xa0kSN?\x95\r\n", 105 | MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "PRIVMSG", "a:b", "\xf9g\xa6=\xcf6s\xb2\xe2\xaf\xa0kSN?\x95")}, 106 | } 107 | 108 | type testparseerror struct { 109 | raw string 110 | err error 111 | } 112 | 113 | var decodetesterrors = []testparseerror{ 114 | {"", ErrorLineIsEmpty}, 115 | {"\n", ErrorLineIsEmpty}, 116 | {"\r\n", ErrorLineIsEmpty}, 117 | {"\r\n ", ErrorLineContainsBadChar}, 118 | {"\r\n ", ErrorLineContainsBadChar}, 119 | {" \r\n", ErrorLineIsEmpty}, 120 | {" \r\n ", ErrorLineContainsBadChar}, 121 | {" \r\n ", ErrorLineContainsBadChar}, 122 | {"\xff\r\n", ErrorLineContainsBadChar}, 123 | {"@tags=tesa\r\n", ErrorLineIsEmpty}, 124 | {"@tags=tested \r\n", ErrorLineIsEmpty}, 125 | {":dan- \r\n", ErrorLineIsEmpty}, 126 | {":dan-\r\n", ErrorLineIsEmpty}, 127 | {"@tag1=1;tag2=2 :dan \r\n", ErrorLineIsEmpty}, 128 | {"@tag1=1;tag2=2 :dan \r\n", ErrorLineIsEmpty}, 129 | {"@tag1=1;tag2=2\x00 :dan \r\n", ErrorLineContainsBadChar}, 130 | {"@tag1=1;tag2=2\xff :dan PRIVMSG #channel hi\r\n", ErrorInvalidTagContent}, 131 | {"@tag1=1;tag2=2\x00 :shivaram PRIVMSG #channel hi\r\n", ErrorLineContainsBadChar}, 132 | {"privmsg #channel :command injection attempt \n:Nickserv PRIVMSG user :Please re-enter your password", ErrorLineContainsBadChar}, 133 | {"privmsg #channel :command injection attempt \r:Nickserv PRIVMSG user :Please re-enter your password", ErrorLineContainsBadChar}, 134 | } 135 | 136 | func validateTruncateError(pair testcodewithlen, err error, t *testing.T) { 137 | if pair.truncateExpected { 138 | if err != ErrorBodyTooLong { 139 | t.Error("For", pair.raw, "expected truncation, but got error", err) 140 | } 141 | } else { 142 | if err != nil { 143 | t.Error("For", pair.raw, "expected no error, but got", err) 144 | } 145 | } 146 | } 147 | 148 | func TestDecode(t *testing.T) { 149 | for _, pair := range decodelentests { 150 | ircmsg, err := ParseLineStrict(pair.raw, true, pair.length) 151 | validateTruncateError(pair, err, t) 152 | 153 | if !reflect.DeepEqual(ircmsg, pair.message) { 154 | t.Error( 155 | "For", pair.raw, 156 | "expected", pair.message, 157 | "got", ircmsg, 158 | ) 159 | } 160 | } 161 | for _, pair := range decodetests { 162 | ircmsg, err := ParseLine(pair.raw) 163 | if err != nil { 164 | t.Error( 165 | "For", pair.raw, 166 | "Failed to parse line:", err, 167 | ) 168 | } 169 | 170 | if !reflect.DeepEqual(ircmsg, pair.message) { 171 | t.Error( 172 | "For", pair.raw, 173 | "expected", pair.message, 174 | "got", ircmsg, 175 | ) 176 | } 177 | } 178 | for _, pair := range decodetesterrors { 179 | _, err := ParseLineStrict(pair.raw, true, 0) 180 | if err != pair.err { 181 | t.Error( 182 | "For", pair.raw, 183 | "expected", pair.err, 184 | "got", err, 185 | ) 186 | } 187 | } 188 | } 189 | 190 | var encodetests = []testcode{ 191 | {":dan-!d@localhost PRIVMSG dan #test :What a cool message\r\n", 192 | MakeMessage(nil, "dan-!d@localhost", "PRIVMSG", "dan", "#test", "What a cool message")}, 193 | {"@time=12732 TEST *a asda:fs :fhye tegh\r\n", 194 | MakeMessage(map[string]string{"time": "12732"}, "", "TEST", "*a", "asda:fs", "fhye tegh")}, 195 | {"@time=12732 TEST *\r\n", 196 | MakeMessage(map[string]string{"time": "12732"}, "", "TEST", "*")}, 197 | {"@re TEST *\r\n", 198 | MakeMessage(map[string]string{"re": ""}, "", "TEST", "*")}, 199 | } 200 | var encodelentests = []testcodewithlen{ 201 | {":dan-!d@lo\r\n", 12, 202 | MakeMessage(nil, "dan-!d@localhost", "PRIVMSG", "dan", "#test", "What a cool message"), true}, 203 | {"@time=12732 TEST *\r\n", 52, 204 | MakeMessage(map[string]string{"time": "12732"}, "", "TEST", "*"), false}, 205 | {"@riohwihowihirgowihre TEST *\r\n", 8, 206 | MakeMessage(map[string]string{"riohwihowihirgowihre": ""}, "", "TEST", "*", "*"), true}, 207 | } 208 | 209 | func TestEncode(t *testing.T) { 210 | for _, pair := range encodetests { 211 | line, err := pair.message.LineBytes() 212 | if err != nil { 213 | t.Error( 214 | "For", pair.raw, 215 | "Failed to parse line:", err, 216 | ) 217 | } 218 | 219 | if string(line) != pair.raw { 220 | t.Error( 221 | "For LineBytes of", pair.message, 222 | "expected", pair.raw, 223 | "got", line, 224 | ) 225 | } 226 | } 227 | for _, pair := range encodetests { 228 | line, err := pair.message.Line() 229 | if err != nil { 230 | t.Error( 231 | "For", pair.raw, 232 | "Failed to parse line:", err, 233 | ) 234 | } 235 | 236 | if line != pair.raw { 237 | t.Error( 238 | "For", pair.message, 239 | "expected", pair.raw, 240 | "got", line, 241 | ) 242 | } 243 | } 244 | for _, pair := range encodelentests { 245 | line, err := pair.message.LineBytesStrict(true, pair.length) 246 | validateTruncateError(pair, err, t) 247 | 248 | if string(line) != pair.raw { 249 | t.Error( 250 | "For", pair.message, 251 | "expected", pair.raw, 252 | "got", line, 253 | ) 254 | } 255 | } 256 | } 257 | 258 | var encodeErrorTests = []struct { 259 | tags map[string]string 260 | prefix string 261 | command string 262 | params []string 263 | err error 264 | }{ 265 | {tags: nil, command: "PRIVMSG", params: []string{"", "hi"}, err: ErrorBadParam}, 266 | {tags: nil, command: "KICK", params: []string{":nick", "message"}, err: ErrorBadParam}, 267 | {tags: nil, command: "QUX", params: []string{"#baz", ":bat", "bar"}, err: ErrorBadParam}, 268 | {tags: nil, command: "", params: []string{"hi"}, err: ErrorCommandMissing}, 269 | {tags: map[string]string{"a\x00b": "hi"}, command: "PING", params: []string{"hi"}, err: ErrorInvalidTagContent}, 270 | {tags: map[string]string{"ab": "h\x00i"}, command: "PING", params: []string{"hi"}, err: ErrorLineContainsBadChar}, 271 | {tags: map[string]string{"ab": "\xff\xff"}, command: "PING", params: []string{"hi"}, err: ErrorInvalidTagContent}, 272 | {tags: map[string]string{"ab": "hi"}, command: "PING", params: []string{"h\x00i"}, err: ErrorLineContainsBadChar}, 273 | {tags: map[string]string{"ab": "hi"}, command: "PING", params: []string{"h\ni"}, err: ErrorLineContainsBadChar}, 274 | {tags: map[string]string{"ab": "hi"}, command: "PING", params: []string{"hi\rQUIT"}, err: ErrorLineContainsBadChar}, 275 | {tags: map[string]string{"ab": "hi"}, command: "NOTICE", params: []string{"#channel", "hi\r\nQUIT"}, err: ErrorLineContainsBadChar}, 276 | } 277 | 278 | func TestEncodeErrors(t *testing.T) { 279 | for _, ep := range encodeErrorTests { 280 | msg := MakeMessage(ep.tags, ep.prefix, ep.command, ep.params...) 281 | _, err := msg.LineBytesStrict(true, 512) 282 | if err != ep.err { 283 | t.Errorf("For %#v, expected %v, got %v", msg, ep.err, err) 284 | } 285 | } 286 | } 287 | 288 | var testMessages = []Message{ 289 | { 290 | tags: map[string]string{"time": "2019-02-27T04:38:57.489Z", "account": "dan-"}, 291 | clientOnlyTags: map[string]string{"+status": "typing"}, 292 | Source: "dan-!~user@example.com", 293 | Command: "TAGMSG", 294 | }, 295 | { 296 | clientOnlyTags: map[string]string{"+status": "typing"}, 297 | Command: "PING", // invalid PING command but we don't care 298 | }, 299 | { 300 | tags: map[string]string{"time": "2019-02-27T04:38:57.489Z"}, 301 | Command: "PING", // invalid PING command but we don't care 302 | Params: []string{"12345"}, 303 | }, 304 | { 305 | tags: map[string]string{"time": "2019-02-27T04:38:57.489Z", "account": "dan-"}, 306 | Source: "dan-!~user@example.com", 307 | Command: "PRIVMSG", 308 | Params: []string{"#ircv3", ":smiley:"}, 309 | }, 310 | { 311 | tags: map[string]string{"time": "2019-02-27T04:38:57.489Z", "account": "dan-"}, 312 | Source: "dan-!~user@example.com", 313 | Command: "PRIVMSG", 314 | Params: []string{"#ircv3", "\x01ACTION writes some specs!\x01"}, 315 | }, 316 | { 317 | Source: "dan-!~user@example.com", 318 | Command: "PRIVMSG", 319 | Params: []string{"#ircv3", ": long trailing command with langue française in it"}, 320 | }, 321 | { 322 | Source: "dan-!~user@example.com", 323 | Command: "PRIVMSG", 324 | Params: []string{"#ircv3", " : long trailing command with langue française in it "}, 325 | }, 326 | { 327 | Source: "shivaram", 328 | Command: "KLINE", 329 | Params: []string{"ANDKILL", "24h", "tkadich", "your", "client", "is", "disconnecting", "too", "much"}, 330 | }, 331 | { 332 | tags: map[string]string{"time": "2019-02-27T06:01:23.545Z", "draft/msgid": "xjmgr6e4ih7izqu6ehmrtrzscy"}, 333 | Source: "שיברם", 334 | Command: "PRIVMSG", 335 | Params: []string{"ויקם מלך חדש על מצרים אשר לא ידע את יוסף"}, 336 | }, 337 | { 338 | Source: "shivaram!~user@2001:0db8::1", 339 | Command: "KICK", 340 | Params: []string{"#darwin", "devilbat", ":::::::::::::: :::::::::::::"}, 341 | }, 342 | } 343 | 344 | func TestEncodeDecode(t *testing.T) { 345 | for _, message := range testMessages { 346 | encoded, err := message.LineBytesStrict(false, 0) 347 | if err != nil { 348 | t.Errorf("Couldn't encode %v: %v", message, err) 349 | } 350 | parsed, err := ParseLineStrict(string(encoded), true, 0) 351 | if err != nil { 352 | t.Errorf("Couldn't re-decode %v: %v", encoded, err) 353 | } 354 | if !reflect.DeepEqual(message, parsed) { 355 | t.Errorf("After encoding and re-parsing, got different messages:\n%v\n%v", message, parsed) 356 | } 357 | } 358 | } 359 | 360 | func TestForceTrailing(t *testing.T) { 361 | message := Message{ 362 | Source: "shivaram", 363 | Command: "PRIVMSG", 364 | Params: []string{"#darwin", "nice"}, 365 | } 366 | bytes, err := message.LineBytesStrict(true, 0) 367 | if err != nil { 368 | t.Error(err) 369 | } 370 | if string(bytes) != ":shivaram PRIVMSG #darwin nice\r\n" { 371 | t.Errorf("unexpected serialization: %s", bytes) 372 | } 373 | message.ForceTrailing() 374 | bytes, err = message.LineBytesStrict(true, 0) 375 | if err != nil { 376 | t.Error(err) 377 | } 378 | if string(bytes) != ":shivaram PRIVMSG #darwin :nice\r\n" { 379 | t.Errorf("unexpected serialization: %s", bytes) 380 | } 381 | } 382 | 383 | func TestErrorLineTooLongGeneration(t *testing.T) { 384 | message := Message{ 385 | tags: map[string]string{"draft/msgid": "SAXV5OYJUr18CNJzdWa1qQ"}, 386 | Source: "shivaram", 387 | Command: "PRIVMSG", 388 | Params: []string{"aaaaaaaaaaaaaaaaaaaaa"}, 389 | } 390 | _, err := message.LineBytesStrict(true, 0) 391 | if err != nil { 392 | t.Error(err) 393 | } 394 | 395 | for i := 0; i < 100; i += 1 { 396 | message.SetTag(fmt.Sprintf("+client-tag-%d", i), "ok") 397 | } 398 | line, err := message.LineBytesStrict(true, 0) 399 | if err != nil { 400 | t.Error(err) 401 | } 402 | if 4096 < len(line) { 403 | t.Errorf("line is too long: %d", len(line)) 404 | } 405 | 406 | // add excess tag data, pushing us over the limit 407 | for i := 100; i < 500; i += 1 { 408 | message.SetTag(fmt.Sprintf("+client-tag-%d", i), "ok") 409 | } 410 | line, err = message.LineBytesStrict(true, 0) 411 | if err != ErrorTagsTooLong { 412 | t.Error(err) 413 | } 414 | 415 | message.clientOnlyTags = nil 416 | for i := 0; i < 500; i += 1 { 417 | message.SetTag(fmt.Sprintf("server-tag-%d", i), "ok") 418 | } 419 | line, err = message.LineBytesStrict(true, 0) 420 | if err != ErrorTagsTooLong { 421 | t.Error(err) 422 | } 423 | 424 | message.tags = nil 425 | message.clientOnlyTags = nil 426 | for i := 0; i < 200; i += 1 { 427 | message.SetTag(fmt.Sprintf("server-tag-%d", i), "ok") 428 | message.SetTag(fmt.Sprintf("+client-tag-%d", i), "ok") 429 | } 430 | // client cannot send this much tag data: 431 | line, err = message.LineBytesStrict(true, 0) 432 | if err != ErrorTagsTooLong { 433 | t.Error(err) 434 | } 435 | // but a server can, since the tags are split between client and server budgets: 436 | line, err = message.LineBytesStrict(false, 0) 437 | if err != nil { 438 | t.Error(err) 439 | } 440 | } 441 | 442 | var truncateTests = []string{ 443 | "x", // U+0078, Latin Small Letter X, 1 byte 444 | "ç", // U+00E7, Latin Small Letter C with Cedilla, 2 bytes 445 | "ꙮ", // U+A66E, Cyrillic Letter Multiocular O, 3 bytes 446 | "🐬", // U+1F42C, Dolphin, 4 bytes 447 | } 448 | 449 | func assertEqual(found, expected interface{}) { 450 | if !reflect.DeepEqual(found, expected) { 451 | panic(fmt.Sprintf("expected %#v, found %#v", expected, found)) 452 | } 453 | } 454 | 455 | func buildPingParam(initialLen, minLen int, encChar string) (result string) { 456 | var out strings.Builder 457 | for i := 0; i < initialLen; i++ { 458 | out.WriteByte('a') 459 | } 460 | for out.Len() <= minLen { 461 | out.WriteString(encChar) 462 | } 463 | return out.String() 464 | } 465 | 466 | func min(i, j int) int { 467 | if i < j { 468 | return i 469 | } 470 | return j 471 | } 472 | 473 | func TestTruncate(t *testing.T) { 474 | // OK, this test is weird: we're going to build a line with a final parameter 475 | // that consists of a bunch of a's, then some nonzero number of repetitions 476 | // of a different UTF8-encoded codepoint. we'll test all 4 possible lengths 477 | // for a codepoint, and a number of different alignments for the codepoint 478 | // relative to the 512-byte boundary. in all cases, we should produce valid 479 | // UTF8, and truncate at most 3 bytes below the 512-byte boundary. 480 | for idx, s := range truncateTests { 481 | // sanity check that we have the expected lengths: 482 | assertEqual(len(s), idx+1) 483 | r, _ := utf8.DecodeRuneInString(s) 484 | if r == utf8.RuneError { 485 | panic("invalid codepoint in test suite") 486 | } 487 | 488 | // "PING [param]\r\n", max parameter size is 512-7=505 bytes 489 | for initialLen := 490; initialLen < 500; initialLen++ { 490 | for i := 1; i < 50; i++ { 491 | param := buildPingParam(initialLen, initialLen+i, s) 492 | msg := MakeMessage(nil, "", "PING", param) 493 | msgBytes, err := msg.LineBytesStrict(false, 512) 494 | msgBytesNonTrunc, _ := msg.LineBytes() 495 | if len(msgBytes) == len(msgBytesNonTrunc) { 496 | if err != nil { 497 | t.Error("message was not truncated, but got error", err) 498 | } 499 | } else { 500 | if err != ErrorBodyTooLong { 501 | t.Error("message was truncated, but got error", err) 502 | } 503 | } 504 | if len(msgBytes) > 512 { 505 | t.Errorf("invalid serialized length %d", len(msgBytes)) 506 | } 507 | if len(msgBytes) < min(512-3, len(msgBytesNonTrunc)) { 508 | t.Errorf("invalid serialized length %d", len(msgBytes)) 509 | } 510 | if !utf8.Valid(msgBytes) { 511 | t.Errorf("PING %s encoded to invalid UTF8: %#v\n", param, msgBytes) 512 | } 513 | // skip over "PING " 514 | first, _ := utf8.DecodeRune(msgBytes[5:]) 515 | assertEqual(first, rune('a')) 516 | last, _ := utf8.DecodeLastRune(bytes.TrimSuffix(msgBytes, []byte("\r\n"))) 517 | assertEqual(last, r) 518 | } 519 | } 520 | } 521 | } 522 | 523 | func TestTruncateNonUTF8(t *testing.T) { 524 | for l := 490; l < 530; l++ { 525 | var buf strings.Builder 526 | for i := 0; i < l; i++ { 527 | buf.WriteByte('\xff') 528 | } 529 | param := buf.String() 530 | msg := MakeMessage(nil, "", "PING", param) 531 | msgBytes, err := msg.LineBytesStrict(false, 512) 532 | if !(err == nil || err == ErrorBodyTooLong) { 533 | panic(err) 534 | } 535 | if len(msgBytes) > 512 { 536 | t.Errorf("invalid serialized length %d", len(msgBytes)) 537 | } 538 | // full length is "PING \r\n", 7+len(param) 539 | if len(msgBytes) < min(512-3, 7+len(param)) { 540 | t.Errorf("invalid serialized length %d", len(msgBytes)) 541 | } 542 | } 543 | } 544 | 545 | func BenchmarkGenerate(b *testing.B) { 546 | msg := MakeMessage( 547 | map[string]string{"time": "2019-02-28T08:12:43.480Z", "account": "shivaram"}, 548 | "shivaram_hexchat!~user@irc.darwin.network", 549 | "PRIVMSG", 550 | "#darwin", "what's up guys", 551 | ) 552 | b.ResetTimer() 553 | for i := 0; i < b.N; i++ { 554 | msg.LineBytesStrict(false, 0) 555 | } 556 | } 557 | 558 | func BenchmarkParse(b *testing.B) { 559 | line := "@account=shivaram;draft/msgid=dqhkgglocqikjqikbkcdnv5dsq;time=2019-03-01T20:11:21.833Z :shivaram!~shivaram@good-fortune PRIVMSG #darwin :you're an EU citizen, right? it's illegal for you to be here now" 560 | for i := 0; i < b.N; i++ { 561 | ParseLineStrict(line, false, 0) 562 | } 563 | } 564 | -------------------------------------------------------------------------------- /ircmsg/tags.go: -------------------------------------------------------------------------------- 1 | // written by Daniel Oaks 2 | // released under the ISC license 3 | 4 | package ircmsg 5 | 6 | import ( 7 | "strings" 8 | "unicode/utf8" 9 | ) 10 | 11 | var ( 12 | // valtoescape replaces real characters with message tag escapes. 13 | valtoescape = strings.NewReplacer("\\", "\\\\", ";", "\\:", " ", "\\s", "\r", "\\r", "\n", "\\n") 14 | 15 | escapedCharLookupTable [256]byte 16 | ) 17 | 18 | func init() { 19 | // most chars escape to themselves 20 | for i := 0; i < 256; i += 1 { 21 | escapedCharLookupTable[i] = byte(i) 22 | } 23 | // these are the exceptions 24 | escapedCharLookupTable[':'] = ';' 25 | escapedCharLookupTable['s'] = ' ' 26 | escapedCharLookupTable['r'] = '\r' 27 | escapedCharLookupTable['n'] = '\n' 28 | } 29 | 30 | // EscapeTagValue takes a value, and returns an escaped message tag value. 31 | // 32 | // This function is automatically used when lines are created from an 33 | // Message, so you don't need to call it yourself before creating a line. 34 | func EscapeTagValue(inString string) string { 35 | return valtoescape.Replace(inString) 36 | } 37 | 38 | // UnescapeTagValue takes an escaped message tag value, and returns the raw value. 39 | // 40 | // This function is automatically used when lines are interpreted by ParseLine, 41 | // so you don't need to call it yourself after parsing a line. 42 | func UnescapeTagValue(inString string) string { 43 | // buf.Len() == 0 is the fastpath where we have not needed to unescape any chars 44 | var buf strings.Builder 45 | remainder := inString 46 | for { 47 | backslashPos := strings.IndexByte(remainder, '\\') 48 | 49 | if backslashPos == -1 { 50 | if buf.Len() == 0 { 51 | return inString 52 | } else { 53 | buf.WriteString(remainder) 54 | break 55 | } 56 | } else if backslashPos == len(remainder)-1 { 57 | // trailing backslash, which we strip 58 | if buf.Len() == 0 { 59 | return inString[:len(inString)-1] 60 | } else { 61 | buf.WriteString(remainder[:len(remainder)-1]) 62 | break 63 | } 64 | } 65 | 66 | // non-trailing backslash detected; we're now on the slowpath 67 | // where we modify the string 68 | if buf.Len() == 0 { 69 | buf.Grow(len(inString)) // just an optimization 70 | } 71 | buf.WriteString(remainder[:backslashPos]) 72 | buf.WriteByte(escapedCharLookupTable[remainder[backslashPos+1]]) 73 | remainder = remainder[backslashPos+2:] 74 | } 75 | 76 | return buf.String() 77 | } 78 | 79 | // https://ircv3.net/specs/extensions/message-tags.html#rules-for-naming-message-tags 80 | func validateTagName(name string) bool { 81 | if len(name) == 0 { 82 | return false 83 | } 84 | if name[0] == '+' { 85 | name = name[1:] 86 | } 87 | if len(name) == 0 { 88 | return false 89 | } 90 | // let's err on the side of leniency here; allow -./ (45-47) in any position 91 | for i := 0; i < len(name); i++ { 92 | c := name[i] 93 | if !(('-' <= c && c <= '/') || ('0' <= c && c <= '9') || ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z')) { 94 | return false 95 | } 96 | } 97 | return true 98 | } 99 | 100 | // "Tag values MUST be encoded as UTF8." 101 | func validateTagValue(value string) bool { 102 | return utf8.ValidString(value) 103 | } 104 | -------------------------------------------------------------------------------- /ircmsg/tags_test.go: -------------------------------------------------------------------------------- 1 | package ircmsg 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | type testcase struct { 10 | escaped string 11 | unescaped string 12 | } 13 | 14 | var tests = []testcase{ 15 | {"te\\nst", "te\nst"}, 16 | {"tes\\\\st", "tes\\st"}, 17 | {"te😃st", "te😃st"}, 18 | } 19 | 20 | var unescapeTests = []testcase{ 21 | {"te\\n\\kst", "te\nkst"}, 22 | {"te\\n\\kst\\", "te\nkst"}, 23 | {"te\\\\nst", "te\\nst"}, 24 | {"te😃st", "te😃st"}, 25 | {"0\\n1\\n2\\n3\\n4\\n5\\n6\\n\\", "0\n1\n2\n3\n4\n5\n6\n"}, 26 | {"test\\", "test"}, 27 | {"te\\:st\\", "te;st"}, 28 | {"te\\:\\st\\", "te; t"}, 29 | {"\\\\te\\:\\st", "\\te; t"}, 30 | {"test\\", "test"}, 31 | {"\\", ""}, 32 | {"", ""}, 33 | } 34 | 35 | func TestEscape(t *testing.T) { 36 | for _, pair := range tests { 37 | val := EscapeTagValue(pair.unescaped) 38 | 39 | if val != pair.escaped { 40 | t.Error( 41 | "For", pair.unescaped, 42 | "expected", pair.escaped, 43 | "got", val, 44 | ) 45 | } 46 | } 47 | } 48 | 49 | func TestUnescape(t *testing.T) { 50 | for _, pair := range tests { 51 | val := UnescapeTagValue(pair.escaped) 52 | 53 | if val != pair.unescaped { 54 | t.Error( 55 | "For", pair.escaped, 56 | "expected", pair.unescaped, 57 | "got", val, 58 | ) 59 | } 60 | } 61 | for _, pair := range unescapeTests { 62 | val := UnescapeTagValue(pair.escaped) 63 | 64 | if val != pair.unescaped { 65 | t.Error( 66 | "For", pair.escaped, 67 | "expected", pair.unescaped, 68 | "got", val, 69 | ) 70 | } 71 | } 72 | } 73 | 74 | func TestValidateTagName(t *testing.T) { 75 | if !validateTagName("c") { 76 | t.Error("c is valid") 77 | } 78 | if validateTagName("a_b") { 79 | t.Error("a_b is invalid") 80 | } 81 | } 82 | 83 | // tag string tests 84 | type testtags struct { 85 | raw string 86 | tags map[string]string 87 | } 88 | 89 | var tagdecodetests = []testtags{ 90 | {"", map[string]string{}}, 91 | {"time=12732;re", map[string]string{"time": "12732", "re": ""}}, 92 | {"time=12732;re=;asdf=5678", map[string]string{"time": "12732", "re": "", "asdf": "5678"}}, 93 | {"time=12732;draft/label=b;re=;asdf=5678", map[string]string{"time": "12732", "re": "", "asdf": "5678", "draft/label": "b"}}, 94 | {"=these;time=12732;=shouldbe;re=;asdf=5678;=ignored", map[string]string{"time": "12732", "re": "", "asdf": "5678"}}, 95 | {"dolphin=🐬;time=123456", map[string]string{"dolphin": "🐬", "time": "123456"}}, 96 | {"+dolphin=🐬;+draft/fox=f🦊x", map[string]string{"+dolphin": "🐬", "+draft/fox": "f🦊x"}}, 97 | {"+dolphin=🐬;+draft/f🦊x=fox", map[string]string{"+dolphin": "🐬"}}, 98 | {"+dolphin=🐬;+f🦊x=fox", map[string]string{"+dolphin": "🐬"}}, 99 | {"+dolphin=🐬;f🦊x=fox", map[string]string{"+dolphin": "🐬"}}, 100 | {"dolphin=🐬;f🦊x=fox", map[string]string{"dolphin": "🐬"}}, 101 | {"f🦊x=fox;+oragono.io/dolphin=🐬", map[string]string{"+oragono.io/dolphin": "🐬"}}, 102 | {"a=b;\\/=.", map[string]string{"a": "b"}}, 103 | } 104 | 105 | func parseTags(rawTags string) (map[string]string, error) { 106 | message, err := ParseLineStrict(fmt.Sprintf("@%s :shivaram TAGMSG #darwin\r\n", rawTags), true, 0) 107 | return message.AllTags(), err 108 | } 109 | 110 | func TestDecodeTags(t *testing.T) { 111 | for _, pair := range tagdecodetests { 112 | tags, err := parseTags(pair.raw) 113 | if err != nil { 114 | t.Error( 115 | "For", pair.raw, 116 | "Failed to parse line:", err, 117 | ) 118 | } 119 | 120 | if !reflect.DeepEqual(tags, pair.tags) { 121 | t.Error( 122 | "For", pair.raw, 123 | "expected", pair.tags, 124 | "got", tags, 125 | ) 126 | } 127 | } 128 | } 129 | 130 | var invalidtagdatatests = []string{ 131 | "label=\xff;batch=c", 132 | "label=a\xffb;batch=c", 133 | "label=a\xffb", 134 | "label=a\xff", 135 | "label=a\xff", 136 | "label=a\xf0a", 137 | } 138 | 139 | func TestTagInvalidUtf8(t *testing.T) { 140 | for _, tags := range invalidtagdatatests { 141 | _, err := ParseLineStrict(fmt.Sprintf("@%s PRIVMSG #chan hi\r\n", tags), true, 0) 142 | if err != ErrorInvalidTagContent { 143 | t.Errorf("") 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /ircmsg/unicode.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Shivaram Lingamneni 2 | // Released under the MIT License 3 | 4 | package ircmsg 5 | 6 | import ( 7 | "unicode/utf8" 8 | ) 9 | 10 | // TruncateUTF8Safe truncates a message, respecting UTF8 boundaries. If a message 11 | // was originally valid UTF8, TruncateUTF8Safe will not make it invalid; instead 12 | // it will truncate additional bytes as needed, back to the last valid 13 | // UTF8-encoded codepoint. If a message is not UTF8, TruncateUTF8Safe will truncate 14 | // at most 3 additional bytes before giving up. 15 | func TruncateUTF8Safe(message string, byteLimit int) (result string) { 16 | if len(message) <= byteLimit { 17 | return message 18 | } 19 | message = message[:byteLimit] 20 | for i := 0; i < (utf8.UTFMax - 1); i++ { 21 | r, n := utf8.DecodeLastRuneInString(message) 22 | if r == utf8.RuneError && n <= 1 { 23 | message = message[:len(message)-1] 24 | } else { 25 | break 26 | } 27 | } 28 | return message 29 | } 30 | -------------------------------------------------------------------------------- /ircmsg/unicode_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Shivaram Lingamneni 2 | // Released under the MIT License 3 | 4 | package ircmsg 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestTruncateUTF8(t *testing.T) { 11 | assertEqual(TruncateUTF8Safe("fffff", 512), "fffff") 12 | assertEqual(TruncateUTF8Safe("fffff", 5), "fffff") 13 | assertEqual(TruncateUTF8Safe("ffffff", 5), "fffff") 14 | assertEqual(TruncateUTF8Safe("ffffffffff", 5), "fffff") 15 | 16 | assertEqual(TruncateUTF8Safe("12345🐬", 9), "12345🐬") 17 | assertEqual(TruncateUTF8Safe("12345🐬", 8), "12345") 18 | assertEqual(TruncateUTF8Safe("12345🐬", 7), "12345") 19 | assertEqual(TruncateUTF8Safe("12345🐬", 6), "12345") 20 | assertEqual(TruncateUTF8Safe("12345", 5), "12345") 21 | 22 | assertEqual(TruncateUTF8Safe("\xff\xff\xff\xff\xff\xff", 512), "\xff\xff\xff\xff\xff\xff") 23 | assertEqual(TruncateUTF8Safe("\xff\xff\xff\xff\xff\xff", 6), "\xff\xff\xff\xff\xff\xff") 24 | // shouldn't truncate the whole string 25 | assertEqual(TruncateUTF8Safe("\xff\xff\xff\xff\xff\xff", 5), "\xff\xff") 26 | } 27 | 28 | func BenchmarkTruncateUTF8Invalid(b *testing.B) { 29 | for i := 0; i < b.N; i++ { 30 | TruncateUTF8Safe("\xff\xff\xff\xff\xff\xff", 5) 31 | } 32 | } 33 | 34 | func BenchmarkTruncateUTF8Valid(b *testing.B) { 35 | for i := 0; i < b.N; i++ { 36 | TruncateUTF8Safe("12345🐬", 8) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ircmsg/userhost.go: -------------------------------------------------------------------------------- 1 | // written by Daniel Oaks 2 | // released under the ISC license 3 | 4 | package ircmsg 5 | 6 | import ( 7 | "errors" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | MalformedNUH = errors.New("NUH is malformed") 13 | ) 14 | 15 | // NUH holds a parsed name!user@host source ("prefix") of an IRC message. 16 | // The Name member will be either a nickname (in the case of a user-initiated 17 | // message) or a server name (in the case of a server-initiated numeric, 18 | // command, or NOTICE). 19 | type NUH struct { 20 | Name string 21 | User string 22 | Host string 23 | } 24 | 25 | // ParseNUH parses a NUH source of an IRC message into its constituent parts; 26 | // name (nickname or server name), username, and hostname. 27 | func ParseNUH(in string) (out NUH, err error) { 28 | if len(in) == 0 { 29 | return out, MalformedNUH 30 | } 31 | 32 | hostStart := strings.IndexByte(in, '@') 33 | if hostStart != -1 { 34 | out.Host = in[hostStart+1:] 35 | in = in[:hostStart] 36 | } 37 | userStart := strings.IndexByte(in, '!') 38 | if userStart != -1 { 39 | out.User = in[userStart+1:] 40 | in = in[:userStart] 41 | } 42 | out.Name = in 43 | 44 | return 45 | } 46 | 47 | // Canonical returns the canonical string representation of the NUH. 48 | func (nuh *NUH) Canonical() (result string) { 49 | var out strings.Builder 50 | out.Grow(len(nuh.Name) + len(nuh.User) + len(nuh.Host) + 2) 51 | out.WriteString(nuh.Name) 52 | if len(nuh.User) != 0 { 53 | out.WriteByte('!') 54 | out.WriteString(nuh.User) 55 | } 56 | if len(nuh.Host) != 0 { 57 | out.WriteByte('@') 58 | out.WriteString(nuh.Host) 59 | } 60 | return out.String() 61 | } 62 | -------------------------------------------------------------------------------- /ircmsg/userhost_test.go: -------------------------------------------------------------------------------- 1 | // written by Daniel Oaks 2 | // released under the ISC license 3 | 4 | package ircmsg 5 | 6 | import ( 7 | "fmt" 8 | "testing" 9 | ) 10 | 11 | type nuhSplitTest struct { 12 | NUH 13 | Source string 14 | Canonical bool 15 | } 16 | 17 | var nuhTests = []nuhSplitTest{ 18 | { 19 | Source: "coolguy", 20 | NUH: NUH{"coolguy", "", ""}, 21 | Canonical: true, 22 | }, 23 | { 24 | Source: "coolguy!ag@127.0.0.1", 25 | NUH: NUH{"coolguy", "ag", "127.0.0.1"}, 26 | Canonical: true, 27 | }, 28 | { 29 | Source: "coolguy!~ag@localhost", 30 | NUH: NUH{"coolguy", "~ag", "localhost"}, 31 | Canonical: true, 32 | }, 33 | // missing components: 34 | { 35 | Source: "!ag@127.0.0.1", 36 | NUH: NUH{"", "ag", "127.0.0.1"}, 37 | Canonical: true, 38 | }, 39 | { 40 | Source: "coolguy!@127.0.0.1", 41 | NUH: NUH{"coolguy", "", "127.0.0.1"}, 42 | }, 43 | { 44 | Source: "coolguy@127.0.0.1", 45 | NUH: NUH{"coolguy", "", "127.0.0.1"}, 46 | Canonical: true, 47 | }, 48 | { 49 | Source: "coolguy!ag@", 50 | NUH: NUH{"coolguy", "ag", ""}, 51 | }, 52 | { 53 | Source: "coolguy!ag", 54 | NUH: NUH{"coolguy", "ag", ""}, 55 | Canonical: true, 56 | }, 57 | // resilient to weird characters: 58 | { 59 | Source: "coolguy!ag@net\x035w\x03ork.admin", 60 | NUH: NUH{"coolguy", "ag", "net\x035w\x03ork.admin"}, 61 | Canonical: true, 62 | }, 63 | { 64 | Source: "coolguy!~ag@n\x02et\x0305w\x0fork.admin", 65 | NUH: NUH{"coolguy", "~ag", "n\x02et\x0305w\x0fork.admin"}, 66 | Canonical: true, 67 | }, 68 | { 69 | Source: "testnet.ergo.chat", 70 | NUH: NUH{"testnet.ergo.chat", "", ""}, 71 | Canonical: true, 72 | }, 73 | } 74 | 75 | func assertEqualNUH(found, expected NUH) { 76 | if found.Name != expected.Name || found.User != expected.User || found.Host != expected.Host { 77 | panic(fmt.Sprintf("expected %#v, found %#v", expected.Canonical(), found.Canonical())) 78 | } 79 | } 80 | 81 | func TestSplittingNUH(t *testing.T) { 82 | for _, test := range nuhTests { 83 | out, err := ParseNUH(test.Source) 84 | if err != nil { 85 | t.Errorf("could not parse nuh test [%s] got [%s]", test.Source, err.Error()) 86 | } 87 | assertEqualNUH(out, test.NUH) 88 | canonical := out.Canonical() 89 | if test.Canonical { 90 | assertEqual(canonical, test.Source) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /ircreader/ircreader.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2021 Shivaram Lingamneni 2 | // released under the MIT license 3 | 4 | package ircreader 5 | 6 | import ( 7 | "bytes" 8 | "errors" 9 | "io" 10 | ) 11 | 12 | /* 13 | Reader is an optimized line reader for IRC lines containing tags; 14 | most IRC lines will not approach the maximum line length (8191 bytes 15 | of tag data, plus 512 bytes of message data), so we want a buffered 16 | reader that can start with a smaller buffer and expand if necessary, 17 | while also maintaining a hard upper limit on the size of the buffer. 18 | */ 19 | 20 | var ( 21 | ErrReadQ = errors.New("readQ exceeded (read too many bytes without terminating newline)") 22 | ) 23 | 24 | type Reader struct { 25 | conn io.Reader 26 | 27 | initialSize int 28 | maxSize int 29 | 30 | buf []byte 31 | start int // start of valid (i.e., read but not yet consumed) data in the buffer 32 | end int // end of valid data in the buffer 33 | searchFrom int // start of valid data in the buffer not yet searched for \n 34 | eof bool 35 | } 36 | 37 | // Returns a new *Reader with sane buffer size limits. 38 | func NewIRCReader(conn io.Reader) *Reader { 39 | var reader Reader 40 | reader.Initialize(conn, 512, 8192+1024) 41 | return &reader 42 | } 43 | 44 | // "Placement new" for a Reader; initializes it with custom buffer size 45 | // limits. 46 | func (cc *Reader) Initialize(conn io.Reader, initialSize, maxSize int) { 47 | *cc = Reader{} 48 | cc.conn = conn 49 | cc.initialSize = initialSize 50 | cc.maxSize = maxSize 51 | } 52 | 53 | // Blocks until a full IRC line is read, then returns it. Accepts either \n 54 | // or \r\n as the line terminator (but not \r in isolation). Passes through 55 | // errors from the underlying connection. Returns ErrReadQ if the buffer limit 56 | // was exceeded without a terminating \n. 57 | func (cc *Reader) ReadLine() ([]byte, error) { 58 | for { 59 | // try to find a terminated line in the buffered data already read 60 | nlidx := bytes.IndexByte(cc.buf[cc.searchFrom:cc.end], '\n') 61 | if nlidx != -1 { 62 | // got a complete line 63 | line := cc.buf[cc.start : cc.searchFrom+nlidx] 64 | cc.start = cc.searchFrom + nlidx + 1 65 | cc.searchFrom = cc.start 66 | // treat \r\n as the line terminator if it was present 67 | if 0 < len(line) && line[len(line)-1] == '\r' { 68 | line = line[:len(line)-1] 69 | } 70 | return line, nil 71 | } 72 | 73 | // are we out of space? we can read more if any of these are true: 74 | // 1. cc.start != 0, so we can slide the existing data back 75 | // 2. cc.end < len(cc.buf), so we can read data into the end of the buffer 76 | // 3. len(cc.buf) < cc.maxSize, so we can grow the buffer 77 | if cc.start == 0 && cc.end == len(cc.buf) && len(cc.buf) == cc.maxSize { 78 | return nil, ErrReadQ 79 | } 80 | 81 | if cc.eof { 82 | return nil, io.EOF 83 | } 84 | 85 | if len(cc.buf) < cc.maxSize && (len(cc.buf)-(cc.end-cc.start) < cc.initialSize/2) { 86 | // allocate a new buffer, copy any remaining data 87 | newLen := roundUpToPowerOfTwo(len(cc.buf) + 1) 88 | if newLen > cc.maxSize { 89 | newLen = cc.maxSize 90 | } else if newLen < cc.initialSize { 91 | newLen = cc.initialSize 92 | } 93 | newBuf := make([]byte, newLen) 94 | copy(newBuf, cc.buf[cc.start:cc.end]) 95 | cc.buf = newBuf 96 | } else if cc.start != 0 { 97 | // slide remaining data back to the front of the buffer 98 | copy(cc.buf, cc.buf[cc.start:cc.end]) 99 | } 100 | cc.end = cc.end - cc.start 101 | cc.start = 0 102 | 103 | cc.searchFrom = cc.end 104 | n, err := cc.conn.Read(cc.buf[cc.end:]) 105 | cc.end += n 106 | if n != 0 && err == io.EOF { 107 | // we may have received new \n-terminated lines, try to parse them 108 | cc.eof = true 109 | } else if err != nil { 110 | return nil, err 111 | } 112 | } 113 | } 114 | 115 | // return n such that v <= n and n == 2**i for some i 116 | func roundUpToPowerOfTwo(v int) int { 117 | // http://graphics.stanford.edu/~seander/bithacks.html 118 | v -= 1 119 | v |= v >> 1 120 | v |= v >> 2 121 | v |= v >> 4 122 | v |= v >> 8 123 | v |= v >> 16 124 | return v + 1 125 | } 126 | -------------------------------------------------------------------------------- /ircreader/ircreader_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Shivaram Lingamneni 2 | // released under the MIT license 3 | 4 | package ircreader 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "math/rand" 10 | "reflect" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | // mockConn is a fake io.Reader that yields len(counts) lines, 16 | // each consisting of counts[i] 'a' characters and a terminating '\n' 17 | type mockConn struct { 18 | counts []int 19 | } 20 | 21 | func min(i, j int) (m int) { 22 | if i < j { 23 | return i 24 | } else { 25 | return j 26 | } 27 | } 28 | 29 | func (c *mockConn) Read(b []byte) (n int, err error) { 30 | for len(b) > 0 { 31 | if len(c.counts) == 0 { 32 | return n, io.EOF 33 | } 34 | if c.counts[0] == 0 { 35 | b[0] = '\n' 36 | c.counts = c.counts[1:] 37 | b = b[1:] 38 | n += 1 39 | continue 40 | } 41 | size := min(c.counts[0], len(b)) 42 | for i := 0; i < size; i++ { 43 | b[i] = 'a' 44 | } 45 | c.counts[0] -= size 46 | b = b[size:] 47 | n += size 48 | } 49 | return n, nil 50 | } 51 | 52 | func (c *mockConn) Write(b []byte) (n int, err error) { 53 | return 54 | } 55 | 56 | func (c *mockConn) Close() error { 57 | c.counts = nil 58 | return nil 59 | } 60 | 61 | func newMockConn(counts []int) *mockConn { 62 | cpCounts := make([]int, len(counts)) 63 | copy(cpCounts, counts) 64 | return &mockConn{ 65 | counts: cpCounts, 66 | } 67 | } 68 | 69 | // construct a mock reader with some number of \n-terminated lines, 70 | // verify that IRCStreamConn can read and split them as expected 71 | func doLineReaderTest(counts []int, t *testing.T) { 72 | c := newMockConn(counts) 73 | r := NewIRCReader(c) 74 | var readCounts []int 75 | for { 76 | line, err := r.ReadLine() 77 | if err == nil { 78 | readCounts = append(readCounts, len(line)) 79 | } else if err == io.EOF { 80 | break 81 | } else { 82 | panic(err) 83 | } 84 | } 85 | 86 | if !reflect.DeepEqual(counts, readCounts) { 87 | t.Errorf("expected %#v, got %#v", counts, readCounts) 88 | } 89 | } 90 | 91 | const ( 92 | maxMockReaderLen = 100 93 | maxMockReaderLineLen = 4096 + 511 94 | ) 95 | 96 | func TestLineReader(t *testing.T) { 97 | counts := []int{44, 428, 3, 0, 200, 2000, 0, 4044, 33, 3, 2, 1, 0, 1, 2, 3, 48, 555} 98 | doLineReaderTest(counts, t) 99 | 100 | // fuzz 101 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 102 | for i := 0; i < 1000; i++ { 103 | countsLen := r.Intn(maxMockReaderLen) + 1 104 | counts := make([]int, countsLen) 105 | for i := 0; i < countsLen; i++ { 106 | counts[i] = r.Intn(maxMockReaderLineLen) 107 | } 108 | doLineReaderTest(counts, t) 109 | } 110 | } 111 | 112 | type mockConnLimits struct { 113 | // simulates the arrival of data via TCP; 114 | // each Read() call will read from at most one of the slices 115 | reads [][]byte 116 | } 117 | 118 | func (c *mockConnLimits) Read(b []byte) (n int, err error) { 119 | if len(c.reads) == 0 { 120 | return n, io.EOF 121 | } 122 | readLen := min(len(c.reads[0]), len(b)) 123 | copy(b[:readLen], c.reads[0][:readLen]) 124 | c.reads[0] = c.reads[0][readLen:] 125 | if len(c.reads[0]) == 0 { 126 | c.reads = c.reads[1:] 127 | } 128 | return readLen, nil 129 | } 130 | 131 | func makeLine(length int, ending bool) (result []byte) { 132 | totalLen := length 133 | if ending { 134 | totalLen++ 135 | } 136 | result = make([]byte, totalLen) 137 | for i := 0; i < length; i++ { 138 | result[i] = 'a' 139 | } 140 | if ending { 141 | result[len(result)-1] = '\n' 142 | } 143 | return 144 | } 145 | 146 | func assertEqual(found, expected interface{}) { 147 | if !reflect.DeepEqual(found, expected) { 148 | panic(fmt.Sprintf("expected %#v, found %#v", expected, found)) 149 | } 150 | } 151 | 152 | func TestRegression(t *testing.T) { 153 | var c mockConnLimits 154 | // this read fills up the buffer with a terminated line: 155 | c.reads = append(c.reads, makeLine(4605, true)) 156 | // this is a large, unterminated read: 157 | c.reads = append(c.reads, makeLine(4095, false)) 158 | // this terminates the previous read, within the acceptable limit: 159 | c.reads = append(c.reads, makeLine(500, true)) 160 | 161 | var cc Reader 162 | cc.Initialize(&c, 512, 4096+512) 163 | 164 | line, err := cc.ReadLine() 165 | assertEqual(len(line), 4605) 166 | assertEqual(err, nil) 167 | 168 | line, err = cc.ReadLine() 169 | assertEqual(len(line), 4595) 170 | assertEqual(err, nil) 171 | 172 | line, err = cc.ReadLine() 173 | assertEqual(err, io.EOF) 174 | } 175 | -------------------------------------------------------------------------------- /ircutils/doc.go: -------------------------------------------------------------------------------- 1 | // written by Daniel Oaks 2 | // released under the ISC license 3 | 4 | /* 5 | Package ircutils provides small, useful utility functions and classes. 6 | 7 | This package is in an alpha stage. 8 | */ 9 | package ircutils 10 | -------------------------------------------------------------------------------- /ircutils/hostnames.go: -------------------------------------------------------------------------------- 1 | // written by Daniel Oaks 2 | // released under the ISC license 3 | 4 | package ircutils 5 | 6 | import "strings" 7 | 8 | var allowedHostnameChars = "abcdefghijklmnopqrstuvwxyz1234567890-." 9 | 10 | // HostnameIsValid provides a way for servers to check whether a looked-up client 11 | // hostname is valid (see InspIRCd #1033 for why this is required). 12 | // 13 | // This function shouldn't be called by clients since they don't need to validate 14 | // hostnames for IRC use, just by servers that need to confirm hostnames of incoming 15 | // clients. 16 | // 17 | // In addition to this function, servers should impose their own limits on max 18 | // hostname length -- this function limits it to 200 but most servers will probably 19 | // want to make it smaller than that. 20 | func HostnameIsValid(hostname string) bool { 21 | // IRC hostnames specifically require a period, rough limit of 200 chars 22 | if !strings.Contains(hostname, ".") || len(hostname) < 1 || len(hostname) > 200 { 23 | return false 24 | } 25 | 26 | // ensure each part of hostname is valid 27 | for _, part := range strings.Split(hostname, ".") { 28 | if len(part) < 1 || len(part) > 63 || strings.HasPrefix(part, "-") || strings.HasSuffix(part, "-") { 29 | return false 30 | } 31 | } 32 | 33 | // ensure all chars of hostname are valid 34 | for _, char := range strings.Split(strings.ToLower(hostname), "") { 35 | if !strings.Contains(allowedHostnameChars, char) { 36 | return false 37 | } 38 | } 39 | 40 | return true 41 | } 42 | -------------------------------------------------------------------------------- /ircutils/sasl.go: -------------------------------------------------------------------------------- 1 | package ircutils 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | ) 7 | 8 | var ( 9 | ErrSASLLimitExceeded = errors.New("SASL total response size exceeded configured limit") 10 | ErrSASLTooLong = errors.New("SASL response chunk exceeded 400-byte limit") 11 | ) 12 | 13 | // EncodeSASLResponse encodes a raw SASL response as parameters to successive 14 | // AUTHENTICATE commands, as described in the IRCv3 SASL specification. 15 | func EncodeSASLResponse(raw []byte) (result []string) { 16 | // https://ircv3.net/specs/extensions/sasl-3.1#the-authenticate-command 17 | // "The response is encoded in Base64 (RFC 4648), then split to 400-byte chunks, 18 | // and each chunk is sent as a separate AUTHENTICATE command. Empty (zero-length) 19 | // responses are sent as AUTHENTICATE +. If the last chunk was exactly 400 bytes 20 | // long, it must also be followed by AUTHENTICATE + to signal end of response." 21 | 22 | if len(raw) == 0 { 23 | return []string{"+"} 24 | } 25 | 26 | response := base64.StdEncoding.EncodeToString(raw) 27 | result = make([]string, 0, (len(response)/400)+1) 28 | lastLen := 0 29 | for len(response) > 0 { 30 | // TODO once we require go 1.21, this can be: lastLen = min(len(response), 400) 31 | lastLen = len(response) 32 | if lastLen > 400 { 33 | lastLen = 400 34 | } 35 | result = append(result, response[:lastLen]) 36 | response = response[lastLen:] 37 | } 38 | 39 | if lastLen == 400 { 40 | result = append(result, "+") 41 | } 42 | 43 | return result 44 | } 45 | 46 | // SASLBuffer handles buffering and decoding SASL responses sent as parameters 47 | // to AUTHENTICATE commands, as described in the IRCv3 SASL specification. 48 | // Do not copy a SASLBuffer after first use. 49 | type SASLBuffer struct { 50 | maxLength int 51 | buf []byte 52 | } 53 | 54 | // NewSASLBuffer returns a new SASLBuffer. maxLength is the maximum amount of 55 | // data to buffer (0 for no limit). 56 | func NewSASLBuffer(maxLength int) *SASLBuffer { 57 | result := new(SASLBuffer) 58 | result.Initialize(maxLength) 59 | return result 60 | } 61 | 62 | // Initialize initializes a SASLBuffer in place. 63 | func (b *SASLBuffer) Initialize(maxLength int) { 64 | b.maxLength = maxLength 65 | } 66 | 67 | // Add processes an additional SASL response chunk sent via AUTHENTICATE. 68 | // If the response is complete, it resets the buffer and returns the decoded 69 | // response along with any decoding or protocol errors detected. 70 | func (b *SASLBuffer) Add(value string) (done bool, output []byte, err error) { 71 | if value == "+" { 72 | // total size is a multiple of 400 (possibly 0) 73 | output = b.buf 74 | b.Clear() 75 | return true, output, nil 76 | } 77 | 78 | if len(value) > 400 { 79 | b.Clear() 80 | return true, nil, ErrSASLTooLong 81 | } 82 | 83 | curLen := len(b.buf) 84 | chunkDecodedLen := base64.StdEncoding.DecodedLen(len(value)) 85 | if b.maxLength != 0 && (curLen+chunkDecodedLen) > b.maxLength { 86 | b.Clear() 87 | return true, nil, ErrSASLLimitExceeded 88 | } 89 | 90 | // "append-make pattern" as in the bytes.Buffer implementation: 91 | b.buf = append(b.buf, make([]byte, chunkDecodedLen)...) 92 | n, err := base64.StdEncoding.Decode(b.buf[curLen:], []byte(value)) 93 | b.buf = b.buf[0 : curLen+n] 94 | if err != nil { 95 | b.Clear() 96 | return true, nil, err 97 | } 98 | if len(value) < 400 { 99 | output = b.buf 100 | b.Clear() 101 | return true, output, nil 102 | } else { 103 | return false, nil, nil 104 | } 105 | } 106 | 107 | // Clear resets the buffer state. 108 | func (b *SASLBuffer) Clear() { 109 | // we can't reuse this buffer in general since we may have returned it 110 | b.buf = nil 111 | } 112 | -------------------------------------------------------------------------------- /ircutils/sasl_test.go: -------------------------------------------------------------------------------- 1 | package ircutils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSplitResponse(t *testing.T) { 8 | assertEqual(EncodeSASLResponse([]byte{}), []string{"+"}) 9 | assertEqual(EncodeSASLResponse( 10 | []byte("shivaram\x00shivaram\x00shivarampassphrase")), 11 | []string{"c2hpdmFyYW0Ac2hpdmFyYW0Ac2hpdmFyYW1wYXNzcGhyYXNl"}, 12 | ) 13 | 14 | // from the examples in the spec: 15 | assertEqual( 16 | EncodeSASLResponse([]byte("\x00emersion\x00Est ut beatae omnis ipsam. Quis fugiat deleniti totam qui. Ipsum quam a dolorum tempora velit laborum odit. Et saepe voluptate sed cumque vel. Voluptas sint ab pariatur libero veritatis corrupti. Vero iure omnis ullam. Vero beatae dolores facere fugiat ipsam. Ea est pariatur minima nobis sunt aut ut. Dolores ut laudantium maiores temporibus voluptates. Reiciendis impedit omnis et unde delectus quas ab. Quae eligendi necessitatibus doloribus molestias tempora magnam assumenda.")), 17 | []string{ 18 | "AGVtZXJzaW9uAEVzdCB1dCBiZWF0YWUgb21uaXMgaXBzYW0uIFF1aXMgZnVnaWF0IGRlbGVuaXRpIHRvdGFtIHF1aS4gSXBzdW0gcXVhbSBhIGRvbG9ydW0gdGVtcG9yYSB2ZWxpdCBsYWJvcnVtIG9kaXQuIEV0IHNhZXBlIHZvbHVwdGF0ZSBzZWQgY3VtcXVlIHZlbC4gVm9sdXB0YXMgc2ludCBhYiBwYXJpYXR1ciBsaWJlcm8gdmVyaXRhdGlzIGNvcnJ1cHRpLiBWZXJvIGl1cmUgb21uaXMgdWxsYW0uIFZlcm8gYmVhdGFlIGRvbG9yZXMgZmFjZXJlIGZ1Z2lhdCBpcHNhbS4gRWEgZXN0IHBhcmlhdHVyIG1pbmltYSBub2JpcyBz", 19 | "dW50IGF1dCB1dC4gRG9sb3JlcyB1dCBsYXVkYW50aXVtIG1haW9yZXMgdGVtcG9yaWJ1cyB2b2x1cHRhdGVzLiBSZWljaWVuZGlzIGltcGVkaXQgb21uaXMgZXQgdW5kZSBkZWxlY3R1cyBxdWFzIGFiLiBRdWFlIGVsaWdlbmRpIG5lY2Vzc2l0YXRpYnVzIGRvbG9yaWJ1cyBtb2xlc3RpYXMgdGVtcG9yYSBtYWduYW0gYXNzdW1lbmRhLg==", 20 | }, 21 | ) 22 | 23 | // 400 byte line must be followed by +: 24 | assertEqual( 25 | EncodeSASLResponse([]byte("slingamn\x00slingamn\x001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111")), 26 | []string{ 27 | "c2xpbmdhbW4Ac2xpbmdhbW4AMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMQ==", 28 | "+", 29 | }, 30 | ) 31 | } 32 | 33 | func TestBuffer(t *testing.T) { 34 | b := NewSASLBuffer(1200) 35 | 36 | // less than 400 bytes 37 | done, output, err := b.Add("c2hpdmFyYW0Ac2hpdmFyYW0Ac2hpdmFyYW1wYXNzcGhyYXNl") 38 | assertEqual(done, true) 39 | assertEqual(output, []byte("shivaram\x00shivaram\x00shivarampassphrase")) 40 | assertEqual(err, nil) 41 | 42 | // 400 bytes exactly plus a continuation +: 43 | done, output, err = b.Add("c2xpbmdhbW4Ac2xpbmdhbW4AMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMQ==") 44 | assertEqual(done, false) 45 | assertEqual(output, []byte(nil)) 46 | assertEqual(err, nil) 47 | done, output, err = b.Add("+") 48 | assertEqual(done, true) 49 | assertEqual(output, []byte("slingamn\x00slingamn\x001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111")) 50 | assertEqual(err, nil) 51 | 52 | // over 400 bytes 53 | done, output, err = b.Add("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==") 54 | assertEqual(done, true) 55 | assertEqual(output, []byte(nil)) 56 | assertEqual(err, ErrSASLTooLong) 57 | 58 | // a single + 59 | done, output, err = b.Add("+") 60 | assertEqual(done, true) 61 | assertEqual(output, []byte(nil)) 62 | assertEqual(err, nil) 63 | 64 | // length limit 65 | for i := 0; i < 4; i++ { 66 | done, output, err = b.Add("AGVtZXJzaW9uAEVzdCB1dCBiZWF0YWUgb21uaXMgaXBzYW0uIFF1aXMgZnVnaWF0IGRlbGVuaXRpIHRvdGFtIHF1aS4gSXBzdW0gcXVhbSBhIGRvbG9ydW0gdGVtcG9yYSB2ZWxpdCBsYWJvcnVtIG9kaXQuIEV0IHNhZXBlIHZvbHVwdGF0ZSBzZWQgY3VtcXVlIHZlbC4gVm9sdXB0YXMgc2ludCBhYiBwYXJpYXR1ciBsaWJlcm8gdmVyaXRhdGlzIGNvcnJ1cHRpLiBWZXJvIGl1cmUgb21uaXMgdWxsYW0uIFZlcm8gYmVhdGFlIGRvbG9yZXMgZmFjZXJlIGZ1Z2lhdCBpcHNhbS4gRWEgZXN0IHBhcmlhdHVyIG1pbmltYSBub2JpcyBz") 67 | assertEqual(done, false) 68 | assertEqual(output, []byte(nil)) 69 | assertEqual(err, nil) 70 | } 71 | done, output, err = b.Add("AA==") 72 | assertEqual(done, true) 73 | assertEqual(output, []byte(nil)) 74 | assertEqual(err, ErrSASLLimitExceeded) 75 | 76 | // invalid base64 77 | done, output, err = b.Add("!!!") 78 | assertEqual(done, true) 79 | assertEqual(len(output), 0) 80 | if err == nil { 81 | t.Errorf("expected non-nil error from invalid base64") 82 | } 83 | 84 | // two lines 85 | done, output, err = b.Add("AGVtZXJzaW9uAEVzdCB1dCBiZWF0YWUgb21uaXMgaXBzYW0uIFF1aXMgZnVnaWF0IGRlbGVuaXRpIHRvdGFtIHF1aS4gSXBzdW0gcXVhbSBhIGRvbG9ydW0gdGVtcG9yYSB2ZWxpdCBsYWJvcnVtIG9kaXQuIEV0IHNhZXBlIHZvbHVwdGF0ZSBzZWQgY3VtcXVlIHZlbC4gVm9sdXB0YXMgc2ludCBhYiBwYXJpYXR1ciBsaWJlcm8gdmVyaXRhdGlzIGNvcnJ1cHRpLiBWZXJvIGl1cmUgb21uaXMgdWxsYW0uIFZlcm8gYmVhdGFlIGRvbG9yZXMgZmFjZXJlIGZ1Z2lhdCBpcHNhbS4gRWEgZXN0IHBhcmlhdHVyIG1pbmltYSBub2JpcyBz") 86 | assertEqual(done, false) 87 | assertEqual(output, []byte(nil)) 88 | assertEqual(err, nil) 89 | done, output, err = b.Add("dW50IGF1dCB1dC4gRG9sb3JlcyB1dCBsYXVkYW50aXVtIG1haW9yZXMgdGVtcG9yaWJ1cyB2b2x1cHRhdGVzLiBSZWljaWVuZGlzIGltcGVkaXQgb21uaXMgZXQgdW5kZSBkZWxlY3R1cyBxdWFzIGFiLiBRdWFlIGVsaWdlbmRpIG5lY2Vzc2l0YXRpYnVzIGRvbG9yaWJ1cyBtb2xlc3RpYXMgdGVtcG9yYSBtYWduYW0gYXNzdW1lbmRhLg==") 90 | assertEqual(done, true) 91 | assertEqual(output, []byte("\x00emersion\x00Est ut beatae omnis ipsam. Quis fugiat deleniti totam qui. Ipsum quam a dolorum tempora velit laborum odit. Et saepe voluptate sed cumque vel. Voluptas sint ab pariatur libero veritatis corrupti. Vero iure omnis ullam. Vero beatae dolores facere fugiat ipsam. Ea est pariatur minima nobis sunt aut ut. Dolores ut laudantium maiores temporibus voluptates. Reiciendis impedit omnis et unde delectus quas ab. Quae eligendi necessitatibus doloribus molestias tempora magnam assumenda.")) 92 | assertEqual(err, nil) 93 | } 94 | -------------------------------------------------------------------------------- /ircutils/unicode.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Shivaram Lingamneni 2 | // Released under the MIT License 3 | 4 | package ircutils 5 | 6 | import ( 7 | "strings" 8 | "unicode" 9 | "unicode/utf8" 10 | 11 | "github.com/ergochat/irc-go/ircmsg" 12 | ) 13 | 14 | var TruncateUTF8Safe = ircmsg.TruncateUTF8Safe 15 | 16 | // Sanitizes human-readable text to make it safe for IRC; 17 | // assumes UTF-8 and uses the replacement character where 18 | // applicable. 19 | func SanitizeText(message string, byteLimit int) (result string) { 20 | var buf strings.Builder 21 | 22 | for _, r := range message { 23 | if r == '\x00' || r == '\r' { 24 | continue 25 | } else if r == '\n' { 26 | if buf.Len()+2 <= byteLimit { 27 | buf.WriteString(" ") 28 | continue 29 | } else { 30 | break 31 | } 32 | } else if unicode.IsSpace(r) { 33 | if buf.Len()+1 <= byteLimit { 34 | buf.WriteString(" ") 35 | } else { 36 | break 37 | } 38 | } else { 39 | rLen := utf8.RuneLen(r) 40 | if buf.Len()+rLen <= byteLimit { 41 | buf.WriteRune(r) 42 | } else { 43 | break 44 | } 45 | } 46 | } 47 | 48 | return buf.String() 49 | } 50 | -------------------------------------------------------------------------------- /ircutils/unicode_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Shivaram Lingamneni 2 | // Released under the MIT License 3 | 4 | package ircutils 5 | 6 | import ( 7 | "fmt" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func assertEqual(found, expected interface{}) { 13 | if !reflect.DeepEqual(found, expected) { 14 | panic(fmt.Sprintf("expected %#v, found %#v", expected, found)) 15 | } 16 | } 17 | 18 | func TestSanitize(t *testing.T) { 19 | assertEqual(SanitizeText("abc", 10), "abc") 20 | assertEqual(SanitizeText("abcdef", 5), "abcde") 21 | 22 | assertEqual(SanitizeText("shivaram\x00shivaram\x00shivarampassphrase", 400), "shivaramshivaramshivarampassphrase") 23 | 24 | assertEqual(SanitizeText("the quick brown fox\xffjumps over the lazy dog", 400), "the quick brown fox\xef\xbf\xbdjumps over the lazy dog") 25 | 26 | // \r ignored, \n is two spaces 27 | assertEqual(SanitizeText("the quick brown fox\r\njumps over the lazy dog", 400), "the quick brown fox jumps over the lazy dog") 28 | assertEqual(SanitizeText("the quick brown fox\njumps over the lazy dog", 400), "the quick brown fox jumps over the lazy dog") 29 | } 30 | --------------------------------------------------------------------------------