├── cmd └── pingpong │ ├── .gitignore │ └── pingpong.go ├── go.mod ├── test_resources ├── channel.txt.gz ├── runtestsloop.sh ├── server.key ├── server.crt └── irctests.json ├── .gitattributes ├── .gitignore ├── Makefile ├── run-test-tests.sh ├── tatombool.go ├── .github └── workflows │ ├── lint.yml │ ├── goveralls.yml │ └── build.yml ├── tatombool_test.go ├── .golangci.yml ├── LICENSE ├── message_benchmark_test.go ├── ratelimit.go ├── ratelimit_test.go ├── irc.go ├── irc_test.go ├── helpers_test.go ├── README.MD ├── message.go ├── client.go ├── message_test.go └── client_test.go /cmd/pingpong/.gitignore: -------------------------------------------------------------------------------- 1 | /pingpong 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gempir/go-twitch-irc/v4 2 | 3 | go 1.12 4 | -------------------------------------------------------------------------------- /test_resources/channel.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gempir/go-twitch-irc/HEAD/test_resources/channel.txt.gz -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.go text eol=lf 2 | LICENSE text eol=lf 3 | Makefile text eol=lf 4 | *.MD text eol=lf 5 | *.travis.yml text eol=lf 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .idea/ 3 | .vscode/ 4 | 5 | .DS_STORE 6 | 7 | coverage.out 8 | coverage.html 9 | 10 | go-twitch-irc 11 | debug.test 12 | -------------------------------------------------------------------------------- /test_resources/runtestsloop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SUCCESS = 0 4 | 5 | while true; do 6 | reset 7 | if ! make test; then 8 | echo "Failed after ${SUCCESS} successful runs" 9 | exit 1 10 | fi 11 | SUCCESS=$((SUCCESS + 1)) 12 | done 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | @go test -v 3 | 4 | bench: 5 | @go test -bench=. -run=^a 6 | 7 | cover: 8 | @go test -coverprofile=coverage.out -covermode=count 9 | @go tool cover -html=coverage.out -o coverage.html 10 | 11 | lint: 12 | @golangci-lint run --new-from-rev=e0a5614e47d349897~0 13 | 14 | lint-full: 15 | @golangci-lint run 16 | -------------------------------------------------------------------------------- /run-test-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | COUNT=1 6 | 7 | while true; do 8 | tput reset 9 | echo $COUNT 10 | COUNT=$((COUNT+1)) 11 | go test -race 12 | # make cover 13 | # go test -race -v -run TestPinger 14 | # sleep 3 15 | # go test -race -run TestCanNotUseImproperlyFormattedOauth 16 | done 17 | -------------------------------------------------------------------------------- /tatombool.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import "sync/atomic" 4 | 5 | // tAtomBool atomic bool for writing/reading across threads 6 | type tAtomBool struct{ flag int32 } 7 | 8 | func (b *tAtomBool) set(value bool) { 9 | var i int32 10 | if value { 11 | i = 1 12 | } 13 | atomic.StoreInt32(&(b.flag), i) 14 | } 15 | 16 | func (b *tAtomBool) get() bool { 17 | return atomic.LoadInt32(&(b.flag)) != 0 18 | } 19 | -------------------------------------------------------------------------------- /test_resources/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PARAMETERS----- 2 | BgUrgQQAIg== 3 | -----END EC PARAMETERS----- 4 | -----BEGIN EC PRIVATE KEY----- 5 | MIGkAgEBBDBUcin9s2P0GT7OqSTl9YflT2aPgQ+4ENc7NTgIdH8j4xMo07BOZZyY 6 | efyiL2Fh9LGgBwYFK4EEACKhZANiAATSEIOE8YUIKEmsljB1fneIzCAkkxRkuL/p 7 | w6vVvhyij2H2SvngzJh9NrlBRMwzLGsXV5AnaRymekjgJ0wBTUXTw7i46Cf0MTZZ 8 | NasnFxpIF0/b1THqa3zJJHqnY35U8+I= 9 | -----END EC PRIVATE KEY----- 10 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Go (Lint) 2 | on: [pull_request] 3 | jobs: 4 | golangci-lint: 5 | name: runner / golangci-lint (pre-build docker image) 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | with: 10 | fetch-depth: 0 11 | 12 | - name: golangci-lint 13 | uses: golangci/golangci-lint-action@v9 14 | with: 15 | version: v2.7.2 16 | 17 | args: --new-from-rev=e0a5614e47d349897~0 18 | -------------------------------------------------------------------------------- /tatombool_test.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import "testing" 4 | 5 | func TestTAtomBoolDefaultValue(t *testing.T) { 6 | var v tAtomBool 7 | assertFalse(t, v.get(), "Default value should be false") 8 | } 9 | 10 | func TestTAtomBoolSet(t *testing.T) { 11 | var v tAtomBool 12 | assertFalse(t, v.get(), "Initial value should be false") 13 | v.set(true) 14 | assertTrue(t, v.get(), "Should be true after being set to true") 15 | v.set(false) 16 | assertFalse(t, v.get(), "Should be false after being set to true") 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/goveralls.yml: -------------------------------------------------------------------------------- 1 | name: Goveralls 2 | on: [pull_request] 3 | jobs: 4 | 5 | goveralls: 6 | name: Goveralls 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | go: [stable, oldstable] 12 | 13 | steps: 14 | - name: Set up Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: ${{ matrix.go }} 18 | 19 | - uses: actions/checkout@v4 20 | - run: go test -coverprofile=coverage.out 21 | - uses: shogo82148/actions-goveralls@v1 22 | with: 23 | github-token: ${{ secrets.github_token }} 24 | path-to-profile: coverage.out 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [pull_request] 3 | jobs: 4 | 5 | build: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | go: [stable, oldstable] 12 | 13 | steps: 14 | - name: Set up Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: ${{ matrix.go }} 18 | id: go 19 | 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v4 22 | 23 | - name: Get dependencies 24 | run: | 25 | go get -v -t -d ./... 26 | 27 | - name: Benchmark 28 | run: make bench 29 | -------------------------------------------------------------------------------- /cmd/pingpong/pingpong.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | twitch "github.com/gempir/go-twitch-irc/v4" 8 | ) 9 | 10 | const ( 11 | clientUsername = "justinfan123123" 12 | clientAuthenticationToken = "oauth:123123123" 13 | ) 14 | 15 | func main() { 16 | client := twitch.NewClient(clientUsername, clientAuthenticationToken) 17 | 18 | client.OnPrivateMessage(func(message twitch.PrivateMessage) { 19 | if strings.Contains(strings.ToLower(message.Message), "ping") { 20 | log.Println(message.User.Name, "PONG", message.Message) 21 | } 22 | }) 23 | 24 | client.Join("testaccount_420") 25 | 26 | err := client.Connect() 27 | if err != nil { 28 | panic(err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | default: none 5 | enable: 6 | - bodyclose 7 | - dogsled 8 | - errcheck 9 | - funlen 10 | - goconst 11 | - gocritic 12 | - gocyclo 13 | - gosec 14 | - staticcheck 15 | - govet 16 | - ineffassign 17 | - unconvert 18 | - unparam 19 | - unused 20 | - whitespace 21 | - gocognit 22 | - prealloc 23 | 24 | settings: 25 | govet: 26 | enable: 27 | - shadow 28 | gocyclo: 29 | min-complexity: 15 30 | goconst: 31 | min-len: 2 32 | min-occurrences: 4 33 | gocritic: 34 | enabled-tags: 35 | - diagnostic 36 | - experimental 37 | - opinionated 38 | - performance 39 | - style 40 | disabled-checks: 41 | - unnamedResult 42 | funlen: 43 | lines: 100 44 | statements: 50 45 | 46 | formatters: 47 | settings: 48 | gofmt: 49 | simplify: true 50 | goimports: {} 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 gempir 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test_resources/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC/DCCAoSgAwIBAgIJAOz/DDtD2ZgbMAkGByqGSM49BAEwdzELMAkGA1UEBhMC 3 | R0IxDzANBgNVBAgTBkxvbmRvbjEPMA0GA1UEBxMGTG9uZG9uMRgwFgYDVQQKEw9H 4 | bG9iYWwgU2VjdXJpdHkxFjAUBgNVBAsTDUlUIERlcGFydG1lbnQxFDASBgNVBAMT 5 | C2V4YW1wbGUuY29tMB4XDTE3MTAwNTE4NTA0NFoXDTI3MTAwMzE4NTA0NFowdzEL 6 | MAkGA1UEBhMCR0IxDzANBgNVBAgTBkxvbmRvbjEPMA0GA1UEBxMGTG9uZG9uMRgw 7 | FgYDVQQKEw9HbG9iYWwgU2VjdXJpdHkxFjAUBgNVBAsTDUlUIERlcGFydG1lbnQx 8 | FDASBgNVBAMTC2V4YW1wbGUuY29tMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE0hCD 9 | hPGFCChJrJYwdX53iMwgJJMUZLi/6cOr1b4coo9h9kr54MyYfTa5QUTMMyxrF1eQ 10 | J2kcpnpI4CdMAU1F08O4uOgn9DE2WTWrJxcaSBdP29Ux6mt8ySR6p2N+VPPio4Hc 11 | MIHZMB0GA1UdDgQWBBSj8oUlaa4x6f7Eg6s9txsBfum5NTCBqQYDVR0jBIGhMIGe 12 | gBSj8oUlaa4x6f7Eg6s9txsBfum5NaF7pHkwdzELMAkGA1UEBhMCR0IxDzANBgNV 13 | BAgTBkxvbmRvbjEPMA0GA1UEBxMGTG9uZG9uMRgwFgYDVQQKEw9HbG9iYWwgU2Vj 14 | dXJpdHkxFjAUBgNVBAsTDUlUIERlcGFydG1lbnQxFDASBgNVBAMTC2V4YW1wbGUu 15 | Y29tggkA7P8MO0PZmBswDAYDVR0TBAUwAwEB/zAJBgcqhkjOPQQBA2cAMGQCMG+s 16 | XvXGnE08soRF4SpMbb/iFqItneTwWWahMiF5S3JZ7WDJ2B94bt1sR6QQj0UucwIw 17 | Lc2srRPo2yF1lpZ80P2fesUzW2OTHIdtrAvDt09UvCwNgNNb340SCuzXbuN8qrOF 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /message_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "bufio" 5 | "compress/gzip" 6 | "fmt" 7 | "os" 8 | "runtime" 9 | "testing" 10 | ) 11 | 12 | var messages []string 13 | 14 | func TestMain(m *testing.M) { 15 | messages = readLog("./test_resources/channel.txt.gz") 16 | os.Exit(m.Run()) 17 | } 18 | 19 | func BenchmarkParseBigLog(b *testing.B) { 20 | for n := 0; n < b.N; n++ { 21 | for _, line := range messages { 22 | ParseMessage(line) 23 | } 24 | } 25 | } 26 | 27 | func BenchmarkParseWHISPERMessage(b *testing.B) { 28 | testMessage := "@badges=;color=#00FF7F;display-name=Danielps1;emotes=;message-id=20;thread-id=32591953_77829817;turbo=0;user-id=32591953;user-type= :danielps1!danielps1@danielps1.tmi.twitch.tv WHISPER gempir :i like memes" 29 | for n := 0; n < b.N; n++ { 30 | ParseMessage(testMessage) 31 | } 32 | } 33 | 34 | func BenchmarkParseMessageType(b *testing.B) { 35 | testCommand := "RECONNECT" 36 | for n := 0; n < b.N; n++ { 37 | parseMessageType(testCommand) 38 | } 39 | } 40 | 41 | func readLog(logFile string) []string { 42 | f, err := os.Open(logFile) 43 | if err != nil { 44 | fmt.Println("logFile not found") 45 | runtime.Goexit() 46 | } 47 | defer f.Close() 48 | 49 | gz, err := gzip.NewReader(f) 50 | if err != nil { 51 | fmt.Println("logFile gzip not readable") 52 | runtime.Goexit() 53 | } 54 | 55 | scanner := bufio.NewScanner(gz) 56 | if err != nil { 57 | fmt.Println("logFile not readable") 58 | runtime.Goexit() 59 | } 60 | 61 | content := []string{} 62 | for scanner.Scan() { 63 | line := scanner.Text() 64 | content = append(content, line) 65 | } 66 | 67 | return content 68 | } 69 | -------------------------------------------------------------------------------- /ratelimit.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | type RateLimiter interface { 9 | // This will impact how go-twitch-irc groups joins together per IRC message 10 | GetLimit() int 11 | Throttle(count int) 12 | IsUnlimited() bool 13 | } 14 | 15 | type WindowRateLimiter struct { 16 | joinLimit int 17 | window []time.Time 18 | mutex sync.Mutex 19 | } 20 | 21 | const Unlimited = -1 22 | const TwitchRateLimitWindow = 10 * time.Second 23 | const windowRateLimiterSleepDuration = 100 * time.Millisecond 24 | 25 | func CreateDefaultRateLimiter() *WindowRateLimiter { 26 | return createRateLimiter(20) 27 | } 28 | 29 | func CreateVerifiedRateLimiter() *WindowRateLimiter { 30 | return createRateLimiter(2000) 31 | } 32 | 33 | func CreateUnlimitedRateLimiter() *WindowRateLimiter { 34 | return createRateLimiter(Unlimited) 35 | } 36 | 37 | func createRateLimiter(limit int) *WindowRateLimiter { 38 | var window []time.Time 39 | 40 | return &WindowRateLimiter{ 41 | joinLimit: limit, 42 | window: window, 43 | } 44 | } 45 | 46 | func (r *WindowRateLimiter) GetLimit() int { 47 | return r.joinLimit 48 | } 49 | 50 | func (r *WindowRateLimiter) Throttle(count int) { 51 | if r.joinLimit == Unlimited { 52 | return 53 | } 54 | r.mutex.Lock() 55 | newWindow := []time.Time{} 56 | 57 | for i := 0; i < len(r.window); i++ { 58 | if r.window[i].Add(TwitchRateLimitWindow).After(time.Now()) { 59 | newWindow = append(newWindow, r.window[i]) 60 | } 61 | } 62 | 63 | if r.joinLimit-len(newWindow) >= count || len(newWindow) == 0 { 64 | for i := 0; i < count; i++ { 65 | newWindow = append(newWindow, time.Now()) 66 | } 67 | r.window = newWindow 68 | r.mutex.Unlock() 69 | return 70 | } 71 | 72 | time.Sleep(time.Until(r.window[0].Add(TwitchRateLimitWindow).Add(windowRateLimiterSleepDuration))) 73 | 74 | r.mutex.Unlock() 75 | r.Throttle(count) 76 | } 77 | 78 | func (r *WindowRateLimiter) IsUnlimited() bool { 79 | return r.joinLimit == Unlimited 80 | } 81 | -------------------------------------------------------------------------------- /ratelimit_test.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestUnlimitedRateLimiter(t *testing.T) { 10 | limiter := CreateUnlimitedRateLimiter() 11 | 12 | if !limiter.IsUnlimited() { 13 | t.Fatal("Limited must be unlimited") 14 | } 15 | 16 | for i := 0; i < 5000; i++ { 17 | before := time.Now() 18 | limiter.Throttle(1) 19 | after := time.Now() 20 | 21 | diff := after.Sub(before) 22 | 23 | if diff >= windowRateLimiterSleepDuration { 24 | t.Fatal("No sleeping allowed in unlimited rate limiter") 25 | } 26 | } 27 | } 28 | 29 | func TestDefaultRateLimiter(t *testing.T) { 30 | limiter := CreateDefaultRateLimiter() 31 | 32 | if limiter.IsUnlimited() { 33 | t.Fatal("Limited must not be unlimited") 34 | } 35 | 36 | for i := 0; i < 20; i++ { 37 | before := time.Now() 38 | limiter.Throttle(1) 39 | after := time.Now() 40 | 41 | diff := after.Sub(before) 42 | 43 | if diff >= windowRateLimiterSleepDuration { 44 | t.Fatal("No sleeping should take place while we're within the rate limit") 45 | } 46 | } 47 | } 48 | 49 | func TestDefaultRateLimiterBigChunks(t *testing.T) { 50 | limiter := CreateDefaultRateLimiter() 51 | 52 | if limiter.IsUnlimited() { 53 | t.Fatal("Limited must not be unlimited") 54 | } 55 | 56 | for i := 0; i < 2; i++ { 57 | before := time.Now() 58 | limiter.Throttle(10) 59 | after := time.Now() 60 | 61 | diff := after.Sub(before) 62 | 63 | if diff >= windowRateLimiterSleepDuration { 64 | t.Fatal("No sleeping should take place while we're within the rate limit") 65 | } 66 | } 67 | } 68 | 69 | func TestDefaultRateLimiterMultiThread(t *testing.T) { 70 | limiter := CreateDefaultRateLimiter() 71 | 72 | wg := sync.WaitGroup{} 73 | 74 | sender := func(n int) { 75 | for i := 0; i < n; i++ { 76 | limiter.Throttle(1) 77 | } 78 | } 79 | 80 | before := time.Now() 81 | numWorkers := 2 82 | wg.Add(numWorkers) 83 | for i := 0; i < numWorkers; i++ { 84 | go func() { 85 | sender(10) 86 | wg.Done() 87 | }() 88 | } 89 | wg.Wait() 90 | after := time.Now() 91 | diff := after.Sub(before) 92 | 93 | if diff >= windowRateLimiterSleepDuration { 94 | t.Fatal("No sleeping allowed in unlimited rate limiter") 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /irc.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // Maximum supported length of an irc message 10 | const maxMessageLength = 510 11 | 12 | type ircMessage struct { 13 | Raw string 14 | Tags map[string]string 15 | Source ircMessageSource 16 | Command string 17 | Params []string 18 | } 19 | 20 | type ircMessageSource struct { 21 | Nickname string 22 | Username string 23 | Host string 24 | } 25 | 26 | func parseIRCMessage(line string) (*ircMessage, error) { 27 | message := ircMessage{ 28 | Raw: line, 29 | Tags: make(map[string]string), 30 | Params: []string{}, 31 | } 32 | 33 | split := strings.Split(line, " ") 34 | index := 0 35 | 36 | if strings.HasPrefix(split[index], "@") { 37 | message.Tags = parseIRCTags(split[index]) 38 | index++ 39 | } 40 | 41 | if index >= len(split) { 42 | return &message, fmt.Errorf("parseIRCMessage: partial message") 43 | } 44 | 45 | if strings.HasPrefix(split[index], ":") { 46 | message.Source = *parseIRCMessageSource(split[index]) 47 | index++ 48 | } 49 | 50 | if index >= len(split) { 51 | return &message, fmt.Errorf("parseIRCMessage: no command") 52 | } 53 | 54 | message.Command = split[index] 55 | index++ 56 | 57 | if index >= len(split) { 58 | return &message, nil 59 | } 60 | 61 | var params []string 62 | for i, v := range split[index:] { 63 | if strings.HasPrefix(v, ":") { 64 | v = strings.Join(split[index+i:], " ") 65 | v = strings.TrimPrefix(v, ":") 66 | params = append(params, v) 67 | break 68 | } 69 | 70 | params = append(params, v) 71 | } 72 | 73 | message.Params = params 74 | 75 | return &message, nil 76 | } 77 | 78 | func parseIRCTags(rawTags string) map[string]string { 79 | tags := make(map[string]string) 80 | 81 | rawTags = strings.TrimPrefix(rawTags, "@") 82 | 83 | for _, tag := range strings.Split(rawTags, ";") { 84 | pair := strings.SplitN(tag, "=", 2) 85 | key := pair[0] 86 | 87 | var value string 88 | if len(pair) == 2 { 89 | value = parseIRCTagValue(pair[1]) 90 | } 91 | 92 | tags[key] = value 93 | } 94 | 95 | return tags 96 | } 97 | 98 | var tagEscapeCharacters = []struct { 99 | from string 100 | to string 101 | }{ 102 | {`\s`, ` `}, 103 | {`\n`, ``}, 104 | {`\r`, ``}, 105 | {`\:`, `;`}, 106 | {`\\`, `\`}, 107 | } 108 | 109 | func parseIRCTagValue(rawValue string) string { 110 | for _, escape := range tagEscapeCharacters { 111 | rawValue = strings.ReplaceAll(rawValue, escape.from, escape.to) 112 | } 113 | 114 | rawValue = strings.TrimSuffix(rawValue, "\\") 115 | 116 | // Some Twitch values can end with a trailing \s 117 | // Example: "system-msg=An\sanonymous\suser\sgifted\sa\sTier\s1\ssub\sto\sTenureCalculator!\s" 118 | rawValue = strings.TrimSpace(rawValue) 119 | 120 | return rawValue 121 | } 122 | 123 | func parseIRCMessageSource(rawSource string) *ircMessageSource { 124 | var source ircMessageSource 125 | 126 | rawSource = strings.TrimPrefix(rawSource, ":") 127 | 128 | regex := regexp.MustCompile(`!|@`) 129 | split := regex.Split(rawSource, -1) 130 | 131 | if len(split) == 0 { 132 | return &source 133 | } 134 | 135 | switch len(split) { 136 | case 1: 137 | source.Host = split[0] 138 | case 2: 139 | // Getting 2 items extremely rare, but does happen sometimes. 140 | // https://github.com/gempir/go-twitch-irc/issues/109 141 | source.Nickname = split[0] 142 | source.Host = split[1] 143 | default: 144 | source.Nickname = split[0] 145 | source.Username = split[1] 146 | source.Host = split[2] 147 | } 148 | 149 | return &source 150 | } 151 | -------------------------------------------------------------------------------- /irc_test.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "testing" 8 | ) 9 | 10 | type ircTest struct { 11 | Input string 12 | Expected ircMessage 13 | } 14 | 15 | func ircTests() ([]*ircTest, error) { 16 | tests := struct { 17 | Tests []*ircTest 18 | }{} 19 | 20 | data, err := ioutil.ReadFile("test_resources/irctests.json") 21 | if err != nil { 22 | return nil, fmt.Errorf("ircTests: failed to read file: %s", err) 23 | } 24 | 25 | if err := json.Unmarshal(data, &tests); err != nil { 26 | return nil, fmt.Errorf("ircTests: failed to unmarshal data: %s", err) 27 | } 28 | 29 | return tests.Tests, nil 30 | } 31 | 32 | func TestCanParseIRCMessage(t *testing.T) { 33 | tests, err := ircTests() 34 | if err != nil { 35 | t.Error(err) 36 | return 37 | } 38 | 39 | for _, test := range tests { 40 | actual, err := parseIRCMessage(test.Input) 41 | if err != nil { 42 | t.Error(err) 43 | continue 44 | } 45 | 46 | assertStringMapsEqual(t, test.Expected.Tags, actual.Tags) 47 | assertStringsEqual(t, test.Expected.Source.Nickname, actual.Source.Nickname) 48 | assertStringsEqual(t, test.Expected.Source.Username, actual.Source.Username) 49 | assertStringsEqual(t, test.Expected.Source.Host, actual.Source.Host) 50 | assertStringsEqual(t, test.Expected.Command, actual.Command) 51 | assertStringSlicesEqual(t, test.Expected.Params, actual.Params) 52 | } 53 | } 54 | 55 | func TestCantParsePartialIRCMessage(t *testing.T) { 56 | testMessage := "@badges=;color=;display-name=ZZZi;emotes=;flags=;id=75bb6b6b-e36c-49af-a293-16024738ab92;mod=0;room-id=36029255;subscriber=0;tmi-sent-ts=1551476573570;turbo" 57 | 58 | actual, err := parseIRCMessage(testMessage) 59 | 60 | expectedTags := map[string]string{ 61 | "badges": "", 62 | "color": "", 63 | "display-name": "ZZZi", 64 | "emotes": "", 65 | "flags": "", 66 | "id": "75bb6b6b-e36c-49af-a293-16024738ab92", 67 | "mod": "0", 68 | "room-id": "36029255", 69 | "subscriber": "0", 70 | "tmi-sent-ts": "1551476573570", 71 | "turbo": "", 72 | } 73 | assertStringMapsEqual(t, expectedTags, actual.Tags) 74 | 75 | assertStringsEqual(t, "", actual.Source.Nickname) 76 | assertStringsEqual(t, "", actual.Source.Username) 77 | assertStringsEqual(t, "", actual.Source.Host) 78 | assertStringsEqual(t, "", actual.Command) 79 | assertStringSlicesEqual(t, nil, actual.Params) 80 | 81 | assertStringsEqual(t, "parseIRCMessage: partial message", err.Error()) 82 | } 83 | 84 | func TestCantParseNoCommandIRCMessage(t *testing.T) { 85 | testMessage := "@badges=;color=#00FF7F;display-name=Danielps1;emotes=;message-id=20;thread-id=32591953_77829817;turbo=0;user-id=32591953;user-type= :danielps1!danielps1@danielps1.tmi.twitch.tv" 86 | 87 | actual, err := parseIRCMessage(testMessage) 88 | 89 | expectedTags := map[string]string{ 90 | "badges": "", 91 | "color": "#00FF7F", 92 | "display-name": "Danielps1", 93 | "emotes": "", 94 | "message-id": "20", 95 | "thread-id": "32591953_77829817", 96 | "turbo": "0", 97 | "user-id": "32591953", 98 | "user-type": "", 99 | } 100 | assertStringMapsEqual(t, expectedTags, actual.Tags) 101 | 102 | assertStringsEqual(t, "danielps1", actual.Source.Nickname) 103 | assertStringsEqual(t, "danielps1", actual.Source.Username) 104 | assertStringsEqual(t, "danielps1.tmi.twitch.tv", actual.Source.Host) 105 | assertStringsEqual(t, "", actual.Command) 106 | assertStringSlicesEqual(t, nil, actual.Params) 107 | 108 | assertStringsEqual(t, "parseIRCMessage: no command", err.Error()) 109 | } 110 | -------------------------------------------------------------------------------- /helpers_test.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func assertStringsEqual(t *testing.T, expected, actual string) { 9 | if expected != actual { 10 | t.Errorf("failed asserting that \"%s\" is expected \"%s\"", actual, expected) 11 | } 12 | } 13 | 14 | func assertIntsEqual(t *testing.T, expected, actual int) { 15 | if expected != actual { 16 | t.Errorf("failed asserting that \"%d\" is expected \"%d\"", actual, expected) 17 | } 18 | } 19 | 20 | func assertInt32sEqual(t *testing.T, expected, actual int32) { 21 | if expected != actual { 22 | t.Errorf("failed asserting that \"%d\" is expected \"%d\"", actual, expected) 23 | } 24 | } 25 | 26 | func assertBoolEqual(t *testing.T, expected, actual bool) { 27 | if expected != actual { 28 | t.Errorf("failed asserting that \"%t\" is expected \"%t\"", actual, expected) 29 | } 30 | } 31 | 32 | func assertTrue(t *testing.T, actual bool, errorMessage string) { 33 | if !actual { 34 | t.Error(errorMessage) 35 | } 36 | } 37 | 38 | func assertFalse(t *testing.T, actual bool, errorMessage string) { 39 | if actual { 40 | t.Error(errorMessage) 41 | } 42 | } 43 | 44 | func assertStringSlicesEqual(t *testing.T, expected, actual []string) { 45 | if actual == nil { 46 | if expected == nil { 47 | return 48 | } 49 | 50 | t.Errorf("actual slice was nil") 51 | } 52 | 53 | if len(actual) != len(expected) { 54 | t.Errorf("actual slice(%#v)(%d) was not the same length as expected slice(%#v)(%d)", actual, len(actual), expected, len(expected)) 55 | } 56 | 57 | for i, v := range actual { 58 | if v != expected[i] { 59 | t.Errorf("actual slice value \"%s\" was not equal to expected value \"%s\" at index \"%d\"", v, expected[i], i) 60 | } 61 | } 62 | } 63 | 64 | func assertStringMapsEqual(t *testing.T, expected, actual map[string]string) { 65 | if actual == nil { 66 | if expected == nil { 67 | return 68 | } 69 | 70 | t.Errorf("actual map was nil") 71 | } 72 | 73 | if len(expected) != len(actual) { 74 | t.Errorf("actual map was not the same length as the expected map") 75 | } 76 | 77 | for key, want := range expected { 78 | got, ok := actual[key] 79 | if !ok { 80 | t.Errorf("actual map doesn't contain key \"%s\"", key) 81 | continue 82 | } 83 | 84 | if want != got { 85 | t.Errorf("actual map value \"%s\" was not equal to expected value \"%s\" in key \"%s\"", got, want, key) 86 | continue 87 | } 88 | } 89 | } 90 | 91 | func assertStringIntMapsEqual(t *testing.T, expected, actual map[string]int) { 92 | if actual == nil { 93 | t.Errorf("actual map was nil") 94 | } 95 | 96 | if len(expected) != len(actual) { 97 | t.Errorf("actual map was not the same length as the expected map") 98 | } 99 | 100 | for k, v := range expected { 101 | got, ok := actual[k] 102 | if !ok { 103 | t.Errorf("actual map doesn't contain key \"%s\"", k) 104 | continue 105 | } 106 | 107 | if v != got { 108 | t.Errorf("actual map value \"%d\" was not equal to expected value \"%d\" in key \"%s\"", got, v, k) 109 | continue 110 | } 111 | } 112 | } 113 | 114 | func assertErrorsEqual(t *testing.T, expected, actual error) { 115 | if expected != actual { 116 | t.Errorf("failed asserting that error \"%s\" is expected \"%s\"", actual, expected) 117 | } 118 | } 119 | 120 | func assertMessageTypesEqual(t *testing.T, expected, actual MessageType) { 121 | if expected != actual { 122 | t.Errorf("failed asserting that MessageType \"%d\" is expected \"%d\"", actual, expected) 123 | } 124 | } 125 | 126 | // formats a ping-signature (i.e. go-twitch-irc) into a full-fledged pong response (i.e. ":tmi.twitch.tv PONG tmi.twitch.tv :go-twitch-irc") 127 | func formatPong(signature string) string { 128 | return ":tmi.twitch.tv PONG tmi.twitch.tv :" + signature 129 | } 130 | 131 | type timedTestMessage struct { 132 | message string 133 | time time.Time 134 | } 135 | 136 | func assertJoinRateLimitRespected(t *testing.T, joinLimit int, joinMessages []timedTestMessage) { 137 | messageBuckets := make(map[string][]timedTestMessage) 138 | startBucketTime := joinMessages[0].time 139 | endBucketTime := startBucketTime.Add(TwitchRateLimitWindow) 140 | 141 | for _, msg := range joinMessages { 142 | if !msg.time.Before(endBucketTime) { 143 | startBucketTime = msg.time 144 | endBucketTime = startBucketTime.Add(TwitchRateLimitWindow) 145 | } 146 | 147 | key := startBucketTime.Format("15:04:05") + " -> " + endBucketTime.Format("15:04:05") 148 | messageBuckets[key] = append(messageBuckets[key], msg) 149 | } 150 | 151 | for key, bucket := range messageBuckets { 152 | if len(bucket) > joinLimit { 153 | t.Errorf("%s has %d joins", key, len(bucket)) 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # go-twitch-irc [![Coverage Status](https://coveralls.io/repos/github/gempir/go-twitch-irc/badge.svg?branch=master)](https://coveralls.io/github/gempir/go-twitch-irc?branch=master) 2 | 3 | This is an irc client for connecting to twitch. It handles the annoying stuff like irc tag parsing. 4 | I highly recommend reading the documentation below, but this readme gives a basic overview of the functionality. 5 | 6 | Documentation: https://pkg.go.dev/github.com/gempir/go-twitch-irc/v4?tab=doc 7 | 8 | ## Getting Started 9 | ```go 10 | package main 11 | 12 | import ( 13 | "fmt" 14 | 15 | "github.com/gempir/go-twitch-irc/v4" 16 | ) 17 | 18 | func main() { 19 | // or client := twitch.NewAnonymousClient() for an anonymous user (no write capabilities) 20 | client := twitch.NewClient("yourtwitchusername", "oauth:123123123") 21 | 22 | client.OnPrivateMessage(func(message twitch.PrivateMessage) { 23 | fmt.Println(message.Message) 24 | }) 25 | 26 | client.Join("gempir") 27 | 28 | err := client.Connect() 29 | if err != nil { 30 | panic(err) 31 | } 32 | } 33 | ``` 34 | ### Available Data 35 | 36 | The twitch.User and MessageType structs reflect the data Twitch provides, minus any fields that have been marked as deprecated: 37 | ```go 38 | type User struct { 39 | ID string 40 | Name string 41 | DisplayName string 42 | Color string 43 | Badges map[string]int 44 | IsBroadcaster bool 45 | IsMod bool 46 | IsVip bool 47 | } 48 | 49 | type WhisperMessage struct { 50 | User User 51 | 52 | Raw string 53 | Type MessageType 54 | RawType string 55 | Tags map[string]string 56 | Message string 57 | Target string 58 | MessageID string 59 | ThreadID string 60 | Emotes []*Emote 61 | Action bool 62 | } 63 | 64 | type PrivateMessage struct { 65 | User User 66 | 67 | Raw string 68 | Type MessageType 69 | RawType string 70 | Tags map[string]string 71 | Message string 72 | Channel string 73 | RoomID string 74 | ID string 75 | Time time.Time 76 | Emotes []*Emote 77 | Bits int 78 | Action bool 79 | FirstMessage bool 80 | Reply *Reply 81 | Source *Source 82 | CustomRewardID string 83 | } 84 | 85 | type Reply struct { 86 | ParentMsgID string 87 | ParentUserID string 88 | ParentUserLogin string 89 | ParentDisplayName string 90 | ParentMsgBody string 91 | } 92 | 93 | type Source struct { 94 | RoomID string 95 | ID string 96 | Badges map[string]int 97 | SourceOnly bool 98 | } 99 | 100 | type ClearChatMessage struct { 101 | Raw string 102 | Type MessageType 103 | RawType string 104 | Tags map[string]string 105 | Message string 106 | Channel string 107 | RoomID string 108 | Time time.Time 109 | BanDuration int 110 | TargetUserID string 111 | TargetUsername string 112 | } 113 | 114 | type ClearMessage struct { 115 | Raw string 116 | Type MessageType 117 | RawType string 118 | Tags map[string]string 119 | Message string 120 | Channel string 121 | Login string 122 | TargetMsgID string 123 | } 124 | 125 | type RoomStateMessage struct { 126 | Raw string 127 | Type MessageType 128 | RawType string 129 | Tags map[string]string 130 | Message string 131 | Channel string 132 | RoomID string 133 | State map[string]int 134 | } 135 | 136 | type UserNoticeMessage struct { 137 | User User 138 | 139 | Raw string 140 | Type MessageType 141 | RawType string 142 | Tags map[string]string 143 | Message string 144 | Channel string 145 | RoomID string 146 | ID string 147 | Time time.Time 148 | Emotes []*Emote 149 | MsgID string 150 | MsgParams map[string]string 151 | SystemMsg string 152 | } 153 | 154 | type UserStateMessage struct { 155 | User User 156 | 157 | Raw string 158 | Type MessageType 159 | RawType string 160 | Tags map[string]string 161 | Message string 162 | Channel string 163 | EmoteSets []string 164 | } 165 | 166 | type GlobalUserStateMessage struct { 167 | User User 168 | 169 | Raw string 170 | Type MessageType 171 | RawType string 172 | Tags map[string]string 173 | EmoteSets []string 174 | } 175 | 176 | type NoticeMessage struct { 177 | Raw string 178 | Type MessageType 179 | RawType string 180 | Tags map[string]string 181 | Message string 182 | Channel string 183 | MsgID string 184 | } 185 | 186 | type UserJoinMessage struct { 187 | // Channel name 188 | Channel string 189 | 190 | // User name 191 | User string 192 | } 193 | 194 | type UserPartMessage struct { 195 | // Channel name 196 | Channel string 197 | 198 | // User name 199 | User string 200 | } 201 | ``` 202 | 203 | For unsupported message types, we return RawMessage: 204 | ```go 205 | type RawMessage struct { 206 | Raw string 207 | Type MessageType 208 | RawType string 209 | Tags map[string]string 210 | Message string 211 | } 212 | ``` 213 | 214 | ### Available Methods 215 | 216 | ParseMessage parses a raw Twitch IRC message into a User and a message object. User can be nil. 217 | 218 | ```go 219 | func ParseMessage(line string) (*User, interface{}) 220 | ``` 221 | 222 | ### Client Methods 223 | 224 | These are the available methods of the client so you can get your bot going: 225 | 226 | ```go 227 | func (c *Client) Say(channel, text string) 228 | func (c *Client) Join(channel string) 229 | func (c *Client) Depart(channel string) 230 | func (c *Client) Userlist(channel string) ([]string, error) 231 | func (c *Client) Connect() error 232 | func (c *Client) Disconnect() error 233 | func (c *Client) Latency() (latency time.Duration, err error) 234 | ``` 235 | 236 | ### Options 237 | 238 | On your client you can configure multiple options: 239 | ```go 240 | client.IrcAddress = "127.0.0.1:3030" // for custom irc server 241 | client.TLS = false // enabled by default, will connect to non TLS server of twitch when off or the given client.IrcAddress 242 | client.SetupCmd = "LOGIN custom_command_here" // Send a custom command on successful IRC connection, before authentication. 243 | client.Capabilities = []string{twitch.TagsCapability, twitch.CommandsCapability} // Customize which capabilities are sent 244 | client.SetJoinRateLimiter(twitch.CreateVerifiedRateLimiter()) // If you have a verified bot or other needs use this to set a custom rate limiter 245 | ``` 246 | 247 | Option modifications must be done before calling Connect on the client. 248 | 249 | #### Capabilities 250 | 251 | By default, the client sends along these capabilities ([Tags](https://dev.twitch.tv/docs/irc/tags), [Commands](https://dev.twitch.tv/docs/irc/commands)). 252 | 253 | ### Callbacks 254 | 255 | These callbacks are available to pass to the client: 256 | ```go 257 | client.OnConnect(func() {}) 258 | client.OnPrivateMessage(func(message PrivateMessage) {}) 259 | client.OnWhisperMessage(func(message WhisperMessage) {}) 260 | client.OnClearChatMessage(func(message ClearChatMessage) {}) 261 | client.OnClearMessage(func(message ClearMessage) {}) 262 | client.OnRoomStateMessage(func(message RoomStateMessage) {}) 263 | client.OnUserNoticeMessage(func(message UserNoticeMessage) {}) 264 | client.OnUserStateMessage(func(message UserStateMessage) {}) 265 | client.OnGlobalUserStateMessage(func(message GlobalUserStateMessage) {}) 266 | client.OnNoticeMessage(func(message NoticeMessage) {}) 267 | client.OnUserJoinMessage(func(message UserJoinMessage) {}) 268 | client.OnUserPartMessage(func(message UserPartMessage) {}) 269 | client.OnSelfJoinMessage(func(message UserJoinMessage) {}) 270 | client.OnSelfPartMessage(func(message UserPartMessage) {}) 271 | ``` 272 | 273 | ### Message Types 274 | 275 | If you ever need more than basic PRIVMSG, this might be for you. 276 | These are the message types currently supported: 277 | 278 | WHISPER 279 | PRIVMSG 280 | CLEARCHAT 281 | CLEARMSG 282 | ROOMSTATE 283 | USERNOTICE 284 | USERSTATE 285 | GLOBALUSERSTATE 286 | NOTICE 287 | JOIN 288 | PART 289 | -------------------------------------------------------------------------------- /test_resources/irctests.json: -------------------------------------------------------------------------------- 1 | { 2 | "tests": [ 3 | { 4 | "Input": "foo bar baz asdf", 5 | "Expected": { 6 | "command": "foo", 7 | "params": [ 8 | "bar", 9 | "baz", 10 | "asdf" 11 | ] 12 | } 13 | }, 14 | { 15 | "Input": ":coolguy foo bar baz asdf", 16 | "Expected": { 17 | "source": { 18 | "host": "coolguy" 19 | }, 20 | "command": "foo", 21 | "params": [ 22 | "bar", 23 | "baz", 24 | "asdf" 25 | ] 26 | } 27 | }, 28 | { 29 | "Input": "foo bar baz :asdf quux", 30 | "Expected": { 31 | "command": "foo", 32 | "params": [ 33 | "bar", 34 | "baz", 35 | "asdf quux" 36 | ] 37 | } 38 | }, 39 | { 40 | "Input": "foo bar baz :", 41 | "Expected": { 42 | "command": "foo", 43 | "params": [ 44 | "bar", 45 | "baz", 46 | "" 47 | ] 48 | } 49 | }, 50 | { 51 | "Input": "foo bar baz ::asdf", 52 | "Expected": { 53 | "command": "foo", 54 | "params": [ 55 | "bar", 56 | "baz", 57 | ":asdf" 58 | ] 59 | } 60 | }, 61 | { 62 | "Input": ":coolguy foo bar baz :asdf quux", 63 | "Expected": { 64 | "source": { 65 | "host": "coolguy" 66 | }, 67 | "command": "foo", 68 | "params": [ 69 | "bar", 70 | "baz", 71 | "asdf quux" 72 | ] 73 | } 74 | }, 75 | { 76 | "Input": ":coolguy foo bar baz : asdf quux ", 77 | "Expected": { 78 | "source": { 79 | "host": "coolguy" 80 | }, 81 | "command": "foo", 82 | "params": [ 83 | "bar", 84 | "baz", 85 | " asdf quux " 86 | ] 87 | } 88 | }, 89 | { 90 | "Input": ":coolguy PRIVMSG bar :lol :) ", 91 | "Expected": { 92 | "source": { 93 | "host": "coolguy" 94 | }, 95 | "command": "PRIVMSG", 96 | "params": [ 97 | "bar", 98 | "lol :) " 99 | ] 100 | } 101 | }, 102 | { 103 | "Input": ":coolguy foo bar baz :", 104 | "Expected": { 105 | "source": { 106 | "host": "coolguy" 107 | }, 108 | "command": "foo", 109 | "params": [ 110 | "bar", 111 | "baz", 112 | "" 113 | ] 114 | } 115 | }, 116 | { 117 | "Input": ":coolguy foo bar baz : ", 118 | "Expected": { 119 | "source": { 120 | "host": "coolguy" 121 | }, 122 | "command": "foo", 123 | "params": [ 124 | "bar", 125 | "baz", 126 | " " 127 | ] 128 | } 129 | }, 130 | { 131 | "Input": "@a=b;c=32;k;rt=ql7 foo", 132 | "Expected": { 133 | "command": "foo", 134 | "tags": { 135 | "a": "b", 136 | "c": "32", 137 | "k": null, 138 | "rt": "ql7" 139 | } 140 | } 141 | }, 142 | { 143 | "Input": "@a=b\\\\and\\nk;c=72\\s45;d=gh\\:764 foo", 144 | "Expected": { 145 | "command": "foo", 146 | "tags": { 147 | "a": "b\\andk", 148 | "c": "72 45", 149 | "d": "gh;764" 150 | }, 151 | "params": [] 152 | } 153 | }, 154 | { 155 | "Input": "@c;h=;a=b :quux ab cd", 156 | "Expected": { 157 | "tags": { 158 | "c": null, 159 | "h": "", 160 | "a": "b" 161 | }, 162 | "source": { 163 | "host": "quux" 164 | }, 165 | "command": "ab", 166 | "params": [ 167 | "cd" 168 | ] 169 | } 170 | }, 171 | { 172 | "Input": ":src JOIN #chan", 173 | "Expected": { 174 | "source": { 175 | "host": "src" 176 | }, 177 | "command": "JOIN", 178 | "params": [ 179 | "#chan" 180 | ] 181 | } 182 | }, 183 | { 184 | "Input": ":src JOIN :#chan", 185 | "Expected": { 186 | "source": { 187 | "host": "src" 188 | }, 189 | "command": "JOIN", 190 | "params": [ 191 | "#chan" 192 | ] 193 | } 194 | }, 195 | { 196 | "Input": ":src AWAY", 197 | "Expected": { 198 | "source": { 199 | "host": "src" 200 | }, 201 | "command": "AWAY" 202 | } 203 | }, 204 | { 205 | "Input": ":src AWAY ", 206 | "Expected": { 207 | "source": { 208 | "host": "src" 209 | }, 210 | "command": "AWAY", 211 | "params": [ 212 | "" 213 | ] 214 | } 215 | }, 216 | { 217 | "Input": ":cool\tguy foo bar baz", 218 | "Expected": { 219 | "source": { 220 | "host": "cool\tguy" 221 | }, 222 | "command": "foo", 223 | "params": [ 224 | "bar", 225 | "baz" 226 | ] 227 | } 228 | }, 229 | { 230 | "Input": ":coolguy!ag@net\u00035w\u0003ork.admin PRIVMSG foo :bar baz", 231 | "Expected": { 232 | "source": { 233 | "nickname": "coolguy", 234 | "username": "ag", 235 | "host": "net\u00035w\u0003ork.admin" 236 | }, 237 | "command": "PRIVMSG", 238 | "params": [ 239 | "foo", 240 | "bar baz" 241 | ] 242 | } 243 | }, 244 | { 245 | "Input": ":coolguy!~ag@n\u0002et\u000305w\u000fork.admin PRIVMSG foo :bar baz", 246 | "Expected": { 247 | "source": { 248 | "nickname": "coolguy", 249 | "username": "~ag", 250 | "host": "n\u0002et\u000305w\u000fork.admin" 251 | }, 252 | "command": "PRIVMSG", 253 | "params": [ 254 | "foo", 255 | "bar baz" 256 | ] 257 | } 258 | }, 259 | { 260 | "Input": "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 :irc.example.com COMMAND param1 param2 :param3 param3", 261 | "Expected": { 262 | "tags": { 263 | "tag1": "value1", 264 | "tag2": null, 265 | "vendor1/tag3": "value2", 266 | "vendor2/tag4": null 267 | }, 268 | "source": { 269 | "host": "irc.example.com" 270 | }, 271 | "command": "COMMAND", 272 | "params": [ 273 | "param1", 274 | "param2", 275 | "param3 param3" 276 | ] 277 | } 278 | }, 279 | { 280 | "Input": ":irc.example.com COMMAND param1 param2 :param3 param3", 281 | "Expected": { 282 | "source": { 283 | "host": "irc.example.com" 284 | }, 285 | "command": "COMMAND", 286 | "params": [ 287 | "param1", 288 | "param2", 289 | "param3 param3" 290 | ] 291 | } 292 | }, 293 | { 294 | "Input": "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 COMMAND param1 param2 :param3 param3", 295 | "Expected": { 296 | "tags": { 297 | "tag1": "value1", 298 | "tag2": null, 299 | "vendor1/tag3": "value2", 300 | "vendor2/tag4": null 301 | }, 302 | "command": "COMMAND", 303 | "params": [ 304 | "param1", 305 | "param2", 306 | "param3 param3" 307 | ] 308 | } 309 | }, 310 | { 311 | "Input": "COMMAND", 312 | "Expected": { 313 | "command": "COMMAND" 314 | } 315 | }, 316 | { 317 | "Input": "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND", 318 | "Expected": { 319 | "tags": { 320 | "foo": "\\\\;\\" 321 | }, 322 | "command": "COMMAND" 323 | } 324 | }, 325 | { 326 | "Input": ":gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters", 327 | "Expected": { 328 | "source": { 329 | "host": "gravel.mozilla.org" 330 | }, 331 | "command": "432", 332 | "params": [ 333 | "", 334 | "#momo", 335 | "Erroneous Nickname: Illegal characters" 336 | ] 337 | } 338 | }, 339 | { 340 | "Input": ":gravel.mozilla.org MODE #tckk +n ", 341 | "Expected": { 342 | "source": { 343 | "host": "gravel.mozilla.org" 344 | }, 345 | "command": "MODE", 346 | "params": [ 347 | "#tckk", 348 | "+n", 349 | "" 350 | ] 351 | } 352 | }, 353 | { 354 | "Input": ":services.esper.net MODE #foo-bar +o foobar ", 355 | "Expected": { 356 | "source": { 357 | "host": "services.esper.net" 358 | }, 359 | "command": "MODE", 360 | "params": [ 361 | "#foo-bar", 362 | "+o", 363 | "foobar", 364 | "", 365 | "" 366 | ] 367 | } 368 | }, 369 | { 370 | "Input": "@tag1=value\\\\ntest COMMAND", 371 | "Expected": { 372 | "tags": { 373 | "tag1": "value\\test" 374 | }, 375 | "command": "COMMAND" 376 | } 377 | }, 378 | { 379 | "Input": "@tag1=value\\1 COMMAND", 380 | "Expected": { 381 | "tags": { 382 | "tag1": "value\\1" 383 | }, 384 | "command": "COMMAND" 385 | } 386 | }, 387 | { 388 | "Input": "@tag1=value1\\ COMMAND", 389 | "Expected": { 390 | "tags": { 391 | "tag1": "value1" 392 | }, 393 | "command": "COMMAND" 394 | } 395 | }, 396 | { 397 | "Input": ":leppunen@tmi.twitch.tv PRIVMSG #pajlada :LUL", 398 | "Expected": { 399 | "source": { 400 | "nickname": "leppunen", 401 | "username": "", 402 | "host": "tmi.twitch.tv" 403 | }, 404 | "command": "PRIVMSG", 405 | "params": [ 406 | "#pajlada", 407 | "LUL" 408 | ] 409 | } 410 | } 411 | ] 412 | } 413 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | // ADDING A NEW MESSAGE TYPE: 10 | // 1. Add the message type at the bottom of the MessageType "const enum", with a unique index 11 | // 2. Add a function at the bottom of file in the "parseXXXMessage" format, that parses your message type and returns a Message 12 | // 3. Register the message into the map in the init function, specifying the message type value and the parser you just made 13 | 14 | // MessageType different message types possible to receive via IRC 15 | type MessageType int 16 | 17 | const ( 18 | // UNSET is for message types we currently don't support 19 | UNSET MessageType = -1 20 | // WHISPER private messages 21 | WHISPER MessageType = 0 22 | // PRIVMSG standard chat message 23 | PRIVMSG MessageType = 1 24 | // CLEARCHAT timeout messages 25 | CLEARCHAT MessageType = 2 26 | // ROOMSTATE changes like sub mode 27 | ROOMSTATE MessageType = 3 28 | // USERNOTICE messages like subs, resubs, raids, etc 29 | USERNOTICE MessageType = 4 30 | // USERSTATE messages 31 | USERSTATE MessageType = 5 32 | // NOTICE messages like sub mode, host on 33 | NOTICE MessageType = 6 34 | // JOIN whenever a user joins a channel 35 | JOIN MessageType = 7 36 | // PART whenever a user parts from a channel 37 | PART MessageType = 8 38 | // RECONNECT is sent from Twitch when they request the client to reconnect (i.e. for an irc server restart) 39 | // https://dev.twitch.tv/docs/irc/commands/#reconnect-twitch-commands 40 | RECONNECT MessageType = 9 41 | // NAMES (or 353 https://www.alien.net.au/irc/irc2numerics.html#353) is the response sent from the server when 42 | // the client requests a list of names for a channel 43 | NAMES MessageType = 10 44 | // PING is a message that can be sent from the IRC server. go-twitch-irc responds to PINGs automatically 45 | PING MessageType = 11 46 | // PONG is a message that should be sent from the IRC server as a response to us sending a PING message. 47 | PONG MessageType = 12 48 | // CLEARMSG whenever a single message is deleted 49 | CLEARMSG MessageType = 13 50 | // GLOBALUSERSTATE On successful login, provides data about the current logged-in user through IRC tags 51 | GLOBALUSERSTATE MessageType = 14 52 | ) 53 | 54 | type messageTypeDescription struct { 55 | Type MessageType 56 | Parser func(*ircMessage) Message 57 | } 58 | 59 | var messageTypeMap map[string]messageTypeDescription 60 | 61 | func init() { 62 | messageTypeMap = map[string]messageTypeDescription{ 63 | "WHISPER": {WHISPER, parseWhisperMessage}, 64 | "PRIVMSG": {PRIVMSG, parsePrivateMessage}, 65 | "CLEARCHAT": {CLEARCHAT, parseClearChatMessage}, 66 | "ROOMSTATE": {ROOMSTATE, parseRoomStateMessage}, 67 | "USERNOTICE": {USERNOTICE, parseUserNoticeMessage}, 68 | "USERSTATE": {USERSTATE, parseUserStateMessage}, 69 | "NOTICE": {NOTICE, parseNoticeMessage}, 70 | "JOIN": {JOIN, parseUserJoinMessage}, 71 | "PART": {PART, parseUserPartMessage}, 72 | "RECONNECT": {RECONNECT, parseReconnectMessage}, 73 | "353": {NAMES, parseNamesMessage}, 74 | "PING": {PING, parsePingMessage}, 75 | "PONG": {PONG, parsePongMessage}, 76 | "CLEARMSG": {CLEARMSG, parseClearMessage}, 77 | "GLOBALUSERSTATE": {GLOBALUSERSTATE, parseGlobalUserStateMessage}, 78 | } 79 | } 80 | 81 | // EmotePosition is a single position of an emote to be used for text replacement. 82 | type EmotePosition struct { 83 | Start int 84 | End int 85 | } 86 | 87 | // Emote twitch emotes 88 | type Emote struct { 89 | Name string 90 | ID string 91 | Count int 92 | Positions []EmotePosition 93 | } 94 | 95 | // ParseMessage parse a raw Twitch IRC message 96 | func ParseMessage(line string) Message { 97 | // Uncomment this and recoverMessage if debugging a message that crashes the parser 98 | // defer recoverMessage(line) 99 | 100 | ircMessage, err := parseIRCMessage(line) 101 | if err != nil { 102 | return parseRawMessage(ircMessage) 103 | } 104 | 105 | if mt, ok := messageTypeMap[ircMessage.Command]; ok { 106 | return mt.Parser(ircMessage) 107 | } 108 | 109 | return parseRawMessage(ircMessage) 110 | } 111 | 112 | // func recoverMessage(line string) { 113 | // if err := recover(); err != nil { 114 | // log.Println(line) 115 | // log.Println(err) 116 | // log.Println(string(debug.Stack())) 117 | // } 118 | // } 119 | 120 | // parseMessageType parses a message type from an irc COMMAND string 121 | func parseMessageType(messageType string) MessageType { 122 | if mt, ok := messageTypeMap[messageType]; ok { 123 | return mt.Type 124 | } 125 | 126 | return UNSET 127 | } 128 | 129 | func parseUser(message *ircMessage) User { 130 | var ( 131 | isBroadcaster bool 132 | isMod bool 133 | isVip bool 134 | ) 135 | 136 | // https://dev.twitch.tv/docs/chat/irc/#privmsg-tags 137 | isBroadcaster = message.Tags["user-id"] == message.Tags["room-id"] 138 | _, isVip = message.Tags["vip"] 139 | if value, tagFound := message.Tags["mod"]; tagFound { 140 | isMod = value == "1" 141 | } 142 | 143 | user := User{ 144 | ID: message.Tags["user-id"], 145 | Name: message.Source.Username, 146 | DisplayName: message.Tags["display-name"], 147 | Color: message.Tags["color"], 148 | Badges: make(map[string]int), 149 | IsBroadcaster: isBroadcaster, 150 | IsMod: isMod, 151 | IsVip: isVip, 152 | } 153 | 154 | if rawBadges := message.Tags["badges"]; rawBadges != "" { 155 | user.Badges = parseBadges(rawBadges) 156 | } 157 | 158 | // USERSTATE doesn't contain a Username, but it does have a display-name tag 159 | if user.Name == "" && user.DisplayName != "" { 160 | user.Name = strings.ToLower(user.DisplayName) 161 | user.Name = strings.Replace(user.Name, " ", "", 1) 162 | } 163 | 164 | return user 165 | } 166 | 167 | func parseBadges(rawBadges string) map[string]int { 168 | badges := make(map[string]int) 169 | 170 | if rawBadges == "" { 171 | return badges 172 | } 173 | 174 | for _, badge := range strings.Split(rawBadges, ",") { 175 | pair := strings.SplitN(badge, "/", 2) 176 | badges[pair[0]], _ = strconv.Atoi(pair[1]) 177 | } 178 | 179 | return badges 180 | } 181 | 182 | func parseRawMessage(message *ircMessage) *RawMessage { 183 | rawMessage := RawMessage{ 184 | Raw: message.Raw, 185 | Type: parseMessageType(message.Command), 186 | RawType: message.Command, 187 | Tags: message.Tags, 188 | } 189 | 190 | for i, v := range message.Params { 191 | if !strings.Contains(v, "#") { 192 | rawMessage.Message = strings.Join(message.Params[i:], " ") 193 | break 194 | } 195 | } 196 | 197 | return &rawMessage 198 | } 199 | 200 | func parseWhisperMessage(message *ircMessage) Message { 201 | whisperMessage := WhisperMessage{ 202 | User: parseUser(message), 203 | 204 | Raw: message.Raw, 205 | Type: parseMessageType(message.Command), 206 | RawType: message.Command, 207 | Tags: message.Tags, 208 | MessageID: message.Tags["message-id"], 209 | ThreadID: message.Tags["thread-id"], 210 | } 211 | 212 | if len(message.Params) == 2 { 213 | whisperMessage.Message = message.Params[1] 214 | } 215 | 216 | whisperMessage.Target = message.Params[0] 217 | 218 | if strings.Contains(whisperMessage.Message, "/me") { 219 | whisperMessage.Message = strings.TrimPrefix(whisperMessage.Message, "/me") 220 | whisperMessage.Action = true 221 | } 222 | 223 | whisperMessage.Emotes = parseEmotes(message.Tags["emotes"], whisperMessage.Message) 224 | 225 | return &whisperMessage 226 | } 227 | 228 | func parsePrivateMessage(message *ircMessage) Message { 229 | var reply *Reply 230 | if _, ok := message.Tags["reply-parent-msg-id"]; ok { 231 | reply = &Reply{ 232 | ParentMsgID: message.Tags["reply-parent-msg-id"], 233 | ParentUserID: message.Tags["reply-parent-user-id"], 234 | ParentUserLogin: message.Tags["reply-parent-user-login"], 235 | ParentDisplayName: message.Tags["reply-parent-display-name"], 236 | ParentMsgBody: message.Tags["reply-parent-msg-body"], 237 | } 238 | } 239 | 240 | var source *Source 241 | if _, ok := message.Tags["source-id"]; ok { 242 | source = &Source{ 243 | RoomID: message.Tags["source-room-id"], 244 | ID: message.Tags["source-id"], 245 | Badges: parseBadges(message.Tags["source-badges"]), 246 | SourceOnly: false, 247 | } 248 | if _, ok := message.Tags["source-only"]; ok { 249 | source.SourceOnly = message.Tags["source-only"] == "1" 250 | } 251 | } 252 | 253 | privateMessage := PrivateMessage{ 254 | User: parseUser(message), 255 | 256 | Raw: message.Raw, 257 | Type: parseMessageType(message.Command), 258 | RawType: message.Command, 259 | Tags: message.Tags, 260 | RoomID: message.Tags["room-id"], 261 | ID: message.Tags["id"], 262 | Time: parseTime(message.Tags["tmi-sent-ts"]), 263 | Reply: reply, 264 | Source: source, 265 | CustomRewardID: message.Tags["custom-reward-id"], 266 | } 267 | 268 | if len(message.Params) == 2 { 269 | privateMessage.Message = message.Params[1] 270 | } 271 | 272 | privateMessage.Channel = strings.TrimPrefix(message.Params[0], "#") 273 | 274 | rawBits, ok := message.Tags["bits"] 275 | if ok { 276 | bits, _ := strconv.Atoi(rawBits) 277 | privateMessage.Bits = bits 278 | } 279 | 280 | text := privateMessage.Message 281 | if strings.HasPrefix(text, "\u0001ACTION") && strings.HasSuffix(text, "\u0001") { 282 | privateMessage.Action = true 283 | if len(text) == 8 { 284 | privateMessage.Message = "" 285 | } else { 286 | privateMessage.Message = text[8 : len(text)-1] 287 | } 288 | } 289 | 290 | privateMessage.Emotes = parseEmotes(message.Tags["emotes"], privateMessage.Message) 291 | 292 | firstMessage, ok := message.Tags["first-msg"] 293 | 294 | if ok { 295 | privateMessage.FirstMessage = firstMessage == "1" 296 | } 297 | 298 | return &privateMessage 299 | } 300 | 301 | func parseClearChatMessage(message *ircMessage) Message { 302 | clearChatMessage := ClearChatMessage{ 303 | Raw: message.Raw, 304 | Type: parseMessageType(message.Command), 305 | RawType: message.Command, 306 | Tags: message.Tags, 307 | RoomID: message.Tags["room-id"], 308 | Time: parseTime(message.Tags["tmi-sent-ts"]), 309 | TargetUserID: message.Tags["target-user-id"], 310 | } 311 | 312 | clearChatMessage.Channel = strings.TrimPrefix(message.Params[0], "#") 313 | 314 | rawBanDuration, ok := message.Tags["ban-duration"] 315 | if ok { 316 | duration, _ := strconv.Atoi(rawBanDuration) 317 | clearChatMessage.BanDuration = duration 318 | } 319 | 320 | if len(message.Params) == 2 { 321 | clearChatMessage.TargetUsername = message.Params[1] 322 | } 323 | 324 | return &clearChatMessage 325 | } 326 | 327 | func parseClearMessage(message *ircMessage) Message { 328 | clearMessage := ClearMessage{ 329 | Raw: message.Raw, 330 | Type: parseMessageType(message.Command), 331 | RawType: message.Command, 332 | Tags: message.Tags, 333 | Login: message.Tags["login"], 334 | TargetMsgID: message.Tags["target-msg-id"], 335 | } 336 | 337 | if len(message.Params) == 2 { 338 | clearMessage.Message = message.Params[1] 339 | } 340 | 341 | clearMessage.Channel = strings.TrimPrefix(message.Params[0], "#") 342 | 343 | return &clearMessage 344 | } 345 | 346 | func parseRoomStateMessage(message *ircMessage) Message { 347 | roomStateMessage := RoomStateMessage{ 348 | Raw: message.Raw, 349 | Type: parseMessageType(message.Command), 350 | RawType: message.Command, 351 | Tags: message.Tags, 352 | RoomID: message.Tags["room-id"], 353 | State: make(map[string]int), 354 | } 355 | 356 | roomStateMessage.Channel = strings.TrimPrefix(message.Params[0], "#") 357 | 358 | stateTags := []string{"emote-only", "followers-only", "r9k", "rituals", "slow", "subs-only"} 359 | for _, tag := range stateTags { 360 | rawValue, ok := message.Tags[tag] 361 | if !ok { 362 | continue 363 | } 364 | 365 | value, _ := strconv.Atoi(rawValue) 366 | roomStateMessage.State[tag] = value 367 | } 368 | 369 | return &roomStateMessage 370 | } 371 | 372 | func parseGlobalUserStateMessage(message *ircMessage) Message { 373 | globalUserStateMessage := GlobalUserStateMessage{ 374 | Raw: message.Raw, 375 | Type: parseMessageType(message.Command), 376 | RawType: message.Command, 377 | Tags: message.Tags, 378 | User: parseUser(message), 379 | EmoteSets: parseEmoteSets(message), 380 | } 381 | 382 | return &globalUserStateMessage 383 | } 384 | 385 | func parseUserNoticeMessage(message *ircMessage) Message { 386 | userNoticeMessage := UserNoticeMessage{ 387 | User: parseUser(message), 388 | 389 | Raw: message.Raw, 390 | Type: parseMessageType(message.Command), 391 | RawType: message.Command, 392 | Tags: message.Tags, 393 | RoomID: message.Tags["room-id"], 394 | ID: message.Tags["id"], 395 | Time: parseTime(message.Tags["tmi-sent-ts"]), 396 | MsgID: message.Tags["msg-id"], 397 | MsgParams: make(map[string]string), 398 | SystemMsg: message.Tags["system-msg"], 399 | } 400 | 401 | if len(message.Params) == 2 { 402 | userNoticeMessage.Message = message.Params[1] 403 | } 404 | 405 | userNoticeMessage.Channel = strings.TrimPrefix(message.Params[0], "#") 406 | userNoticeMessage.Emotes = parseEmotes(message.Tags["emotes"], userNoticeMessage.Message) 407 | 408 | for tag, value := range message.Tags { 409 | if strings.Contains(tag, "msg-param") { 410 | userNoticeMessage.MsgParams[tag] = value 411 | } 412 | } 413 | 414 | return &userNoticeMessage 415 | } 416 | 417 | func parseUserStateMessage(message *ircMessage) Message { 418 | userStateMessage := UserStateMessage{ 419 | User: parseUser(message), 420 | 421 | Raw: message.Raw, 422 | Type: parseMessageType(message.Command), 423 | RawType: message.Command, 424 | Tags: message.Tags, 425 | Channel: strings.TrimPrefix(message.Params[0], "#"), 426 | EmoteSets: parseEmoteSets(message), 427 | } 428 | 429 | return &userStateMessage 430 | } 431 | 432 | func parseNoticeMessage(message *ircMessage) Message { 433 | noticeMessage := NoticeMessage{ 434 | Raw: message.Raw, 435 | Type: parseMessageType(message.Command), 436 | RawType: message.Command, 437 | Tags: message.Tags, 438 | MsgID: message.Tags["msg-id"], 439 | } 440 | 441 | if len(message.Params) == 2 { 442 | noticeMessage.Message = message.Params[1] 443 | } 444 | 445 | noticeMessage.Channel = strings.TrimPrefix(message.Params[0], "#") 446 | 447 | return ¬iceMessage 448 | } 449 | 450 | func parseUserJoinMessage(message *ircMessage) Message { 451 | parsedMessage := UserJoinMessage{ 452 | Raw: message.Raw, 453 | Type: parseMessageType(message.Command), 454 | RawType: message.Command, 455 | 456 | User: message.Source.Username, 457 | } 458 | 459 | if len(message.Params) == 1 { 460 | parsedMessage.Channel = strings.TrimPrefix(message.Params[0], "#") 461 | } 462 | 463 | return &parsedMessage 464 | } 465 | 466 | func parseUserPartMessage(message *ircMessage) Message { 467 | parsedMessage := UserPartMessage{ 468 | Raw: message.Raw, 469 | Type: parseMessageType(message.Command), 470 | RawType: message.Command, 471 | 472 | User: message.Source.Username, 473 | } 474 | 475 | if len(message.Params) == 1 { 476 | parsedMessage.Channel = strings.TrimPrefix(message.Params[0], "#") 477 | } 478 | 479 | return &parsedMessage 480 | } 481 | 482 | func parseReconnectMessage(message *ircMessage) Message { 483 | return &ReconnectMessage{ 484 | Raw: message.Raw, 485 | Type: parseMessageType(message.Command), 486 | RawType: message.Command, 487 | } 488 | } 489 | 490 | func parseNamesMessage(message *ircMessage) Message { 491 | parsedMessage := NamesMessage{ 492 | Raw: message.Raw, 493 | Type: parseMessageType(message.Command), 494 | RawType: message.Command, 495 | } 496 | 497 | if len(message.Params) == 4 { 498 | parsedMessage.Channel = strings.TrimPrefix(message.Params[2], "#") 499 | parsedMessage.Users = strings.Split(message.Params[3], " ") 500 | } 501 | 502 | return &parsedMessage 503 | } 504 | 505 | func parsePingMessage(message *ircMessage) Message { 506 | parsedMessage := PingMessage{ 507 | Raw: message.Raw, 508 | Type: parseMessageType(message.Command), 509 | RawType: message.Command, 510 | } 511 | 512 | if len(message.Params) == 1 { 513 | parsedMessage.Message = strings.Split(message.Params[0], " ")[0] 514 | } 515 | 516 | return &parsedMessage 517 | } 518 | 519 | func parsePongMessage(message *ircMessage) Message { 520 | parsedMessage := PongMessage{ 521 | Raw: message.Raw, 522 | Type: parseMessageType(message.Command), 523 | RawType: message.Command, 524 | } 525 | 526 | if len(message.Params) == 2 { 527 | parsedMessage.Message = strings.Split(message.Params[1], " ")[0] 528 | } 529 | 530 | return &parsedMessage 531 | } 532 | 533 | func parseTime(rawTime string) time.Time { 534 | if rawTime == "" { 535 | return time.Time{} 536 | } 537 | 538 | time64, _ := strconv.ParseInt(rawTime, 10, 64) 539 | return time.Unix(0, time64*1e6) 540 | } 541 | 542 | func parseEmotes(rawEmotes, message string) []*Emote { 543 | var emotes []*Emote 544 | 545 | if rawEmotes == "" { 546 | return emotes 547 | } 548 | 549 | runes := []rune(message) 550 | 551 | L: 552 | for _, v := range strings.Split(rawEmotes, "/") { 553 | split := strings.SplitN(v, ":", 2) 554 | if len(split) != 2 { 555 | // We have received bad emote data :( 556 | continue 557 | } 558 | pairs := strings.Split(split[1], ",") 559 | if len(pairs) < 1 { 560 | // We have received bad emote data :( 561 | continue 562 | } 563 | pair := strings.SplitN(pairs[0], "-", 2) 564 | if len(pair) != 2 { 565 | // We have received bad emote data :( 566 | continue 567 | } 568 | 569 | firstIndex, _ := strconv.Atoi(pair[0]) 570 | lastIndex, _ := strconv.Atoi(pair[1]) 571 | 572 | if lastIndex+1 > len(runes) { 573 | lastIndex = len(runes) - 1 574 | } 575 | 576 | if firstIndex+1 > len(runes) { 577 | firstIndex = len(runes) - 1 578 | } 579 | 580 | var positions []EmotePosition 581 | for _, p := range pairs { 582 | pos := strings.SplitN(p, "-", 2) 583 | if len(pos) != 2 { 584 | // Position is not valid, continue on the outer loop and skip this emote. 585 | continue L 586 | } 587 | 588 | // Convert the start and end positions from strings, and bail if it fails. 589 | start, err := strconv.Atoi(pos[0]) 590 | if err != nil { 591 | continue L 592 | } 593 | 594 | end, err := strconv.Atoi(pos[1]) 595 | if err != nil { 596 | continue L 597 | } 598 | 599 | positions = append(positions, EmotePosition{ 600 | Start: start, 601 | End: end, 602 | }) 603 | } 604 | 605 | emote := &Emote{ 606 | Name: string(runes[firstIndex : lastIndex+1]), 607 | ID: split[0], 608 | Count: strings.Count(split[1], ",") + 1, 609 | Positions: positions, 610 | } 611 | 612 | emotes = append(emotes, emote) 613 | } 614 | 615 | return emotes 616 | } 617 | 618 | func parseEmoteSets(message *ircMessage) []string { 619 | _, ok := message.Tags["emote-sets"] 620 | if !ok { 621 | return []string{} 622 | } 623 | 624 | return strings.Split(message.Tags["emote-sets"], ",") 625 | } 626 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/textproto" 11 | "strings" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | const ( 17 | // ircTwitch constant for twitch irc chat address 18 | ircTwitchTLS = "irc.chat.twitch.tv:6697" 19 | ircTwitch = "irc.chat.twitch.tv:6667" 20 | 21 | pingSignature = "go-twitch-irc" 22 | pingMessage = "PING :" + pingSignature 23 | 24 | // TagsCapability for Twitch's Tags capabilities, see https://dev.twitch.tv/docs/irc/tags 25 | TagsCapability = "twitch.tv/tags" 26 | 27 | // CommandsCapability for Twitch's Commands capabilities, see https://dev.twitch.tv/docs/irc/commands 28 | CommandsCapability = "twitch.tv/commands" 29 | 30 | // MembershipCapability for Twitch's Membership capabilities, see https://dev.twitch.tv/docs/irc/membership 31 | MembershipCapability = "twitch.tv/membership" 32 | ) 33 | 34 | var ( 35 | // ErrClientDisconnected returned from Connect() when a Disconnect() was called 36 | ErrClientDisconnected = errors.New("client called Disconnect()") 37 | 38 | // ErrLoginAuthenticationFailed returned from Connect() when either the wrong or a malformed oauth token is used 39 | ErrLoginAuthenticationFailed = errors.New("login authentication failed") 40 | 41 | // ErrConnectionIsNotOpen is returned by Disconnect in case you call it without being connected 42 | ErrConnectionIsNotOpen = errors.New("connection is not open") 43 | 44 | // WriteBufferSize can be modified to change the write channel buffer size. 45 | // Must be configured before NewClient is called to take effect 46 | WriteBufferSize = 512 47 | 48 | // ReadBufferSize can be modified to change the read channel buffer size. 49 | // Must be configured before NewClient is called to take effect 50 | ReadBufferSize = 64 51 | 52 | // DefaultCapabilities is the default caps when creating a new Client 53 | DefaultCapabilities = []string{TagsCapability, CommandsCapability} 54 | ) 55 | 56 | // Internal errors 57 | var ( 58 | errReconnect = errors.New("reconnect") 59 | ) 60 | 61 | // User data you receive from TMI 62 | type User struct { 63 | ID string 64 | Name string 65 | DisplayName string 66 | Color string 67 | Badges map[string]int 68 | IsBroadcaster bool 69 | IsMod bool 70 | IsVip bool 71 | } 72 | 73 | // Message interface that all messages implement 74 | type Message interface { 75 | GetType() MessageType 76 | } 77 | 78 | // RawMessage data you receive from TMI 79 | type RawMessage struct { 80 | Raw string 81 | Type MessageType 82 | RawType string 83 | Tags map[string]string 84 | Message string 85 | } 86 | 87 | // GetType implements the Message interface, and returns this message's type 88 | func (msg *RawMessage) GetType() MessageType { 89 | return msg.Type 90 | } 91 | 92 | // WhisperMessage data you receive from WHISPER message type 93 | type WhisperMessage struct { 94 | User User 95 | 96 | Raw string 97 | Type MessageType 98 | RawType string 99 | Tags map[string]string 100 | Message string 101 | Target string 102 | MessageID string 103 | ThreadID string 104 | Emotes []*Emote 105 | Action bool 106 | } 107 | 108 | // GetType implements the Message interface, and returns this message's type 109 | func (msg *WhisperMessage) GetType() MessageType { 110 | return msg.Type 111 | } 112 | 113 | // PrivateMessage data you receive from PRIVMSG message type 114 | type PrivateMessage struct { 115 | User User 116 | 117 | Raw string 118 | Type MessageType 119 | RawType string 120 | Tags map[string]string 121 | Message string 122 | Channel string 123 | RoomID string 124 | ID string 125 | Time time.Time 126 | Emotes []*Emote 127 | Bits int 128 | Action bool 129 | FirstMessage bool 130 | Reply *Reply 131 | Source *Source 132 | CustomRewardID string 133 | } 134 | 135 | type Reply struct { 136 | ParentMsgID string 137 | ParentUserID string 138 | ParentUserLogin string 139 | ParentDisplayName string 140 | ParentMsgBody string 141 | } 142 | 143 | type Source struct { 144 | RoomID string 145 | ID string 146 | Badges map[string]int 147 | SourceOnly bool 148 | } 149 | 150 | // GetType implements the Message interface, and returns this message's type 151 | func (msg *PrivateMessage) GetType() MessageType { 152 | return msg.Type 153 | } 154 | 155 | // ClearChatMessage data you receive from CLEARCHAT message type 156 | type ClearChatMessage struct { 157 | Raw string 158 | Type MessageType 159 | RawType string 160 | Tags map[string]string 161 | Message string 162 | Channel string 163 | RoomID string 164 | Time time.Time 165 | BanDuration int 166 | TargetUserID string 167 | TargetUsername string 168 | } 169 | 170 | // GetType implements the Message interface, and returns this message's type 171 | func (msg *ClearChatMessage) GetType() MessageType { 172 | return msg.Type 173 | } 174 | 175 | // ClearMessage data you receive from CLEARMSG message type 176 | type ClearMessage struct { 177 | Raw string 178 | Type MessageType 179 | RawType string 180 | Tags map[string]string 181 | Message string 182 | Channel string 183 | Login string 184 | TargetMsgID string 185 | } 186 | 187 | // GetType implements the Message interface, and returns this message's type 188 | func (msg *ClearMessage) GetType() MessageType { 189 | return msg.Type 190 | } 191 | 192 | // RoomStateMessage data you receive from ROOMSTATE message type 193 | type RoomStateMessage struct { 194 | Raw string 195 | Type MessageType 196 | RawType string 197 | Tags map[string]string 198 | Message string 199 | Channel string 200 | RoomID string 201 | State map[string]int 202 | } 203 | 204 | // GetType implements the Message interface, and returns this message's type 205 | func (msg *RoomStateMessage) GetType() MessageType { 206 | return msg.Type 207 | } 208 | 209 | // UserNoticeMessage data you receive from USERNOTICE message type 210 | type UserNoticeMessage struct { 211 | User User 212 | 213 | Raw string 214 | Type MessageType 215 | RawType string 216 | Tags map[string]string 217 | Message string 218 | Channel string 219 | RoomID string 220 | ID string 221 | Time time.Time 222 | Emotes []*Emote 223 | MsgID string 224 | MsgParams map[string]string 225 | SystemMsg string 226 | } 227 | 228 | // GetType implements the Message interface, and returns this message's type 229 | func (msg *UserNoticeMessage) GetType() MessageType { 230 | return msg.Type 231 | } 232 | 233 | // UserStateMessage data you receive from the USERSTATE message type 234 | type UserStateMessage struct { 235 | User User 236 | 237 | Raw string 238 | Type MessageType 239 | RawType string 240 | Tags map[string]string 241 | Message string 242 | Channel string 243 | EmoteSets []string 244 | } 245 | 246 | // GetType implements the Message interface, and returns this message's type 247 | func (msg *UserStateMessage) GetType() MessageType { 248 | return msg.Type 249 | } 250 | 251 | // NoticeMessage data you receive from the NOTICE message type 252 | type NoticeMessage struct { 253 | Raw string 254 | Type MessageType 255 | RawType string 256 | Tags map[string]string 257 | Message string 258 | Channel string 259 | MsgID string 260 | } 261 | 262 | // GetType implements the Message interface, and returns this message's type 263 | func (msg *NoticeMessage) GetType() MessageType { 264 | return msg.Type 265 | } 266 | 267 | // UserJoinMessage desJoines the message that is sent whenever a user joins a channel we're connected to 268 | // See https://dev.twitch.tv/docs/irc/membership/#join-twitch-membership 269 | type UserJoinMessage struct { 270 | Raw string 271 | Type MessageType 272 | RawType string 273 | 274 | // Channel name 275 | Channel string 276 | 277 | // User name 278 | User string 279 | } 280 | 281 | // GetType implements the Message interface, and returns this message's type 282 | func (msg *UserJoinMessage) GetType() MessageType { 283 | return msg.Type 284 | } 285 | 286 | // UserPartMessage describes the message that is sent whenever a user leaves a channel we're connected to 287 | // See https://dev.twitch.tv/docs/irc/membership/#part-twitch-membership 288 | type UserPartMessage struct { 289 | Raw string 290 | Type MessageType 291 | RawType string 292 | 293 | // Channel name 294 | Channel string 295 | 296 | // User name 297 | User string 298 | } 299 | 300 | // GetType implements the Message interface, and returns this message's type 301 | func (msg *UserPartMessage) GetType() MessageType { 302 | return msg.Type 303 | } 304 | 305 | // GlobalUserStateMessage On successful login, provides data about the current logged-in user through IRC tags 306 | // See https://dev.twitch.tv/docs/irc/tags/#globaluserstate-twitch-tags 307 | type GlobalUserStateMessage struct { 308 | User User 309 | 310 | Raw string 311 | Type MessageType 312 | RawType string 313 | Tags map[string]string 314 | EmoteSets []string 315 | } 316 | 317 | // GetType implements the Message interface, and returns this message's type 318 | func (msg *GlobalUserStateMessage) GetType() MessageType { 319 | return msg.Type 320 | } 321 | 322 | // ReconnectMessage describes the 323 | type ReconnectMessage struct { 324 | Raw string 325 | Type MessageType 326 | RawType string 327 | } 328 | 329 | // GetType implements the Message interface, and returns this message's type 330 | func (msg *ReconnectMessage) GetType() MessageType { 331 | return msg.Type 332 | } 333 | 334 | // NamesMessage describes the data posted in response to a /names command 335 | // See https://www.alien.net.au/irc/irc2numerics.html#353 336 | type NamesMessage struct { 337 | Raw string 338 | Type MessageType 339 | RawType string 340 | 341 | // Channel name 342 | Channel string 343 | 344 | // List of user names 345 | Users []string 346 | } 347 | 348 | // GetType implements the Message interface, and returns this message's type 349 | func (msg *NamesMessage) GetType() MessageType { 350 | return msg.Type 351 | } 352 | 353 | // PingMessage describes an IRC PING message 354 | type PingMessage struct { 355 | Raw string 356 | Type MessageType 357 | RawType string 358 | 359 | Message string 360 | } 361 | 362 | // GetType implements the Message interface, and returns this message's type 363 | func (msg *PingMessage) GetType() MessageType { 364 | return msg.Type 365 | } 366 | 367 | // PongMessage describes an IRC PONG message 368 | type PongMessage struct { 369 | Raw string 370 | Type MessageType 371 | RawType string 372 | 373 | Message string 374 | } 375 | 376 | // GetType implements the Message interface, and returns this message's type 377 | func (msg *PongMessage) GetType() MessageType { 378 | return msg.Type 379 | } 380 | 381 | // Client client to control your connection and attach callbacks 382 | type Client struct { 383 | IrcAddress string 384 | ircUser string 385 | ircToken string 386 | TLS bool 387 | connActive tAtomBool 388 | channels map[string]bool 389 | channelUserlistMutex *sync.RWMutex 390 | channelUserlist map[string]map[string]bool 391 | channelsMtx *sync.RWMutex 392 | latencyMutex *sync.RWMutex 393 | onConnect func() 394 | onWhisperMessage func(message WhisperMessage) 395 | onPrivateMessage func(message PrivateMessage) 396 | onClearChatMessage func(message ClearChatMessage) 397 | onRoomStateMessage func(message RoomStateMessage) 398 | onClearMessage func(message ClearMessage) 399 | onUserNoticeMessage func(message UserNoticeMessage) 400 | onUserStateMessage func(message UserStateMessage) 401 | onGlobalUserStateMessage func(message GlobalUserStateMessage) 402 | onNoticeMessage func(message NoticeMessage) 403 | onUserJoinMessage func(message UserJoinMessage) 404 | onUserPartMessage func(message UserPartMessage) 405 | onSelfJoinMessage func(message UserJoinMessage) 406 | onSelfPartMessage func(message UserPartMessage) 407 | onReconnectMessage func(message ReconnectMessage) 408 | onNamesMessage func(message NamesMessage) 409 | onPingMessage func(message PingMessage) 410 | onPongMessage func(message PongMessage) 411 | onUnsetMessage func(message RawMessage) 412 | 413 | onPingSent func() 414 | 415 | // read is the incoming messages channel, normally buffered with ReadBufferSize 416 | read chan string 417 | 418 | // write is the outgoing messages channel, normally buffered with WriteBufferSize 419 | write chan string 420 | 421 | // clientReconnect is closed whenever the client needs to reconnect for connection issue reasons 422 | clientReconnect chanCloser 423 | 424 | // userDisconnect is closed when the user calls Disconnect 425 | userDisconnect chanCloser 426 | 427 | // pongReceived is listened to by the pinger go-routine after it has sent off a ping. will be triggered by handleLine 428 | pongReceived chan bool 429 | 430 | // messageReceived is listened to by the pinger go-routine to interrupt the idle ping interval 431 | messageReceived chan bool 432 | 433 | // Option whether to send pings every `IdlePingInterval`. The IdlePingInterval is interrupted every time a message is received from the irc server 434 | // The variable may only be modified before calling Connect 435 | SendPings bool 436 | 437 | // IdlePingInterval is the interval at which to send a ping to the irc server to ensure the connection is alive. 438 | // The variable may only be modified before calling Connect 439 | IdlePingInterval time.Duration 440 | 441 | // PongTimeout is the time go-twitch-irc waits after sending a ping before issuing a reconnect 442 | // The variable may only be modified before calling Connect 443 | PongTimeout time.Duration 444 | 445 | // LastSentPing is the time the last ping was sent. Used to measure latency. 446 | lastSentPing time.Time 447 | 448 | // Latency is the latency to the irc server measured as the duration 449 | // between when the last ping was sent and when the last pong was received 450 | latency time.Duration 451 | 452 | // SetupCmd is the command that is ran on successful connection to Twitch. Useful if you are proxying or something to run a custom command on connect. 453 | // The variable must be modified before calling Connect or the command will not run. 454 | SetupCmd string 455 | 456 | // Capabilities is the list of capabilities that should be sent as part of the connection setup 457 | // By default, this is all caps (Tags, Commands, Membership) 458 | // If this is an empty list or nil, no CAP REQ message is sent at all 459 | Capabilities []string 460 | 461 | // The ratelimits the client will respect when sending messages 462 | joinRateLimiter RateLimiter 463 | } 464 | 465 | // NewClient to create a new client 466 | func NewClient(username, oauth string) *Client { 467 | return &Client{ 468 | ircUser: username, 469 | ircToken: oauth, 470 | TLS: true, 471 | channels: map[string]bool{}, 472 | channelUserlist: map[string]map[string]bool{}, 473 | channelsMtx: &sync.RWMutex{}, 474 | latencyMutex: &sync.RWMutex{}, 475 | messageReceived: make(chan bool), 476 | 477 | read: make(chan string, ReadBufferSize), 478 | write: make(chan string, WriteBufferSize), 479 | 480 | // NOTE: IdlePingInterval must be higher than PongTimeout 481 | SendPings: true, 482 | IdlePingInterval: time.Second * 15, 483 | PongTimeout: time.Second * 5, 484 | 485 | channelUserlistMutex: &sync.RWMutex{}, 486 | 487 | Capabilities: DefaultCapabilities, 488 | 489 | joinRateLimiter: CreateDefaultRateLimiter(), 490 | } 491 | } 492 | 493 | // NewAnonymousClient to create a new client without login requirements (anonymous user) 494 | // Do note that the Say and Whisper functions will be ineffectual when using this constructor 495 | func NewAnonymousClient() *Client { 496 | return NewClient("justinfan123123", "oauth:59301") 497 | } 498 | 499 | // OnConnect attach callback to when a connection has been established 500 | func (c *Client) OnConnect(callback func()) { 501 | c.onConnect = callback 502 | } 503 | 504 | // OnWhisperMessage attach callback to new whisper 505 | func (c *Client) OnWhisperMessage(callback func(message WhisperMessage)) { 506 | c.onWhisperMessage = callback 507 | } 508 | 509 | // OnPrivateMessage attach callback to new standard chat messages 510 | func (c *Client) OnPrivateMessage(callback func(message PrivateMessage)) { 511 | c.onPrivateMessage = callback 512 | } 513 | 514 | // OnClearChatMessage attach callback to new messages such as timeouts 515 | func (c *Client) OnClearChatMessage(callback func(message ClearChatMessage)) { 516 | c.onClearChatMessage = callback 517 | } 518 | 519 | // OnClearMessage attach callback when a single message is deleted 520 | func (c *Client) OnClearMessage(callback func(message ClearMessage)) { 521 | c.onClearMessage = callback 522 | } 523 | 524 | // OnRoomStateMessage attach callback to new messages such as submode enabled 525 | func (c *Client) OnRoomStateMessage(callback func(message RoomStateMessage)) { 526 | c.onRoomStateMessage = callback 527 | } 528 | 529 | // OnUserNoticeMessage attach callback to new usernotice message such as sub, resub, and raids 530 | func (c *Client) OnUserNoticeMessage(callback func(message UserNoticeMessage)) { 531 | c.onUserNoticeMessage = callback 532 | } 533 | 534 | // OnUserStateMessage attach callback to new userstate 535 | func (c *Client) OnUserStateMessage(callback func(message UserStateMessage)) { 536 | c.onUserStateMessage = callback 537 | } 538 | 539 | // OnGlobalUserStateMessage attach callback to new global user state 540 | func (c *Client) OnGlobalUserStateMessage(callback func(message GlobalUserStateMessage)) { 541 | c.onGlobalUserStateMessage = callback 542 | } 543 | 544 | // OnNoticeMessage attach callback to new notice message such as hosts 545 | func (c *Client) OnNoticeMessage(callback func(message NoticeMessage)) { 546 | c.onNoticeMessage = callback 547 | } 548 | 549 | // OnUserJoinMessage attaches callback to user joins 550 | func (c *Client) OnUserJoinMessage(callback func(message UserJoinMessage)) { 551 | c.onUserJoinMessage = callback 552 | } 553 | 554 | // OnUserPartMessage attaches callback to user parts 555 | func (c *Client) OnUserPartMessage(callback func(message UserPartMessage)) { 556 | c.onUserPartMessage = callback 557 | } 558 | 559 | // OnSelfJoinMessage attaches callback to user JOINs of client's own user 560 | // Twitch will send us JOIN messages for our own user even without requesting twitch.tv/membership capability 561 | func (c *Client) OnSelfJoinMessage(callback func(message UserJoinMessage)) { 562 | c.onSelfJoinMessage = callback 563 | } 564 | 565 | // OnSelfJoinMessage attaches callback to user PARTs of client's own user 566 | // Twitch will send us PART messages for our own user even without requesting twitch.tv/membership capability 567 | func (c *Client) OnSelfPartMessage(callback func(message UserPartMessage)) { 568 | c.onSelfPartMessage = callback 569 | } 570 | 571 | // OnReconnectMessage attaches callback that is triggered whenever the twitch servers tell us to reconnect 572 | func (c *Client) OnReconnectMessage(callback func(message ReconnectMessage)) { 573 | c.onReconnectMessage = callback 574 | } 575 | 576 | // OnNamesMessage attaches callback to /names response 577 | func (c *Client) OnNamesMessage(callback func(message NamesMessage)) { 578 | c.onNamesMessage = callback 579 | } 580 | 581 | // OnPingMessage attaches callback to PING message 582 | func (c *Client) OnPingMessage(callback func(message PingMessage)) { 583 | c.onPingMessage = callback 584 | } 585 | 586 | // OnPongMessage attaches callback to PONG message 587 | func (c *Client) OnPongMessage(callback func(message PongMessage)) { 588 | c.onPongMessage = callback 589 | } 590 | 591 | // OnUnsetMessage attaches callback to message types we currently don't support 592 | func (c *Client) OnUnsetMessage(callback func(message RawMessage)) { 593 | c.onUnsetMessage = callback 594 | } 595 | 596 | // OnPingSent attaches callback that's called whenever the client sends out a ping message 597 | func (c *Client) OnPingSent(callback func()) { 598 | c.onPingSent = callback 599 | } 600 | 601 | // Say write something in a chat 602 | func (c *Client) Say(channel, text string) { 603 | channel = strings.ToLower(channel) 604 | 605 | c.send(fmt.Sprintf("PRIVMSG #%s :%s", channel, text)) 606 | } 607 | 608 | // Reply to a message previously sent in the same channel using the twitch reply feature 609 | func (c *Client) Reply(channel, parentMsgID, text string) { 610 | channel = strings.ToLower(channel) 611 | 612 | c.send(fmt.Sprintf("@reply-parent-msg-id=%s PRIVMSG #%s :%s", parentMsgID, channel, text)) 613 | } 614 | 615 | // Join enter a twitch channel to read more messages. 616 | // It will respect the given ratelimits. 617 | // This is not a blocking operation. 618 | func (c *Client) Join(channels ...string) { 619 | messages, joined := c.createJoinMessages(channels...) 620 | 621 | // If we have an active connection, explicitly join 622 | // before we add the joined channels to our map 623 | c.channelsMtx.Lock() 624 | for _, message := range messages { 625 | if c.connActive.get() { 626 | c.send(message) 627 | } 628 | } 629 | 630 | for _, channel := range joined { 631 | c.channels[channel] = c.connActive.get() 632 | c.channelUserlistMutex.Lock() 633 | c.channelUserlist[channel] = map[string]bool{} 634 | c.channelUserlistMutex.Unlock() 635 | } 636 | c.channelsMtx.Unlock() 637 | } 638 | 639 | // Latency returns the latency to the irc server measured as the duration 640 | // between when the last ping was sent and when the last pong was received. 641 | // Returns zero duration if no ping has been sent yet. 642 | // Returns an error if SendPings is false. 643 | func (c *Client) Latency() (latency time.Duration, err error) { 644 | if !c.SendPings { 645 | err = errors.New("measuring latency requires SendPings to be true") 646 | return 647 | } 648 | 649 | c.latencyMutex.RLock() 650 | defer c.latencyMutex.RUnlock() 651 | 652 | latency = c.latency 653 | return 654 | } 655 | 656 | // Creates an irc join message to join the given channels. 657 | // 658 | // Returns the join message, any channels included in the join message, 659 | // and any remaining channels. Channels which have already been joined 660 | // are not included in the remaining channels that are returned. 661 | func (c *Client) createJoinMessages(channels ...string) ([]string, []string) { 662 | baseMessage := "JOIN" 663 | joinMessages := []string{} 664 | joined := []string{} 665 | 666 | if channels == nil || len(channels) < 1 { 667 | return joinMessages, joined 668 | } 669 | 670 | sb := strings.Builder{} 671 | sb.WriteString(baseMessage) 672 | channelsWritten := 0 673 | 674 | for _, channel := range channels { 675 | channel = strings.ToLower(channel) 676 | // If the channel already exists in the map we don't need to re-join it 677 | c.channelsMtx.Lock() 678 | if c.channels[channel] { 679 | c.channelsMtx.Unlock() 680 | continue 681 | } 682 | c.channelsMtx.Unlock() 683 | if sb.Len()+len(channel)+2 > maxMessageLength || (!c.joinRateLimiter.IsUnlimited() && channelsWritten >= c.joinRateLimiter.GetLimit()) { 684 | joinMessages = append(joinMessages, sb.String()) 685 | sb.Reset() 686 | sb.WriteString(baseMessage) 687 | channelsWritten = 0 688 | } 689 | if channelsWritten == 0 { 690 | sb.WriteString(" #" + channel) 691 | channelsWritten++ 692 | } else { 693 | sb.WriteString(",#" + channel) 694 | channelsWritten++ 695 | } 696 | joined = append(joined, channel) 697 | } 698 | 699 | joinMessages = append(joinMessages, sb.String()) 700 | 701 | return joinMessages, joined 702 | } 703 | 704 | // Depart leave a twitch channel 705 | func (c *Client) Depart(channel string) { 706 | if c.connActive.get() { 707 | c.send(fmt.Sprintf("PART #%s", channel)) 708 | } 709 | 710 | c.channelsMtx.Lock() 711 | delete(c.channels, channel) 712 | c.channelUserlistMutex.Lock() 713 | delete(c.channelUserlist, channel) 714 | c.channelUserlistMutex.Unlock() 715 | c.channelsMtx.Unlock() 716 | } 717 | 718 | // Disconnect close current connection 719 | func (c *Client) Disconnect() error { 720 | if !c.connActive.get() { 721 | return ErrConnectionIsNotOpen 722 | } 723 | 724 | c.userDisconnect.Close() 725 | 726 | return nil 727 | } 728 | 729 | // Connect connect the client to the irc server 730 | func (c *Client) Connect() error { 731 | if c.IrcAddress == "" && c.TLS { 732 | c.IrcAddress = ircTwitchTLS 733 | } else if c.IrcAddress == "" && !c.TLS { 734 | c.IrcAddress = ircTwitch 735 | } 736 | 737 | dialer := &net.Dialer{ 738 | KeepAlive: time.Second * 10, 739 | } 740 | 741 | var conf *tls.Config 742 | if strings.HasPrefix(c.IrcAddress, "127.0.0.1:") { 743 | conf = &tls.Config{ 744 | MinVersion: tls.VersionTLS12, 745 | //nolint:gosec // disable certificate chain check locally 746 | InsecureSkipVerify: true, 747 | } 748 | } else { 749 | conf = &tls.Config{ 750 | MinVersion: tls.VersionTLS12, 751 | } 752 | } 753 | 754 | for { 755 | err := c.makeConnection(dialer, conf) 756 | 757 | switch err { 758 | case errReconnect: 759 | continue 760 | 761 | default: 762 | return err 763 | } 764 | } 765 | } 766 | 767 | func (c *Client) makeConnection(dialer *net.Dialer, conf *tls.Config) (err error) { 768 | c.connActive.set(false) 769 | var conn net.Conn 770 | if c.TLS { 771 | conn, err = tls.DialWithDialer(dialer, "tcp", c.IrcAddress, conf) 772 | } else { 773 | conn, err = dialer.Dial("tcp", c.IrcAddress) 774 | } 775 | if err != nil { 776 | return 777 | } 778 | 779 | wg := sync.WaitGroup{} 780 | c.clientReconnect.Reset() 781 | c.userDisconnect.Reset() 782 | 783 | // Start the connection reader in a separate go-routine 784 | wg.Add(1) 785 | go c.startReader(conn, &wg) 786 | 787 | if c.SendPings { 788 | // If SendPings is true (which it is by default), start the thread 789 | // responsible for managing sending pings and reading pongs 790 | // in a separate go-routine 791 | wg.Add(1) 792 | c.startPinger(conn, &wg) 793 | } 794 | 795 | // Send the initial connection messages (like logging in, getting the CAP REQ stuff) 796 | c.setupConnection(conn) 797 | 798 | // Start the connection writer in a separate go-routine 799 | wg.Add(1) 800 | go c.startWriter(conn, &wg) 801 | 802 | // start the parser in the same go-routine as makeConnection was called from 803 | // the error returned from parser will be forwarded to the caller of makeConnection 804 | // and that error will decide whether or not to reconnect 805 | err = c.startParser() 806 | 807 | conn.Close() 808 | c.clientReconnect.Close() 809 | 810 | // Wait for the reader, pinger, and writer to close 811 | wg.Wait() 812 | 813 | return 814 | } 815 | 816 | // Userlist returns the userlist for a given channel 817 | func (c *Client) Userlist(channel string) ([]string, error) { 818 | c.channelUserlistMutex.RLock() 819 | defer c.channelUserlistMutex.RUnlock() 820 | usermap, ok := c.channelUserlist[channel] 821 | if !ok || usermap == nil { 822 | return nil, fmt.Errorf("could not find userlist for channel '%s' in client", channel) 823 | } 824 | userlist := make([]string, len(usermap)) 825 | 826 | i := 0 827 | for key := range usermap { 828 | userlist[i] = key 829 | i++ 830 | } 831 | 832 | return userlist, nil 833 | } 834 | 835 | // SetIRCToken updates the oauth token for this client used for authentication 836 | // This will not cause a reconnect, but is meant more for "on next connect, use this new token" in case the old token has expired 837 | func (c *Client) SetIRCToken(ircToken string) { 838 | c.ircToken = ircToken 839 | } 840 | 841 | // SetJoinRateLimiter will set the rate limits for the client. 842 | // Use the factory methods CreateDefaultRateLimiter, CreateVerifiedRateLimiter or CreateUnlimitedRateLimiter to create the rate limits 843 | // or make your own RateLimiter based on the interface 844 | func (c *Client) SetJoinRateLimiter(rateLimiter RateLimiter) { 845 | c.joinRateLimiter = rateLimiter 846 | } 847 | 848 | func (c *Client) startReader(reader io.Reader, wg *sync.WaitGroup) { 849 | defer func() { 850 | c.clientReconnect.Close() 851 | 852 | wg.Done() 853 | }() 854 | 855 | tp := textproto.NewReader(bufio.NewReader(reader)) 856 | 857 | for { 858 | line, err := tp.ReadLine() 859 | if err != nil { 860 | return 861 | } 862 | messages := strings.Split(line, "\r\n") 863 | for _, msg := range messages { 864 | if !c.connActive.get() && strings.Contains(msg, ":tmi.twitch.tv 001") { 865 | c.connActive.set(true) 866 | c.initialJoins() 867 | if c.onConnect != nil { 868 | c.onConnect() 869 | } 870 | } 871 | c.read <- msg 872 | } 873 | } 874 | } 875 | 876 | func (c *Client) startPinger(closer io.Closer, wg *sync.WaitGroup) { 877 | c.pongReceived = make(chan bool, 1) 878 | 879 | go func() { 880 | defer func() { 881 | wg.Done() 882 | }() 883 | 884 | for { 885 | select { 886 | case <-c.clientReconnect.channel: 887 | return 888 | 889 | case <-c.userDisconnect.channel: 890 | return 891 | 892 | case <-c.messageReceived: 893 | // Interrupt idle ping interval 894 | continue 895 | 896 | case <-time.After(c.IdlePingInterval): 897 | if c.onPingSent != nil { 898 | c.onPingSent() 899 | } 900 | c.send(pingMessage) 901 | 902 | // update lastSentPing without blocking this goroutine waiting for the lock 903 | go func() { 904 | timeSent := time.Now() 905 | c.latencyMutex.Lock() 906 | c.lastSentPing = timeSent 907 | c.latencyMutex.Unlock() 908 | }() 909 | 910 | select { 911 | case <-c.pongReceived: 912 | // Received pong message within the time limit, we're good 913 | continue 914 | 915 | case <-time.After(c.PongTimeout): 916 | // No pong message was received within the pong timeout, disconnect 917 | c.clientReconnect.Close() 918 | closer.Close() 919 | } 920 | } 921 | } 922 | }() 923 | } 924 | 925 | func (c *Client) setupConnection(conn net.Conn) { 926 | if c.SetupCmd != "" { 927 | conn.Write([]byte(c.SetupCmd + "\r\n")) 928 | } 929 | if len(c.Capabilities) > 0 { 930 | _, _ = conn.Write([]byte("CAP REQ :" + strings.Join(c.Capabilities, " ") + "\r\n")) 931 | } 932 | conn.Write([]byte("PASS " + c.ircToken + "\r\n")) 933 | conn.Write([]byte("NICK " + c.ircUser + "\r\n")) 934 | } 935 | 936 | func (c *Client) startWriter(writer io.WriteCloser, wg *sync.WaitGroup) { 937 | defer func() { 938 | wg.Done() 939 | }() 940 | for { 941 | select { 942 | case <-c.clientReconnect.channel: 943 | return 944 | case <-c.userDisconnect.channel: 945 | return 946 | case msg := <-c.write: 947 | c.writeMessage(writer, msg) 948 | } 949 | } 950 | } 951 | 952 | func (c *Client) writeMessage(writer io.WriteCloser, msg string) { 953 | if strings.HasPrefix(msg, "JOIN") { 954 | splits := strings.Split(msg, ",") 955 | c.joinRateLimiter.Throttle(len(splits)) 956 | } 957 | 958 | _, err := writer.Write([]byte(msg + "\r\n")) 959 | if err != nil { 960 | // Attempt to re-send failed messages 961 | c.write <- msg 962 | 963 | _ = writer.Close() 964 | c.clientReconnect.Close() 965 | } 966 | } 967 | 968 | func (c *Client) startParser() error { 969 | for { 970 | // reader 971 | select { 972 | case msg := <-c.read: 973 | if err := c.handleLine(msg); err != nil { 974 | return err 975 | } 976 | 977 | case <-c.clientReconnect.channel: 978 | return errReconnect 979 | 980 | case <-c.userDisconnect.channel: 981 | return ErrClientDisconnected 982 | } 983 | } 984 | } 985 | 986 | func (c *Client) initialJoins() { 987 | // join or rejoin channels on connection 988 | channels := []string{} 989 | for channel := range c.channels { 990 | channels = append(channels, channel) 991 | c.channels[channel] = false 992 | } 993 | c.Join(channels...) 994 | } 995 | 996 | func (c *Client) send(line string) { 997 | select { 998 | case c.write <- line: 999 | default: 1000 | // The buffer of c.write is full, queue up the message to be sent later. 1001 | // We have no guarantee of order anymore if the buffer is full 1002 | go func() { 1003 | c.write <- line 1004 | }() 1005 | } 1006 | } 1007 | 1008 | // Errors returned from handleLine break out of readConnections, which starts a reconnect 1009 | // This means that we should only return fatal errors as errors here 1010 | func (c *Client) handleLine(line string) error { 1011 | go func() { 1012 | // Send a message on the `messageReceived` channel, but do not block in case no one is receiving on the other end 1013 | select { 1014 | case c.messageReceived <- true: 1015 | default: 1016 | } 1017 | }() 1018 | 1019 | message := ParseMessage(line) 1020 | 1021 | switch msg := message.(type) { 1022 | case *WhisperMessage: 1023 | if c.onWhisperMessage != nil { 1024 | c.onWhisperMessage(*msg) 1025 | } 1026 | return nil 1027 | 1028 | case *PrivateMessage: 1029 | if c.onPrivateMessage != nil { 1030 | c.onPrivateMessage(*msg) 1031 | } 1032 | return nil 1033 | 1034 | case *ClearChatMessage: 1035 | if c.onClearChatMessage != nil { 1036 | c.onClearChatMessage(*msg) 1037 | } 1038 | return nil 1039 | 1040 | case *ClearMessage: 1041 | if c.onClearMessage != nil { 1042 | c.onClearMessage(*msg) 1043 | } 1044 | return nil 1045 | 1046 | case *RoomStateMessage: 1047 | if c.onRoomStateMessage != nil { 1048 | c.onRoomStateMessage(*msg) 1049 | } 1050 | return nil 1051 | 1052 | case *UserNoticeMessage: 1053 | if c.onUserNoticeMessage != nil { 1054 | c.onUserNoticeMessage(*msg) 1055 | } 1056 | return nil 1057 | 1058 | case *UserStateMessage: 1059 | if c.onUserStateMessage != nil { 1060 | c.onUserStateMessage(*msg) 1061 | } 1062 | return nil 1063 | 1064 | case *GlobalUserStateMessage: 1065 | if c.onGlobalUserStateMessage != nil { 1066 | c.onGlobalUserStateMessage(*msg) 1067 | } 1068 | return nil 1069 | 1070 | case *NoticeMessage: 1071 | if c.onNoticeMessage != nil { 1072 | c.onNoticeMessage(*msg) 1073 | } 1074 | return c.handleNoticeMessage(*msg) 1075 | 1076 | case *UserJoinMessage: 1077 | c.handleUserJoinMessage(*msg) 1078 | if msg.User == c.ircUser { 1079 | if c.onSelfJoinMessage != nil { 1080 | c.onSelfJoinMessage(*msg) 1081 | } 1082 | } else { 1083 | if c.onUserJoinMessage != nil { 1084 | c.onUserJoinMessage(*msg) 1085 | } 1086 | } 1087 | return nil 1088 | 1089 | case *UserPartMessage: 1090 | c.handleUserPartMessage(*msg) 1091 | if msg.User == c.ircUser { 1092 | if c.onSelfPartMessage != nil { 1093 | c.onSelfPartMessage(*msg) 1094 | } 1095 | } else { 1096 | if c.onUserPartMessage != nil { 1097 | c.onUserPartMessage(*msg) 1098 | } 1099 | } 1100 | return nil 1101 | 1102 | case *ReconnectMessage: 1103 | // https://dev.twitch.tv/docs/irc/commands/#reconnect-twitch-commands 1104 | if c.onReconnectMessage != nil { 1105 | c.onReconnectMessage(*msg) 1106 | } 1107 | return errReconnect 1108 | 1109 | case *NamesMessage: 1110 | if c.onNamesMessage != nil { 1111 | c.onNamesMessage(*msg) 1112 | } 1113 | c.handleNamesMessage(*msg) 1114 | return nil 1115 | 1116 | case *PingMessage: 1117 | if c.onPingMessage != nil { 1118 | c.onPingMessage(*msg) 1119 | } 1120 | c.handlePingMessage(*msg) 1121 | return nil 1122 | 1123 | case *PongMessage: 1124 | if c.onPongMessage != nil { 1125 | c.onPongMessage(*msg) 1126 | } 1127 | c.handlePongMessage(*msg) 1128 | return nil 1129 | 1130 | case *RawMessage: 1131 | if c.onUnsetMessage != nil { 1132 | c.onUnsetMessage(*msg) 1133 | } 1134 | } 1135 | 1136 | return nil 1137 | } 1138 | 1139 | func (c *Client) handleNoticeMessage(msg NoticeMessage) error { 1140 | if msg.Channel == "*" { 1141 | if msg.Message == "Login authentication failed" || msg.Message == "Improperly formatted auth" || msg.Message == "Invalid NICK" || msg.Message == "Login unsuccessful" { 1142 | return ErrLoginAuthenticationFailed 1143 | } 1144 | } 1145 | 1146 | return nil 1147 | } 1148 | 1149 | func (c *Client) handleUserJoinMessage(msg UserJoinMessage) { 1150 | // Self JOINs are handled on a separate callback 1151 | if msg.User == c.ircUser { 1152 | return 1153 | } 1154 | 1155 | c.channelUserlistMutex.Lock() 1156 | defer c.channelUserlistMutex.Unlock() 1157 | 1158 | if c.channelUserlist[msg.Channel] == nil { 1159 | c.channelUserlist[msg.Channel] = map[string]bool{} 1160 | } 1161 | 1162 | if _, ok := c.channelUserlist[msg.Channel][msg.User]; !ok { 1163 | c.channelUserlist[msg.Channel][msg.User] = true 1164 | } 1165 | } 1166 | 1167 | func (c *Client) handleUserPartMessage(msg UserPartMessage) { 1168 | // Self PARTs are handled on a separate callback 1169 | if msg.User == c.ircUser { 1170 | return 1171 | } 1172 | 1173 | c.channelUserlistMutex.Lock() 1174 | defer c.channelUserlistMutex.Unlock() 1175 | 1176 | delete(c.channelUserlist[msg.Channel], msg.User) 1177 | } 1178 | 1179 | func (c *Client) handleNamesMessage(msg NamesMessage) { 1180 | c.channelUserlistMutex.Lock() 1181 | defer c.channelUserlistMutex.Unlock() 1182 | 1183 | if c.channelUserlist[msg.Channel] == nil { 1184 | c.channelUserlist[msg.Channel] = map[string]bool{} 1185 | } 1186 | 1187 | for _, user := range msg.Users { 1188 | c.channelUserlist[msg.Channel][user] = true 1189 | } 1190 | } 1191 | 1192 | func (c *Client) handlePingMessage(msg PingMessage) { 1193 | if msg.Message == "" { 1194 | c.send("PONG") 1195 | } else { 1196 | c.send("PONG :" + msg.Message) 1197 | } 1198 | } 1199 | 1200 | func (c *Client) handlePongMessage(msg PongMessage) { 1201 | if msg.Message == pingSignature { 1202 | // Received a pong that was sent by us 1203 | select { 1204 | case c.pongReceived <- true: 1205 | c.latencyMutex.Lock() 1206 | c.latency = time.Since(c.lastSentPing) 1207 | c.latencyMutex.Unlock() 1208 | default: 1209 | } 1210 | } 1211 | } 1212 | 1213 | // chanCloser is a helper function for abusing channels for notifications 1214 | // this is an easy "notify many" channel 1215 | type chanCloser struct { 1216 | mutex sync.Mutex 1217 | 1218 | o *sync.Once 1219 | channel chan struct{} 1220 | } 1221 | 1222 | func (c *chanCloser) Reset() { 1223 | c.mutex.Lock() 1224 | defer c.mutex.Unlock() 1225 | c.o = &sync.Once{} 1226 | c.channel = make(chan struct{}) 1227 | } 1228 | 1229 | func (c *chanCloser) Close() { 1230 | c.mutex.Lock() 1231 | defer c.mutex.Unlock() 1232 | c.o.Do(func() { 1233 | close(c.channel) 1234 | }) 1235 | } 1236 | -------------------------------------------------------------------------------- /message_test.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCanPraseBadActionMessageWithoutPanic(t *testing.T) { 8 | testMessage := "@badges=;color=;display-name=pajlada;emotes=;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1522855191000;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :\u0001ACTION\u0001" 9 | 10 | message := ParseMessage(testMessage) 11 | msg := message.(*PrivateMessage) 12 | 13 | assertStringsEqual(t, "11148817", msg.User.ID) 14 | assertStringsEqual(t, "", msg.Message) 15 | } 16 | 17 | func TestCantParseNoTagsMessage(t *testing.T) { 18 | testMessage := "my test message" 19 | 20 | message := ParseMessage(testMessage) 21 | rawMessage := message.(*RawMessage) 22 | 23 | if rawMessage.Type != UNSET { 24 | t.Errorf("parsing MessageType failed") 25 | } 26 | 27 | assertStringsEqual(t, "my", rawMessage.RawType) 28 | assertStringMapsEqual(t, nil, rawMessage.Tags) 29 | assertStringsEqual(t, "test message", rawMessage.Message) 30 | } 31 | 32 | func TestCantParseInvalidMessage(t *testing.T) { 33 | testMessage := "@my :test message" 34 | 35 | message := ParseMessage(testMessage) 36 | rawMessage := message.(*RawMessage) 37 | 38 | if rawMessage.Type != UNSET { 39 | t.Errorf("parsing MessageType failed") 40 | } 41 | 42 | assertStringsEqual(t, "message", rawMessage.RawType) 43 | 44 | expectedTags := map[string]string{ 45 | "my": "", 46 | } 47 | assertStringMapsEqual(t, expectedTags, rawMessage.Tags) 48 | 49 | assertStringsEqual(t, "", rawMessage.Message) 50 | } 51 | 52 | func TestCantParsePartialMessage(t *testing.T) { 53 | testMessage := "@badges=;color=;display-name=ZZZi;emotes=;flags=;id=75bb6b6b-e36c-49af-a293-16024738ab92;mod=0;room-id=36029255;subscriber=0;tmi-sent-ts=1551476573570;turbo" 54 | 55 | message := ParseMessage(testMessage) 56 | rawMessage := message.(*RawMessage) 57 | 58 | if rawMessage.Type != UNSET { 59 | t.Errorf("parsing MessageType failed") 60 | } 61 | assertStringsEqual(t, "", rawMessage.RawType) 62 | 63 | expectedTags := map[string]string{ 64 | "badges": "", 65 | "color": "", 66 | "display-name": "ZZZi", 67 | "emotes": "", 68 | "flags": "", 69 | "id": "75bb6b6b-e36c-49af-a293-16024738ab92", 70 | "mod": "0", 71 | "room-id": "36029255", 72 | "subscriber": "0", 73 | "tmi-sent-ts": "1551476573570", 74 | "turbo": "", 75 | } 76 | assertStringMapsEqual(t, expectedTags, rawMessage.Tags) 77 | assertStringsEqual(t, "", rawMessage.Message) 78 | } 79 | 80 | func TestCanParseSliceOutOfBoundsMessage(t *testing.T) { 81 | testMessage := "@badge-info=;badges=;color=#D2691E;display-name=xSpeedHack;emotes=245:38-52;flags=28-35:A.6/I.6;id=ebf30552-c327-4602-a346-582ecc880ab5;mod=0;room-id=23304775;subscriber=0;tmi-sent-ts=1556302007395;turbo=0;user-id=189609555;user-type= :xspeedhack!xspeedhack@xspeedhack.tmi.twitch.tv PRIVMSG #turntheslayer :Чет скушные катки Жек когда Хачаги?! ResidentSleeper" 82 | 83 | message := ParseMessage(testMessage) 84 | privateMessage := message.(*PrivateMessage) 85 | 86 | if privateMessage.Type != PRIVMSG { 87 | t.Error("parsing MessageType failed") 88 | } 89 | assertStringsEqual(t, "PRIVMSG", privateMessage.RawType) 90 | assertStringsEqual(t, "Чет скушные катки Жек когда Хачаги?! ResidentSleeper", privateMessage.Message) 91 | assertIntsEqual(t, 1, len(privateMessage.Emotes)) 92 | } 93 | 94 | func TestCanParseWHISPERMessage(t *testing.T) { 95 | testMessage := "@badges=;color=#00FF7F;display-name=Danielps1;emotes=;message-id=20;thread-id=32591953_77829817;turbo=0;user-id=32591953;user-type= :danielps1!danielps1@danielps1.tmi.twitch.tv WHISPER gempir :i like memes" 96 | 97 | message := ParseMessage(testMessage) 98 | whisperMessage := message.(*WhisperMessage) 99 | user := whisperMessage.User 100 | 101 | assertStringsEqual(t, "32591953", user.ID) 102 | assertStringsEqual(t, "danielps1", user.Name) 103 | assertStringsEqual(t, "Danielps1", user.DisplayName) 104 | assertStringsEqual(t, "#00FF7F", user.Color) 105 | 106 | expectedBadges := map[string]int{} 107 | assertStringIntMapsEqual(t, expectedBadges, user.Badges) 108 | 109 | if whisperMessage.Type != WHISPER { 110 | t.Error("parsing MessageType failed") 111 | } 112 | assertStringsEqual(t, "WHISPER", whisperMessage.RawType) 113 | assertStringsEqual(t, "i like memes", whisperMessage.Message) 114 | assertIntsEqual(t, 0, len(whisperMessage.Emotes)) 115 | assertFalse(t, whisperMessage.Action, "parsing Action failed") 116 | } 117 | 118 | func TestCanParseWHISPERActionMessage(t *testing.T) { 119 | testMessage := "@badges=;color=#1E90FF;display-name=FletcherCodes;emotes=;message-id=50;thread-id=269899575_408892348;turbo=0;user-id=269899575;user-type= :fletchercodes!fletchercodes@fletchercodes.tmi.twitch.tv WHISPER clippyassistant :/me tests whisper action" 120 | 121 | message := ParseMessage(testMessage) 122 | whisperMessage := message.(*WhisperMessage) 123 | 124 | assertTrue(t, whisperMessage.Action, "parsing Action failed") 125 | } 126 | 127 | func TestCanParsePRIVMSGMessage(t *testing.T) { 128 | type test struct { 129 | name string 130 | message string 131 | expectedMessage PrivateMessage 132 | } 133 | tests := []test{ 134 | { 135 | "Message With First Message", 136 | "@badges=premium/1;color=#DAA520;display-name=FletcherCodes;emotes=;first-msg=1;flags=;id=6efffc70-27a1-4637-9111-44e5104bb7da;mod=0;room-id=408892348;subscriber=0;tmi-sent-ts=1551473087761;turbo=0;user-id=269899575;user-type= :fletchercodes!fletchercodes@fletchercodes.tmi.twitch.tv PRIVMSG #clippyassistant :Chew your food slower... it's healthier", 137 | PrivateMessage{ 138 | User: User{ 139 | ID: "269899575", 140 | Name: "fletchercodes", 141 | DisplayName: "FletcherCodes", 142 | Color: "#DAA520", 143 | Badges: map[string]int{ 144 | "premium": 1, 145 | }, 146 | }, 147 | Type: PRIVMSG, 148 | RawType: "PRIVMSG", 149 | Message: "Chew your food slower... it's healthier", 150 | Channel: "clippyassistant", 151 | RoomID: "408892348", 152 | ID: "6efffc70-27a1-4637-9111-44e5104bb7da", 153 | FirstMessage: true, 154 | }, 155 | }, 156 | { 157 | "Message Without First Message", 158 | "@badges=premium/1;color=#DAA520;display-name=FletcherCodes;emotes=;first-msg=0;flags=;id=6efffc70-27a1-4637-9111-44e5104bb7da;mod=0;room-id=408892348;subscriber=0;tmi-sent-ts=1551473087761;turbo=0;user-id=269899575;user-type= :fletchercodes!fletchercodes@fletchercodes.tmi.twitch.tv PRIVMSG #clippyassistant :Chew your food slower... it's healthier", 159 | PrivateMessage{ 160 | User: User{ 161 | ID: "269899575", 162 | Name: "fletchercodes", 163 | DisplayName: "FletcherCodes", 164 | Color: "#DAA520", 165 | Badges: map[string]int{ 166 | "premium": 1, 167 | }, 168 | }, 169 | Type: PRIVMSG, 170 | RawType: "PRIVMSG", 171 | Message: "Chew your food slower... it's healthier", 172 | Channel: "clippyassistant", 173 | RoomID: "408892348", 174 | ID: "6efffc70-27a1-4637-9111-44e5104bb7da", 175 | FirstMessage: false, 176 | }, 177 | }, 178 | { 179 | "Message With Missing First Message", 180 | "@badges=premium/1;color=#DAA520;display-name=FletcherCodes;emotes=;flags=;id=6efffc70-27a1-4637-9111-44e5104bb7da;mod=0;room-id=408892348;subscriber=0;tmi-sent-ts=1551473087761;turbo=0;user-id=269899575;user-type= :fletchercodes!fletchercodes@fletchercodes.tmi.twitch.tv PRIVMSG #clippyassistant :Chew your food slower... it's healthier", 181 | PrivateMessage{ 182 | User: User{ 183 | ID: "269899575", 184 | Name: "fletchercodes", 185 | DisplayName: "FletcherCodes", 186 | Color: "#DAA520", 187 | Badges: map[string]int{ 188 | "premium": 1, 189 | }, 190 | }, 191 | Type: PRIVMSG, 192 | RawType: "PRIVMSG", 193 | Message: "Chew your food slower... it's healthier", 194 | Channel: "clippyassistant", 195 | RoomID: "408892348", 196 | ID: "6efffc70-27a1-4637-9111-44e5104bb7da", 197 | FirstMessage: false, 198 | }, 199 | }, 200 | { 201 | "Reply Message", 202 | "@badges=premium/1;color=#DAA520;display-name=FletcherCodes;emotes=;flags=;id=6efffc70-27a1-4637-9111-44e5104bb7da;mod=0;reply-parent-msg-id=b34ccfc7-4977-403a-8a94-33c6bac34fb8;reply-parent-user-id=71601484;reply-parent-user-login=yannismate;reply-parent-display-name=Yannismate;reply-parent-msg-body=This\\smessage\\scontains\\sspecial\\schars\\s!\\:;room-id=408892348;subscriber=0;tmi-sent-ts=1551473087761;turbo=0;user-id=269899575;user-type= :fletchercodes!fletchercodes@fletchercodes.tmi.twitch.tv PRIVMSG #clippyassistant :Chew your food slower... it's healthier", 203 | PrivateMessage{ 204 | User: User{ 205 | ID: "269899575", 206 | Name: "fletchercodes", 207 | DisplayName: "FletcherCodes", 208 | Color: "#DAA520", 209 | Badges: map[string]int{ 210 | "premium": 1, 211 | }, 212 | }, 213 | Type: PRIVMSG, 214 | RawType: "PRIVMSG", 215 | Message: "Chew your food slower... it's healthier", 216 | Channel: "clippyassistant", 217 | RoomID: "408892348", 218 | ID: "6efffc70-27a1-4637-9111-44e5104bb7da", 219 | FirstMessage: false, 220 | Reply: &Reply{ 221 | ParentMsgID: "b34ccfc7-4977-403a-8a94-33c6bac34fb8", 222 | ParentUserID: "71601484", 223 | ParentUserLogin: "yannismate", 224 | ParentDisplayName: "Yannismate", 225 | ParentMsgBody: "This message contains special chars !;", 226 | }, 227 | }, 228 | }, 229 | { 230 | "Message With Custom Reward ID", 231 | "@badge-info=;badges=vip/1,bits/100;color=#FF69B4;custom-reward-id=ea98be77-c54e-49cd-bc52-d8290cf12ad8;display-name=eloonstra;emotes=;first-msg=0;flags=0-2:S.5;id=c46f5ea8-bbba-499d-a10f-96463d2955c1;mod=0;returning-chatter=0;room-id=75704631;subscriber=0;tmi-sent-ts=1674085692645;turbo=0;user-id=465396358;user-type=;vip=1 :eloonstra!eloonstra@eloonstra.tmi.twitch.tv PRIVMSG #eloonstra :boing", 232 | PrivateMessage{ 233 | User: User{ 234 | ID: "465396358", 235 | Name: "eloonstra", 236 | DisplayName: "eloonstra", 237 | Color: "#FF69B4", 238 | Badges: map[string]int{ 239 | "vip": 1, 240 | "bits": 100, 241 | }, 242 | }, 243 | Type: PRIVMSG, 244 | RawType: "PRIVMSG", 245 | Message: "boing", 246 | Channel: "eloonstra", 247 | RoomID: "75704631", 248 | ID: "c46f5ea8-bbba-499d-a10f-96463d2955c1", 249 | FirstMessage: false, 250 | Source: &Source{ 251 | Badges: map[string]int{ 252 | "subscriber": 3, 253 | }, 254 | RoomID: "39656772", 255 | ID: "614e293b-46c8-4f6f-9e35-f53f64254472", 256 | SourceOnly: false, 257 | }, 258 | }, 259 | }, 260 | { 261 | "Message from shared chat", 262 | "@badge-info=;badges=moderator/1;color=#00FF7F;display-name=Eternalwerecat22;emotes=;flags=;id=fa9da65d-86da-429f-90f1-467a6ac332df;mod=1;room-id=39656772;source-badge-info=subscriber/5;source-badges=subscriber/3;source-id=614e293b-46c8-4f6f-9e35-f53f64254472;source-only=0;source-room-id=655533779;subscriber=0;tmi-sent-ts=1762536730215;turbo=0;user-id=488149571;user-type=mod :eternalwerecat22!eternalwerecat22@eternalwerecat22.tmi.twitch.tv PRIVMSG #vampireblazewing :Video game things", 263 | PrivateMessage{ 264 | User: User{ 265 | ID: "488149571", 266 | Name: "eternalwerecat22", 267 | DisplayName: "Eternalwerecat22", 268 | Color: "#00FF7F", 269 | Badges: map[string]int{ 270 | "moderator": 1, 271 | }, 272 | }, 273 | Type: PRIVMSG, 274 | RawType: "PRIVMSG", 275 | Message: "Video game things", 276 | Channel: "vampireblazewing", 277 | RoomID: "39656772", 278 | ID: "fa9da65d-86da-429f-90f1-467a6ac332df", 279 | FirstMessage: false, 280 | CustomRewardID: "ea98be77-c54e-49cd-bc52-d8290cf12ad8", 281 | }, 282 | }, 283 | } 284 | 285 | for _, tt := range tests { 286 | func(tt test) { 287 | t.Run(tt.name, func(t *testing.T) { 288 | message := ParseMessage(tt.message) 289 | privateMessage := message.(*PrivateMessage) 290 | user := privateMessage.User 291 | 292 | assertStringsEqual(t, tt.expectedMessage.User.ID, user.ID) 293 | assertStringsEqual(t, tt.expectedMessage.User.Name, user.Name) 294 | assertStringsEqual(t, tt.expectedMessage.User.DisplayName, user.DisplayName) 295 | assertStringsEqual(t, tt.expectedMessage.User.Color, user.Color) 296 | assertStringIntMapsEqual(t, tt.expectedMessage.User.Badges, user.Badges) 297 | 298 | if privateMessage.Type != tt.expectedMessage.Type { 299 | t.Error("parsing MessageType failed") 300 | } 301 | 302 | assertStringsEqual(t, tt.expectedMessage.RawType, privateMessage.RawType) 303 | assertStringsEqual(t, tt.expectedMessage.Message, privateMessage.Message) 304 | assertStringsEqual(t, tt.expectedMessage.Channel, privateMessage.Channel) 305 | assertStringsEqual(t, tt.expectedMessage.RoomID, privateMessage.RoomID) 306 | assertStringsEqual(t, tt.expectedMessage.ID, privateMessage.ID) 307 | assertBoolEqual(t, tt.expectedMessage.Action, privateMessage.Action) 308 | 309 | assertIntsEqual(t, len(tt.expectedMessage.Emotes), len(privateMessage.Emotes)) 310 | assertIntsEqual(t, tt.expectedMessage.Bits, privateMessage.Bits) 311 | assertBoolEqual(t, tt.expectedMessage.FirstMessage, privateMessage.FirstMessage) 312 | 313 | if tt.expectedMessage.Reply != nil { 314 | assertStringsEqual(t, tt.expectedMessage.Reply.ParentMsgID, privateMessage.Reply.ParentMsgID) 315 | assertStringsEqual(t, tt.expectedMessage.Reply.ParentUserID, privateMessage.Reply.ParentUserID) 316 | assertStringsEqual(t, tt.expectedMessage.Reply.ParentUserLogin, privateMessage.Reply.ParentUserLogin) 317 | assertStringsEqual(t, tt.expectedMessage.Reply.ParentDisplayName, privateMessage.Reply.ParentDisplayName) 318 | assertStringsEqual(t, tt.expectedMessage.Reply.ParentMsgBody, privateMessage.Reply.ParentMsgBody) 319 | } 320 | }) 321 | }(tt) 322 | } 323 | } 324 | 325 | func TestCanParsePRIVMSGUserRoles(t *testing.T) { 326 | type expectedRoles struct { 327 | IsBroadcaster bool 328 | IsModerator bool 329 | IsVIP bool 330 | } 331 | type test struct { 332 | name string 333 | message string 334 | expectedRoles expectedRoles 335 | expectedMessage PrivateMessage 336 | } 337 | tests := []test{ 338 | { 339 | "Message From Broadcaster", 340 | "@badge-info=subscriber/23;badges=broadcaster/1,subscriber/0,sub-gifter/5;client-nonce=8754bd6c285fb6795d58dae6ec70796a;color=#8A2BE2;display-name=hash_table;emotes=;first-msg=0;flags=;id=05eb8c6f-6606-4a44-90f3-fa2d516f9c51;mod=0;returning-chatter=0;room-id=63073510;subscriber=1;tmi-sent-ts=1727526200010;turbo=0;user-id=63073510;user-type= :hash_table!hash_table@hash_table.tmi.twitch.tv PRIVMSG #hash_table :asd", 341 | expectedRoles{ 342 | IsBroadcaster: true, 343 | IsModerator: false, 344 | IsVIP: false, 345 | }, 346 | PrivateMessage{ 347 | User: User{ 348 | ID: "63073510", 349 | Name: "hash_table", 350 | DisplayName: "hash_table", 351 | Color: "#8A2BE2", 352 | IsBroadcaster: true, 353 | IsMod: false, 354 | IsVip: false, 355 | }, 356 | Type: PRIVMSG, 357 | RawType: "PRIVMSG", 358 | Message: "asd", 359 | RoomID: "63073510", 360 | }, 361 | }, 362 | { 363 | "Message From Moderator", 364 | "@badge-info=founder/1;badges=moderator/1,founder/0,game-developer/1;color=#8A2BE2;display-name=hash_table;emotes=;first-msg=0;flags=;id=9e9f5092-2372-47cb-a996-09e055e45480;mod=1;returning-chatter=0;room-id=488227870;subscriber=1;tmi-sent-ts=1727528720632;turbo=0;user-id=63073510;user-type=mod :hash_table!hash_table@hash_table.tmi.twitch.tv PRIVMSG #frizzeh :asd", 365 | expectedRoles{ 366 | IsBroadcaster: false, 367 | IsModerator: true, 368 | IsVIP: false, 369 | }, 370 | PrivateMessage{ 371 | User: User{ 372 | ID: "63073510", 373 | Name: "hash_table", 374 | DisplayName: "hash_table", 375 | Color: "#8A2BE2", 376 | IsBroadcaster: false, 377 | IsMod: true, 378 | IsVip: false, 379 | }, 380 | Type: PRIVMSG, 381 | RawType: "PRIVMSG", 382 | Message: "asd", 383 | RoomID: "488227870", 384 | }, 385 | }, 386 | { 387 | "Message From VIP", 388 | "@badge-info=founder/1;badges=vip/1,founder/0,game-developer/1;color=#8A2BE2;display-name=hash_table;emotes=;first-msg=0;flags=;id=cbbd0def-69e6-4cc1-a3bf-6268e737bd69;mod=0;returning-chatter=0;room-id=488227870;subscriber=1;tmi-sent-ts=1727529432379;turbo=0;user-id=63073510;user-type=;vip=1 :hash_table!hash_table@hash_table.tmi.twitch.tv PRIVMSG #frizzeh :asd", 389 | expectedRoles{ 390 | IsBroadcaster: false, 391 | IsModerator: false, 392 | IsVIP: true, 393 | }, 394 | PrivateMessage{ 395 | User: User{ 396 | ID: "63073510", 397 | Name: "hash_table", 398 | DisplayName: "hash_table", 399 | Color: "#8A2BE2", 400 | IsBroadcaster: false, 401 | IsMod: false, 402 | IsVip: true, 403 | }, 404 | Type: PRIVMSG, 405 | RawType: "PRIVMSG", 406 | Message: "asd", 407 | RoomID: "488227870", 408 | }, 409 | }, 410 | } 411 | 412 | for _, tt := range tests { 413 | func(tt test) { 414 | t.Run(tt.name, func(t *testing.T) { 415 | message := ParseMessage(tt.message) 416 | privateMessage := message.(*PrivateMessage) 417 | user := privateMessage.User 418 | assertBoolEqual(t, tt.expectedRoles.IsBroadcaster, user.IsBroadcaster) 419 | assertBoolEqual(t, tt.expectedRoles.IsModerator, user.IsMod) 420 | assertBoolEqual(t, tt.expectedRoles.IsVIP, user.IsVip) 421 | }) 422 | }(tt) 423 | } 424 | } 425 | 426 | func TestCanParsePRIVMSGActionMessage(t *testing.T) { 427 | testMessage := "@badges=premium/1;color=#DAA520;display-name=FletcherCodes;emotes=;flags=;id=6efffc70-27a1-4637-9111-44e5104bb7da;mod=0;room-id=408892348;subscriber=0;tmi-sent-ts=1551473087761;turbo=0;user-id=269899575;user-type= :fletchercodes!fletchercodes@fletchercodes.tmi.twitch.tv PRIVMSG #clippyassistant :\u0001ACTION Thrashh5, FeelsWayTooAmazingMan kinda\u0001" 428 | 429 | message := ParseMessage(testMessage) 430 | privateMessage := message.(*PrivateMessage) 431 | 432 | assertTrue(t, privateMessage.Action, "parsing Action failed") 433 | } 434 | 435 | func TestCanParseEmoteMessage(t *testing.T) { 436 | testMessage := "@badges=;color=#008000;display-name=Zugren;emotes=120232:0-6,13-19,26-32,39-45,52-58;id=51c290e9-1b50-497c-bb03-1667e1afe6e4;mod=0;room-id=11148817;sent-ts=1490382458685;subscriber=0;tmi-sent-ts=1490382456776;turbo=0;user-id=65897106;user-type= :zugren!zugren@zugren.tmi.twitch.tv PRIVMSG #pajlada :TriHard Clap TriHard Clap TriHard Clap TriHard Clap TriHard Clap" 437 | 438 | message := ParseMessage(testMessage) 439 | privateMessage := message.(*PrivateMessage) 440 | 441 | assertIntsEqual(t, 1, len(privateMessage.Emotes)) 442 | assertStringsEqual(t, "120232", privateMessage.Emotes[0].ID) 443 | assertStringsEqual(t, "TriHard", privateMessage.Emotes[0].Name) 444 | assertIntsEqual(t, 5, privateMessage.Emotes[0].Count) 445 | assertIntsEqual(t, 5, len(privateMessage.Emotes[0].Positions)) 446 | } 447 | 448 | func TestCanHandleBadEmoteMessage(t *testing.T) { 449 | type test struct { 450 | name string 451 | message string 452 | expectedEmotes []Emote 453 | } 454 | tests := []test{ 455 | { 456 | "Broken Emote Message", 457 | "@badge-info=subscriber/3;badges=subscriber/3;color=#0000FF;display-name=Linkoping;emotes=25:40-44;flags=17-26:S.6;id=744f9c58-b180-4f46-bd9e-b515b5ef75c1;mod=0;room-id=188442366;subscriber=1;tmi-sent-ts=1566335866017;turbo=0;user-id=91673457;user-type= :linkoping!linkoping@linkoping.tmi.twitch.tv PRIVMSG #queenqarro :Då kan du begära skadestånd och förtal Kappa", 458 | []Emote{{Name: "appa", ID: "25", Count: 1}}, 459 | }, 460 | { 461 | "Broken Emote Message", 462 | "@badge-info=;badges=moderator/1,partner/1;color=#5B99FF;display-name=StreamElements;emotes=86:30-39/822112:73-79;flags=22-27:S.5;id=03c3eec9-afd1-4858-a2e0-fccbf6ad8d1a;mod=1;room-id=506590738;subscriber=0;tmi-sent-ts=1588638345928;turbo=0;user-id=100135110;user-type=mod :streamelements!streamelements@streamelements.tmi.twitch.tv PRIVMSG #nobru :\u0001ACTION A LOJA AINDA NÃO ESTÁ PRONTA BibleThump , AGUARDE... NOVIDADES EM BREVE FortOne\u0001", 463 | []Emote{{Name: "ibleThump ", ID: "86", Count: 1}, {Name: "ortOne", ID: "822112", Count: 1}}, 464 | }, 465 | { 466 | "Broken Emote Message", 467 | "@badge-info=subscriber/1;badges=subscriber/0;color=;display-name=jhoelsc;emotes=301683486:46-58,60-72,74-86/301683544:88-100;flags=0-4:S.6;id=1f1afcdd-d94c-4699-b35f-d214deb1e11a;mod=0;room-id=167189231;subscriber=1;tmi-sent-ts=1588640587462;turbo=0;user-id=505763008;user-type= :jhoelsc!jhoelsc@jhoelsc.tmi.twitch.tv PRIVMSG #staryuuki :pensé que no habría directo que bueno que si staryuukiLove staryuukiLove staryuukiLove staryuukiBits", 468 | []Emote{{Name: "taryuukiLove ", ID: "301683486", Count: 3}, {Name: "taryuukiBits", ID: "301683544", Count: 1}}, 469 | }, 470 | { 471 | // This message is a modified example from https://github.com/twitchdev/issues/issues/104 that I have modified to make the emote be one extra character off, which I imagine could happen if the same unicode parsing thing magic bug happens twice in the same message 472 | "Extra Broken Emote Message", 473 | "@badge-info=subscriber/3;badges=subscriber/3;color=#0000FF;display-name=Linkoping;emotes=25:41-45;flags=17-26:S.6;id=744f9c58-b180-4f46-bd9e-b515b5ef75c1;mod=0;room-id=188442366;subscriber=1;tmi-sent-ts=1566335866017;turbo=0;user-id=91673457;user-type= :linkoping!linkoping@linkoping.tmi.twitch.tv PRIVMSG #queenqarro :Då kan du begära skadestånd och förtal Kappa", 474 | []Emote{{Name: "ppa", ID: "25", Count: 1}}, 475 | }, 476 | } 477 | 478 | for _, tt := range tests { 479 | func(tt test) { 480 | t.Run(tt.name, func(t *testing.T) { 481 | message := ParseMessage(tt.message) 482 | privateMessage := message.(*PrivateMessage) 483 | 484 | assertIntsEqual(t, len(tt.expectedEmotes), len(privateMessage.Emotes)) 485 | 486 | for i, expectedEmote := range tt.expectedEmotes { 487 | assertStringsEqual(t, expectedEmote.ID, privateMessage.Emotes[i].ID) 488 | assertStringsEqual(t, expectedEmote.Name, privateMessage.Emotes[i].Name) 489 | assertIntsEqual(t, expectedEmote.Count, privateMessage.Emotes[i].Count) 490 | } 491 | }) 492 | }(tt) 493 | } 494 | } 495 | 496 | func TestCanParseBitsMessage(t *testing.T) { 497 | testMessage := "@badges=bits/5000;bits=5000;color=#007EFF;display-name=FletcherCodes;emotes=;flags=;id=405c4ccb-7d69-4a57-ac16-292e72ba288b;mod=0;room-id=408892348;subscriber=0;tmi-sent-ts=1551478518354;turbo=0;user-id=269899575;user-type= :fletchercodes!fletchercodes@fletchercodes.tmi.twitch.tv PRIVMSG #clippyassistant :showlove5000 Chew your food slower... it's healthier" 498 | 499 | message := ParseMessage(testMessage) 500 | privateMessage := message.(*PrivateMessage) 501 | 502 | assertIntsEqual(t, 5000, privateMessage.Bits) 503 | } 504 | 505 | func TestCanParseCLEARCHATMessage(t *testing.T) { 506 | testMessage := "@room-id=408892348;tmi-sent-ts=1551538661807 :tmi.twitch.tv CLEARCHAT #clippyassistant" 507 | 508 | message := ParseMessage(testMessage) 509 | clearchatMessage := message.(*ClearChatMessage) 510 | 511 | if clearchatMessage.Type != CLEARCHAT { 512 | t.Error("parsing MessageType failed") 513 | } 514 | assertStringsEqual(t, "CLEARCHAT", clearchatMessage.RawType) 515 | assertStringsEqual(t, "", clearchatMessage.Message) 516 | assertStringsEqual(t, clearchatMessage.Channel, "clippyassistant") 517 | assertStringsEqual(t, "408892348", clearchatMessage.RoomID) 518 | } 519 | 520 | func TestCanParseBanMessage(t *testing.T) { 521 | testMessage := "@room-id=408892348;target-user-id=269899575;tmi-sent-ts=1551538522968 :tmi.twitch.tv CLEARCHAT #clippyassistant :fletchercodes" 522 | 523 | message := ParseMessage(testMessage) 524 | clearchatMessage := message.(*ClearChatMessage) 525 | 526 | if clearchatMessage.Type != CLEARCHAT { 527 | t.Error("parsing MessageType failed") 528 | } 529 | assertStringsEqual(t, "CLEARCHAT", clearchatMessage.RawType) 530 | assertStringsEqual(t, "", clearchatMessage.Message) 531 | assertStringsEqual(t, clearchatMessage.Channel, "clippyassistant") 532 | assertStringsEqual(t, "408892348", clearchatMessage.RoomID) 533 | assertIntsEqual(t, 0, clearchatMessage.BanDuration) 534 | assertStringsEqual(t, "269899575", clearchatMessage.TargetUserID) 535 | assertStringsEqual(t, "fletchercodes", clearchatMessage.TargetUsername) 536 | } 537 | 538 | func TestCanParseTimeoutMessage(t *testing.T) { 539 | testMessage := "@ban-duration=5;room-id=408892348;target-user-id=269899575;tmi-sent-ts=1551538496775 :tmi.twitch.tv CLEARCHAT #clippyassistant :fletchercodes" 540 | 541 | message := ParseMessage(testMessage) 542 | clearchatMessage := message.(*ClearChatMessage) 543 | 544 | assertIntsEqual(t, 5, clearchatMessage.BanDuration) 545 | } 546 | 547 | func TestCanParseCLEARMSGMessage(t *testing.T) { 548 | testMessage := "@login=ronni;target-msg-id=abc-123-def :tmi.twitch.tv CLEARMSG #dallas :HeyGuys" 549 | 550 | message := ParseMessage(testMessage) 551 | clearMessage := message.(*ClearMessage) 552 | 553 | if clearMessage.Type != CLEARMSG { 554 | t.Error("parsing MessageType failed") 555 | } 556 | assertStringsEqual(t, "CLEARMSG", clearMessage.RawType) 557 | assertStringsEqual(t, "HeyGuys", clearMessage.Message) 558 | assertStringsEqual(t, "dallas", clearMessage.Channel) 559 | assertStringsEqual(t, "ronni", clearMessage.Login) 560 | assertStringsEqual(t, "abc-123-def", clearMessage.TargetMsgID) 561 | } 562 | 563 | func TestCanParseROOMSTATEMessage(t *testing.T) { 564 | testMessage := "@broadcaster-lang=en;emote-only=0;followers-only=-1;r9k=1;rituals=0;room-id=408892348;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #clippyassistant" 565 | 566 | message := ParseMessage(testMessage) 567 | roomstateMessage := message.(*RoomStateMessage) 568 | 569 | if roomstateMessage.Type != ROOMSTATE { 570 | t.Error("parsing MessageType failed") 571 | } 572 | assertStringsEqual(t, "ROOMSTATE", roomstateMessage.RawType) 573 | assertStringsEqual(t, "", roomstateMessage.Message) 574 | assertStringsEqual(t, "clippyassistant", roomstateMessage.Channel) 575 | assertStringsEqual(t, "408892348", roomstateMessage.RoomID) 576 | 577 | expectedState := map[string]int{ 578 | "emote-only": 0, 579 | "followers-only": -1, 580 | "r9k": 1, 581 | "rituals": 0, 582 | "slow": 0, 583 | "subs-only": 0, 584 | } 585 | assertStringIntMapsEqual(t, expectedState, roomstateMessage.State) 586 | } 587 | 588 | func TestCanParseROOMSTATEChangeMessage(t *testing.T) { 589 | testMessage := `@followers-only=10;room-id=408892348 :tmi.twitch.tv ROOMSTATE #clippyassistant` 590 | 591 | message := ParseMessage(testMessage) 592 | roomstateMessage := message.(*RoomStateMessage) 593 | 594 | expectedState := map[string]int{ 595 | "followers-only": 10, 596 | } 597 | assertStringIntMapsEqual(t, expectedState, roomstateMessage.State) 598 | } 599 | 600 | func TestCanParseUSERNOTICESubMessage(t *testing.T) { 601 | testMessage := "@badges=subscriber/0,premium/1;color=;display-name=FletcherCodes;emotes=;flags=;id=57cbe8d9-8d17-4760-b1e7-0d888e1fdc60;login=fletchercodes;mod=0;msg-id=sub;msg-param-cumulative-months=0;msg-param-months=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=The\\sWhatevas;msg-param-sub-plan=Prime;room-id=408892348;subscriber=1;system-msg=fletchercodes\\ssubscribed\\swith\\sTwitch\\sPrime.;tmi-sent-ts=1551486064328;turbo=0;user-id=269899575;user-type= :tmi.twitch.tv USERNOTICE #clippyassistant" 602 | 603 | message := ParseMessage(testMessage) 604 | usernoticeMessage := message.(*UserNoticeMessage) 605 | user := usernoticeMessage.User 606 | 607 | assertStringsEqual(t, "269899575", user.ID) 608 | assertStringsEqual(t, "fletchercodes", user.Name) 609 | assertStringsEqual(t, "FletcherCodes", user.DisplayName) 610 | assertStringsEqual(t, "", user.Color) 611 | 612 | expectedBadges := map[string]int{ 613 | "subscriber": 0, 614 | "premium": 1, 615 | } 616 | assertStringIntMapsEqual(t, expectedBadges, user.Badges) 617 | 618 | if usernoticeMessage.Type != USERNOTICE { 619 | t.Error("parsing MessageType failed") 620 | } 621 | assertStringsEqual(t, "USERNOTICE", usernoticeMessage.RawType) 622 | assertStringsEqual(t, "", usernoticeMessage.Message) 623 | assertStringsEqual(t, "clippyassistant", usernoticeMessage.Channel) 624 | assertStringsEqual(t, "408892348", usernoticeMessage.RoomID) 625 | assertStringsEqual(t, "57cbe8d9-8d17-4760-b1e7-0d888e1fdc60", usernoticeMessage.ID) 626 | assertIntsEqual(t, 0, len(usernoticeMessage.Emotes)) 627 | assertStringsEqual(t, "sub", usernoticeMessage.MsgID) 628 | 629 | expectedParams := map[string]string{ 630 | "msg-param-cumulative-months": "0", 631 | "msg-param-months": "0", 632 | "msg-param-should-share-streak": "0", 633 | "msg-param-sub-plan-name": "The Whatevas", 634 | "msg-param-sub-plan": "Prime", 635 | } 636 | assertStringMapsEqual(t, expectedParams, usernoticeMessage.MsgParams) 637 | 638 | assertStringsEqual(t, "fletchercodes subscribed with Twitch Prime.", usernoticeMessage.SystemMsg) 639 | } 640 | 641 | func TestCanParseUSERNOTICESubGiftMessage(t *testing.T) { 642 | testMessage := "@badges=subscriber/0,premium/1;color=#00FF7F;display-name=FletcherCodes;emotes=;flags=;id=b608909e-2089-4f97-9475-f2cd93f6717a;login=fletchercodes;mod=0;msg-id=subgift;msg-param-months=1;msg-param-origin-id=da\\s39\\sa3\\see\\s5e\\s6b\\s4b\\s0d\\s32\\s55\\sbf\\sef\\s95\\s60\\s18\\s90\\saf\\sd8\\s07\\s09;msg-param-recipient-display-name=NSFletcher;msg-param-recipient-id=418105091;msg-param-recipient-user-name=nsfletcher;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(clippyassistant);msg-param-sub-plan=1000;room-id=408892348;subscriber=1;system-msg=FletcherCodes\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\sNSFletcher!;tmi-sent-ts=1551487298580;turbo=0;user-id=79793581;user-type= :tmi.twitch.tv USERNOTICE #clippyassistant" 643 | 644 | message := ParseMessage(testMessage) 645 | usernoticeMessage := message.(*UserNoticeMessage) 646 | 647 | assertStringsEqual(t, "subgift", usernoticeMessage.MsgID) 648 | 649 | expectedParams := map[string]string{ 650 | "msg-param-months": "1", 651 | "msg-param-origin-id": "da 39 a3 ee 5e 6b 4b 0d 32 55 bf ef 95 60 18 90 af d8 07 09", 652 | "msg-param-recipient-display-name": "NSFletcher", 653 | "msg-param-recipient-id": "418105091", 654 | "msg-param-recipient-user-name": "nsfletcher", 655 | "msg-param-sender-count": "0", 656 | "msg-param-sub-plan-name": "Channel Subscription (clippyassistant)", 657 | "msg-param-sub-plan": "1000", 658 | } 659 | assertStringMapsEqual(t, expectedParams, usernoticeMessage.MsgParams) 660 | 661 | assertStringsEqual(t, "FletcherCodes gifted a Tier 1 sub to NSFletcher!", usernoticeMessage.SystemMsg) 662 | } 663 | 664 | func TestCanParseUSERNOTICEAnonymousGiftSubMessage(t *testing.T) { 665 | testMessage := `@badges=broadcaster/1,subscriber/6;color=;display-name=qa_subs_partner;emotes=;flags=;id=b1818e3c-0005-490f-ad0a-804957ddd760;login=qa_subs_partner;mod=0;msg-id=anonsubgift;msg-param-months=3;msg-param-recipient-display-name=TenureCalculator;msg-param-recipient-id=135054130;msg-param-recipient-user-name=tenurecalculator;msg-param-sub-plan-name=t111;msg-param-sub-plan=1000;room-id=196450059;subscriber=1;system-msg=An\sanonymous\suser\sgifted\sa\sTier\s1\ssub\sto\sTenureCalculator!\s;tmi-sent-ts=1542063432068;turbo=0;user-id=196450059;user-type= :tmi.twitch.tv USERNOTICE #qa_subs_partner` 666 | 667 | message := ParseMessage(testMessage) 668 | usernoticeMessage := message.(*UserNoticeMessage) 669 | 670 | assertStringsEqual(t, "anonsubgift", usernoticeMessage.MsgID) 671 | 672 | expectedParams := map[string]string{ 673 | "msg-param-months": "3", 674 | "msg-param-recipient-display-name": "TenureCalculator", // Maybe create a target User 675 | "msg-param-recipient-id": "135054130", 676 | "msg-param-recipient-user-name": "tenurecalculator", 677 | "msg-param-sub-plan-name": "t111", 678 | "msg-param-sub-plan": "1000", 679 | } 680 | assertStringMapsEqual(t, expectedParams, usernoticeMessage.MsgParams) 681 | 682 | assertStringsEqual(t, "An anonymous user gifted a Tier 1 sub to TenureCalculator!", usernoticeMessage.SystemMsg) 683 | } 684 | 685 | func TestCanParseUSERNOTICERaidMessage(t *testing.T) { 686 | testMessage := "@badges=partner/1;color=#00FF7F;display-name=FletcherCodes;emotes=;flags=;id=7a61cd41-f049-466b-9654-43e5bfc554aa;login=fletchercodes;mod=0;msg-id=raid;msg-param-displayName=FletcherCodes;msg-param-login=fletchercodes;msg-param-profileImageURL=https://static-cdn.jtvnw.net/jtv_user_pictures/herr_currywurst-profile_image-e6c037c9d321b955-70x70.jpeg;msg-param-viewerCount=538;room-id=269899575;subscriber=0;system-msg=538\\sraiders\\sfrom\\sFletcherCodes\\shave\\sjoined\\n!;tmi-sent-ts=1551490358542;turbo=0;user-id=269899575;user-type= :tmi.twitch.tv USERNOTICE #clippyassistant" 687 | 688 | message := ParseMessage(testMessage) 689 | usernoticeMessage := message.(*UserNoticeMessage) 690 | 691 | assertStringsEqual(t, "raid", usernoticeMessage.MsgID) 692 | 693 | expectedParams := map[string]string{ 694 | "msg-param-displayName": "FletcherCodes", 695 | "msg-param-login": "fletchercodes", 696 | "msg-param-profileImageURL": "https://static-cdn.jtvnw.net/jtv_user_pictures/herr_currywurst-profile_image-e6c037c9d321b955-70x70.jpeg", 697 | "msg-param-viewerCount": "538", 698 | } 699 | assertStringMapsEqual(t, expectedParams, usernoticeMessage.MsgParams) 700 | 701 | assertStringsEqual(t, "538 raiders from FletcherCodes have joined!", usernoticeMessage.SystemMsg) 702 | } 703 | 704 | func TestCanParseUSERNOTICEUnraidMessage(t *testing.T) { 705 | testMessage := "@badges=broadcaster/1;color=#8A2BE2;display-name=FletcherCodes;emotes=;flags=;id=06e33f48-c728-4332-b4bc-b7eae6f59f3c;login=fletchercodes;mod=0;msg-id=unraid;room-id=269899575;subscriber=0;system-msg=The\\sraid\\shas\\sbeen\\scancelled.;tmi-sent-ts=1551518456143;turbo=0;user-id=269899575;user-type= :tmi.twitch.tv USERNOTICE #fletchercodes" 706 | 707 | message := ParseMessage(testMessage) 708 | usernoticeMessage := message.(*UserNoticeMessage) 709 | 710 | assertStringsEqual(t, "unraid", usernoticeMessage.MsgID) 711 | 712 | expectedParams := map[string]string{} 713 | assertStringMapsEqual(t, expectedParams, usernoticeMessage.MsgParams) 714 | 715 | assertStringsEqual(t, "The raid has been cancelled.", usernoticeMessage.SystemMsg) 716 | } 717 | 718 | func TestCanParseUSERNOTICERitualMessage(t *testing.T) { 719 | testMessage := "@badges=;color=;display-name=FletcherCodes;emotes=64138:0-8;flags=;id=e4090aa9-8079-41ff-904d-64c7a2193ee0;login=fletchercodes;mod=0;msg-id=ritual;msg-param-ritual-name=new_chatter;room-id=408892348;subscriber=0;system-msg=@FletcherCodes\\sis\\snew\\shere.\\sSay\\shello!;tmi-sent-ts=1551487438943;turbo=0;user-id=412636239;user-type= :tmi.twitch.tv USERNOTICE #clippyassistant :SeemsGood" 720 | 721 | message := ParseMessage(testMessage) 722 | usernoticeMessage := message.(*UserNoticeMessage) 723 | 724 | assertStringsEqual(t, "SeemsGood", usernoticeMessage.Message) 725 | assertStringsEqual(t, "ritual", usernoticeMessage.MsgID) 726 | 727 | expectedParams := map[string]string{ 728 | "msg-param-ritual-name": "new_chatter", 729 | } 730 | assertStringMapsEqual(t, expectedParams, usernoticeMessage.MsgParams) 731 | 732 | assertStringsEqual(t, "@FletcherCodes is new here. Say hello!", usernoticeMessage.SystemMsg) 733 | } 734 | 735 | func TestCanParseUSERSTATEMessage(t *testing.T) { 736 | testMessage := "@badges=;color=#1E90FF;display-name=FletcherCodes;emote-sets=0,87321,269983,269986,568076,1548253;mod=0;subscriber=0;user-type= :tmi.twitch.tv USERSTATE #clippyassistant" 737 | 738 | message := ParseMessage(testMessage) 739 | userstateMessage := message.(*UserStateMessage) 740 | user := userstateMessage.User 741 | 742 | assertStringsEqual(t, "", user.ID) 743 | assertStringsEqual(t, "fletchercodes", user.Name) 744 | assertStringsEqual(t, "FletcherCodes", user.DisplayName) 745 | assertStringsEqual(t, "#1E90FF", user.Color) 746 | 747 | expectedBadges := map[string]int{} 748 | assertStringIntMapsEqual(t, expectedBadges, user.Badges) 749 | 750 | if userstateMessage.Type != USERSTATE { 751 | t.Error("parsing MessageType failed") 752 | } 753 | assertStringsEqual(t, "USERSTATE", userstateMessage.RawType) 754 | assertStringsEqual(t, "", userstateMessage.Message) 755 | assertStringsEqual(t, "clippyassistant", userstateMessage.Channel) 756 | 757 | expectedEmoteSets := []string{"0", "87321", "269983", "269986", "568076", "1548253"} 758 | assertStringSlicesEqual(t, expectedEmoteSets, userstateMessage.EmoteSets) 759 | } 760 | 761 | func TestCanParseNOTICEMessage(t *testing.T) { 762 | testMessage := "@msg-id=subs_on :tmi.twitch.tv NOTICE #clippyassistant :This room is now in subscribers-only mode." 763 | 764 | message := ParseMessage(testMessage) 765 | noticeMessage := message.(*NoticeMessage) 766 | 767 | if noticeMessage.Type != NOTICE { 768 | t.Error("parsing MessageType failed") 769 | } 770 | assertStringsEqual(t, "NOTICE", noticeMessage.RawType) 771 | assertStringsEqual(t, "This room is now in subscribers-only mode.", noticeMessage.Message) 772 | assertStringsEqual(t, "clippyassistant", noticeMessage.Channel) 773 | assertStringsEqual(t, "subs_on", noticeMessage.MsgID) 774 | } 775 | 776 | func TestCanParsePING1(t *testing.T) { 777 | testMessage := `PING :tmi.twitch.tv` 778 | rawMessage := ParseMessage(testMessage) 779 | message := rawMessage.(*PingMessage) 780 | 781 | assertStringsEqual(t, message.Message, "tmi.twitch.tv") 782 | assertMessageTypesEqual(t, PING, message.GetType()) 783 | } 784 | 785 | func TestCanParsePING2(t *testing.T) { 786 | testMessage := `:tmi.twitch.tv PING :message` 787 | rawMessage := ParseMessage(testMessage) 788 | message := rawMessage.(*PingMessage) 789 | 790 | assertStringsEqual(t, message.Message, "message") 791 | assertMessageTypesEqual(t, PING, message.GetType()) 792 | } 793 | 794 | func TestCanParsePING3(t *testing.T) { 795 | testMessage := `PING` 796 | rawMessage := ParseMessage(testMessage) 797 | message := rawMessage.(*PingMessage) 798 | 799 | assertStringsEqual(t, message.Message, "") 800 | assertMessageTypesEqual(t, PING, message.GetType()) 801 | } 802 | 803 | func TestCanParsePING4(t *testing.T) { 804 | testMessage := `PING :message` 805 | rawMessage := ParseMessage(testMessage) 806 | message := rawMessage.(*PingMessage) 807 | 808 | assertStringsEqual(t, message.Message, "message") 809 | assertMessageTypesEqual(t, PING, message.GetType()) 810 | } 811 | 812 | func TestCanParsePING5(t *testing.T) { 813 | testMessage := `PING :message anything after the first space should be ignored` 814 | rawMessage := ParseMessage(testMessage) 815 | message := rawMessage.(*PingMessage) 816 | 817 | assertStringsEqual(t, message.Message, "message") 818 | assertMessageTypesEqual(t, PING, message.GetType()) 819 | } 820 | 821 | // potential other ping messages they could send according to the irc standard 822 | // testMessage3 := `:tmi.twitch.tv PING :a b c` // reply a 823 | // testMessage4 := `:tmi.twitch.tv PING` // reply PONG 824 | 825 | func TestCanParsePONG1(t *testing.T) { 826 | testMessage := `:tmi.twitch.tv PONG tmi.twitch.tv :go-twitch-irc` 827 | rawMessage := ParseMessage(testMessage) 828 | message := rawMessage.(*PongMessage) 829 | 830 | assertStringsEqual(t, message.Message, "go-twitch-irc") 831 | assertMessageTypesEqual(t, PONG, message.GetType()) 832 | } 833 | 834 | func TestCanParsePONG2(t *testing.T) { 835 | testMessage := `:tmi.twitch.tv PONG tmi.twitch.tv :go-twitch-irc lol` 836 | rawMessage := ParseMessage(testMessage) 837 | message := rawMessage.(*PongMessage) 838 | 839 | assertStringsEqual(t, message.Message, "go-twitch-irc") 840 | assertMessageTypesEqual(t, PONG, message.GetType()) 841 | } 842 | 843 | func TestCanParsePONG3(t *testing.T) { 844 | testMessage := `PONG :tmi.twitch.tv` 845 | rawMessage := ParseMessage(testMessage) 846 | message := rawMessage.(*PongMessage) 847 | 848 | assertStringsEqual(t, message.Message, "") 849 | assertMessageTypesEqual(t, PONG, message.GetType()) 850 | } 851 | 852 | func TestPRIVMSGEmotesParsedProperly(t *testing.T) { 853 | type test struct { 854 | name string 855 | message string 856 | } 857 | tests := []test{ 858 | { 859 | "Normal PRIVMSG", 860 | "@badge-info=subscriber/52;badges=broadcaster/1,subscriber/48,partner/1;color=#CC44FF;display-name=pajlada;emotes=25:6-10/1902:16-20;flags=;id=2a3f9d35-5487-4239-80b3-6c9a5a1907a9;mod=0;room-id=11148817;subscriber=1;tmi-sent-ts=1587291978478;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :-tags Kappa 123 Keepo", 861 | }, 862 | { 863 | "Action PRIVMSG", 864 | "@badge-info=subscriber/52;badges=broadcaster/1,subscriber/48,partner/1;color=#CC44FF;display-name=pajlada;emotes=25:6-10/1902:16-20;flags=;id=0c46c822-f668-4427-b19a-a1a0780a44ae;mod=0;room-id=11148817;subscriber=1;tmi-sent-ts=1587291881363;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :\x01ACTION -tags Kappa 123 Keepo\x01", 865 | }, 866 | } 867 | 868 | for _, tt := range tests { 869 | func(tt test) { 870 | t.Run(tt.name, func(t *testing.T) { 871 | message := ParseMessage(tt.message) 872 | privateMessage := message.(*PrivateMessage) 873 | assertIntsEqual(t, len(privateMessage.Emotes), 2) 874 | 875 | // Emote 1: Kappa at 6-10 876 | assertStringsEqual(t, privateMessage.Emotes[0].Name, "Kappa") 877 | assertStringsEqual(t, privateMessage.Emotes[0].ID, "25") 878 | 879 | // Emote 2: Keepo at 16-20 880 | assertStringsEqual(t, privateMessage.Emotes[1].Name, "Keepo") 881 | assertStringsEqual(t, privateMessage.Emotes[1].ID, "1902") 882 | }) 883 | }(tt) 884 | } 885 | } 886 | 887 | func TestPRIVMSGMalformedEmotesDontCrash(t *testing.T) { 888 | type test struct { 889 | name string 890 | message string 891 | } 892 | tests := []test{ 893 | { 894 | "Broken #1", 895 | "@badge-info=subscriber/52;badges=moderator/1,subscriber/48;color=#2E8B57;display-name=pajbot;emotes=80481_/3:7-14;flags=;id=1ec936d3-7853-4113-9984-664ac5c42694;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1589640131796;turbo=0;user-id=82008718;user-type=mod :pajbot!pajbot@pajbot.tmi.twitch.tv PRIVMSG #pajlada :󠀀-tags pajaW_/3.0", 896 | }, 897 | } 898 | 899 | for _, tt := range tests { 900 | func(tt test) { 901 | t.Run(tt.name, func(t *testing.T) { 902 | ParseMessage(tt.message) 903 | }) 904 | }(tt) 905 | } 906 | } 907 | 908 | func TestCanParseGlobalUserStateMessage(t *testing.T) { 909 | testMessage := "@badge-info=;badges=;color=#2E8B57;display-name=pajbot;emote-sets=0,15961,24569,24570;user-id=82008718;user-type= :tmi.twitch.tv GLOBALUSERSTATE" 910 | 911 | message := ParseMessage(testMessage) 912 | globalUserStateMessage := message.(*GlobalUserStateMessage) 913 | user := globalUserStateMessage.User 914 | 915 | assertStringsEqual(t, "82008718", user.ID) 916 | assertStringsEqual(t, "pajbot", user.Name) 917 | assertStringsEqual(t, "pajbot", user.DisplayName) 918 | assertStringsEqual(t, "#2E8B57", user.Color) 919 | 920 | expectedBadges := map[string]int{} 921 | assertStringIntMapsEqual(t, expectedBadges, user.Badges) 922 | 923 | if globalUserStateMessage.Type != GLOBALUSERSTATE { 924 | t.Error("parsing MessageType failed") 925 | } 926 | assertStringsEqual(t, "GLOBALUSERSTATE", globalUserStateMessage.RawType) 927 | 928 | expectedEmoteSets := []string{"0", "15961", "24569", "24570"} 929 | assertStringSlicesEqual(t, expectedEmoteSets, globalUserStateMessage.EmoteSets) 930 | } 931 | 932 | func TestParseBadgesWithEmptyString(t *testing.T) { 933 | // Test that parseBadges handles empty string without panic 934 | badges := parseBadges("") 935 | expectedBadges := map[string]int{} 936 | assertStringIntMapsEqual(t, expectedBadges, badges) 937 | } 938 | 939 | func TestCanParseSharedChatMessageWithEmptySourceBadges(t *testing.T) { 940 | // Test parsing a shared chat message with empty source-badges to ensure no panic 941 | testMessage := "@badge-info=;badges=moderator/1;color=#FF0000;display-name=TestUser;emotes=;flags=;id=test-id;mod=1;room-id=123456;source-badges=;source-id=source-test-id;source-room-id=654321;subscriber=0;tmi-sent-ts=1234567890000;turbo=0;user-id=987654;user-type=mod :testuser!testuser@testuser.tmi.twitch.tv PRIVMSG #testchannel :Hello from shared chat" 942 | 943 | message := ParseMessage(testMessage) 944 | privateMessage := message.(*PrivateMessage) 945 | 946 | // Verify the message parsed correctly 947 | if privateMessage.Type != PRIVMSG { 948 | t.Error("parsing MessageType failed") 949 | } 950 | assertStringsEqual(t, "PRIVMSG", privateMessage.RawType) 951 | assertStringsEqual(t, "Hello from shared chat", privateMessage.Message) 952 | assertStringsEqual(t, "testchannel", privateMessage.Channel) 953 | 954 | // Verify the Source is populated 955 | if privateMessage.Source == nil { 956 | t.Error("Source should not be nil for shared chat message") 957 | } else { 958 | assertStringsEqual(t, "source-test-id", privateMessage.Source.ID) 959 | assertStringsEqual(t, "654321", privateMessage.Source.RoomID) 960 | // Verify that source badges is an empty map (not nil, no panic) 961 | expectedBadges := map[string]int{} 962 | assertStringIntMapsEqual(t, expectedBadges, privateMessage.Source.Badges) 963 | } 964 | } 965 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package twitch 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "crypto/tls" 7 | "fmt" 8 | "net" 9 | "net/textproto" 10 | "reflect" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "sync/atomic" 16 | "testing" 17 | "time" 18 | ) 19 | 20 | var ( 21 | startPortMutex sync.Mutex 22 | startPort = 10000 23 | ) 24 | 25 | func newPort() (r int) { 26 | startPortMutex.Lock() 27 | r = startPort 28 | startPort++ 29 | startPortMutex.Unlock() 30 | return 31 | } 32 | 33 | func closeOnConnect(c chan struct{}) func(conn net.Conn) { 34 | return func(conn net.Conn) { 35 | close(c) 36 | } 37 | } 38 | 39 | func waitWithTimeout(c chan struct{}) bool { 40 | select { 41 | case <-c: 42 | return true 43 | case <-time.After(time.Second * 3): 44 | return false 45 | } 46 | } 47 | 48 | func closeOnPassReceived(pass *string, c chan struct{}) func(message string) { 49 | return func(message string) { 50 | if strings.HasPrefix(message, "PASS") { 51 | *pass = message 52 | close(c) 53 | } 54 | } 55 | } 56 | 57 | func nothingOnConnect(conn net.Conn) { 58 | } 59 | 60 | func nothingOnMessage(message string) { 61 | } 62 | 63 | func clientCloseOnConnect(c chan struct{}) func() { 64 | return func() { 65 | close(c) 66 | } 67 | } 68 | 69 | func postMessageOnConnect(message string) func(conn net.Conn) { 70 | return func(conn net.Conn) { 71 | fmt.Fprintf(conn, "%s\r\n", message) 72 | } 73 | } 74 | 75 | func postMessagesOnConnect(messages []string) func(conn net.Conn) { 76 | return func(conn net.Conn) { 77 | for _, message := range messages { 78 | fmt.Fprintf(conn, "%s\r\n", message) 79 | } 80 | } 81 | } 82 | 83 | func newTestClient(host string) *Client { 84 | client := NewClient("justinfan123123", "oauth:123123132") 85 | client.IrcAddress = host 86 | 87 | return client 88 | } 89 | 90 | func newAnonymousTestClient(host string) *Client { 91 | client := NewAnonymousClient() 92 | client.IrcAddress = host 93 | 94 | return client 95 | } 96 | 97 | func connectAndEnsureGoodDisconnect(t *testing.T, client *Client) chan struct{} { 98 | c := make(chan struct{}) 99 | 100 | go func() { 101 | err := client.Connect() 102 | assertErrorsEqual(t, ErrClientDisconnected, err) 103 | close(c) 104 | }() 105 | 106 | return c 107 | } 108 | 109 | func handleTestConnection(t *testing.T, onConnect func(net.Conn), onMessage func(string), listener net.Listener, wg *sync.WaitGroup) { 110 | conn, err := listener.Accept() 111 | if err != nil { 112 | t.Error(err) 113 | } 114 | defer func() { 115 | time.Sleep(100 * time.Millisecond) 116 | conn.Close() 117 | wg.Done() 118 | }() 119 | 120 | reader := bufio.NewReader(conn) 121 | tp := textproto.NewReader(reader) 122 | 123 | for { 124 | message, err := tp.ReadLine() 125 | if err != nil { 126 | return 127 | } 128 | message = strings.Replace(message, "\r\n", "", 1) 129 | 130 | if strings.HasPrefix(message, "NICK") { 131 | fmt.Fprintf(conn, ":tmi.twitch.tv 001 justinfan123123 :Welcome, GLHF!\r\n") 132 | onConnect(conn) 133 | continue 134 | } 135 | 136 | if strings.HasPrefix(message, "PASS") { 137 | pass := strings.Split(message, " ")[1] 138 | if !strings.HasPrefix(pass, "oauth:") { 139 | fmt.Fprintf(conn, ":tmi.twitch.tv NOTICE * :Improperly formatted auth\r\n") 140 | return 141 | } else if pass == "oauth:wrong" { 142 | fmt.Fprintf(conn, ":tmi.twitch.tv NOTICE * :Login authentication failed\r\n") 143 | return 144 | } 145 | } 146 | 147 | onMessage(message) 148 | } 149 | } 150 | 151 | type testServer struct { 152 | host string 153 | 154 | stopped chan struct{} 155 | } 156 | 157 | func startServer(t *testing.T, onConnect func(net.Conn), onMessage func(string)) string { 158 | s := startServer2(t, onConnect, onMessage) 159 | return s.host 160 | } 161 | 162 | func startServer2(t *testing.T, onConnect func(net.Conn), onMessage func(string)) *testServer { 163 | s := &testServer{ 164 | host: "127.0.0.1:" + strconv.Itoa(newPort()), 165 | 166 | stopped: make(chan struct{}), 167 | } 168 | 169 | cert, err := tls.LoadX509KeyPair("test_resources/server.crt", "test_resources/server.key") 170 | if err != nil { 171 | t.Fatal(err) 172 | } 173 | config := &tls.Config{ 174 | MinVersion: tls.VersionTLS12, 175 | Certificates: []tls.Certificate{cert}, 176 | } 177 | listener, err := tls.Listen("tcp", s.host, config) 178 | if err != nil { 179 | t.Fatal(err) 180 | } 181 | 182 | wg := sync.WaitGroup{} 183 | wg.Add(1) 184 | go handleTestConnection(t, onConnect, onMessage, listener, &wg) 185 | 186 | go func() { 187 | wg.Wait() 188 | listener.Close() 189 | 190 | close(s.stopped) 191 | }() 192 | 193 | return s 194 | } 195 | 196 | func startServerMultiConns(t *testing.T, numConns int, onConnect func(net.Conn), onMessage func(string)) string { 197 | host := "127.0.0.1:" + strconv.Itoa(newPort()) 198 | 199 | cert, err := tls.LoadX509KeyPair("test_resources/server.crt", "test_resources/server.key") 200 | if err != nil { 201 | t.Fatal(err) 202 | } 203 | config := &tls.Config{ 204 | Certificates: []tls.Certificate{cert}, 205 | } 206 | listener, err := tls.Listen("tcp", host, config) 207 | if err != nil { 208 | t.Fatal(err) 209 | } 210 | 211 | wg := sync.WaitGroup{} 212 | wg.Add(numConns) 213 | 214 | for i := 0; i < numConns; i++ { 215 | go handleTestConnection(t, onConnect, onMessage, listener, &wg) 216 | } 217 | 218 | go func() { 219 | wg.Wait() 220 | listener.Close() 221 | }() 222 | 223 | return host 224 | } 225 | 226 | func startServerMultiConnsNoTLS(t *testing.T, numConns int, onConnect func(net.Conn), onMessage func(string)) string { 227 | host := "127.0.0.1:" + strconv.Itoa(newPort()) 228 | 229 | listener, err := net.Listen("tcp", host) 230 | if err != nil { 231 | t.Fatal(err) 232 | } 233 | 234 | wg := sync.WaitGroup{} 235 | wg.Add(numConns) 236 | 237 | for i := 0; i < numConns; i++ { 238 | go handleTestConnection(t, onConnect, onMessage, listener, &wg) 239 | } 240 | 241 | go func() { 242 | wg.Wait() 243 | listener.Close() 244 | }() 245 | 246 | return host 247 | } 248 | 249 | func startNoTLSServer(t *testing.T, onConnect func(net.Conn), onMessage func(string)) string { 250 | host := "127.0.0.1:" + strconv.Itoa(newPort()) 251 | 252 | listener, err := net.Listen("tcp", host) 253 | if err != nil { 254 | t.Fatal(err) 255 | } 256 | 257 | wg := sync.WaitGroup{} 258 | wg.Add(1) 259 | go handleTestConnection(t, onConnect, onMessage, listener, &wg) 260 | go func() { 261 | wg.Wait() 262 | listener.Close() 263 | }() 264 | 265 | return host 266 | } 267 | 268 | func TestCanConnectAndAuthenticateWithoutTLS(t *testing.T) { 269 | t.Parallel() 270 | const oauthCode = "oauth:123123132" 271 | wait := make(chan struct{}) 272 | 273 | var received string 274 | 275 | host := startNoTLSServer(t, nothingOnConnect, func(message string) { 276 | if strings.HasPrefix(message, "PASS") { 277 | received = message 278 | close(wait) 279 | } 280 | }) 281 | 282 | client := NewClient("justinfan123123", oauthCode) 283 | client.TLS = false 284 | client.IrcAddress = host 285 | client.PongTimeout = time.Second * 30 286 | go func() { 287 | go func() { 288 | _ = client.Connect() 289 | }() 290 | }() 291 | 292 | select { 293 | case <-wait: 294 | case <-time.After(time.Second * 3): 295 | t.Fatal("no oauth read") 296 | } 297 | 298 | assertStringsEqual(t, "PASS "+oauthCode, received) 299 | } 300 | 301 | func TestCanChangeOauthToken(t *testing.T) { 302 | t.Parallel() 303 | const oauthCode = "oauth:123123132" 304 | wait := make(chan bool) 305 | 306 | var received string 307 | 308 | host := startNoTLSServer(t, nothingOnConnect, func(message string) { 309 | if strings.HasPrefix(message, "PASS") { 310 | received = message 311 | wait <- true 312 | } 313 | }) 314 | 315 | client := NewClient("justinfan123123", "wrongoauthcodelol") 316 | client.TLS = false 317 | client.IrcAddress = host 318 | client.SetIRCToken(oauthCode) 319 | go func() { 320 | go func() { 321 | _ = client.Connect() 322 | }() 323 | }() 324 | 325 | select { 326 | case <-wait: 327 | case <-time.After(time.Second * 3): 328 | t.Fatal("no oauth read") 329 | } 330 | 331 | assertStringsEqual(t, "PASS "+oauthCode, received) 332 | } 333 | 334 | func TestCanAddSetupCmd(t *testing.T) { 335 | t.Parallel() 336 | const oauthCode = "oauth:123123132" 337 | const setupCmd = "LOGIN kkonabot" 338 | wait := make(chan bool) 339 | 340 | var received string 341 | 342 | host := startNoTLSServer(t, nothingOnConnect, func(message string) { 343 | if strings.HasPrefix(message, "LOGIN") { 344 | received = message 345 | wait <- true 346 | } 347 | }) 348 | 349 | client := NewClient("justinfan123123", oauthCode) 350 | client.TLS = false 351 | client.IrcAddress = host 352 | client.SetupCmd = setupCmd 353 | go func() { 354 | _ = client.Connect() 355 | }() 356 | 357 | select { 358 | case <-wait: 359 | case <-time.After(time.Second * 3): 360 | t.Fatal("no oauth read") 361 | } 362 | 363 | assertStringsEqual(t, setupCmd, received) 364 | } 365 | 366 | func TestCanCreateClient(t *testing.T) { 367 | t.Parallel() 368 | client := NewClient("justinfan123123", "oauth:1123123") 369 | 370 | if reflect.TypeOf(client) != reflect.TypeOf(&Client{}) { 371 | t.Error("client is not of type Client") 372 | } 373 | } 374 | 375 | func TestCanConnectAndAuthenticate(t *testing.T) { 376 | const oauthCode = "oauth:123123132" 377 | wait := make(chan struct{}) 378 | 379 | var received string 380 | 381 | host := startServer(t, nothingOnConnect, func(message string) { 382 | if strings.HasPrefix(message, "PASS") { 383 | received = message 384 | close(wait) 385 | } 386 | }) 387 | 388 | client := newTestClient(host) 389 | client.PongTimeout = time.Second * 30 390 | connectAndEnsureGoodDisconnect(t, client) 391 | defer func() { 392 | err := client.Disconnect() 393 | if err == nil { 394 | t.Error(err, "connection should not be open") 395 | } 396 | }() 397 | 398 | select { 399 | case <-wait: 400 | case <-time.After(time.Second * 3): 401 | t.Fatal("no oauth read") 402 | } 403 | 404 | assertStringsEqual(t, "PASS "+oauthCode, received) 405 | } 406 | 407 | // This test is meant to be a blueprint for a test that needs the flow completely from server start to server stop 408 | func TestFullConnectAndDisconnect(t *testing.T) { 409 | const oauthCode = "oauth:123123132" 410 | waitPass := make(chan struct{}) 411 | waitServerConnect := make(chan struct{}) 412 | waitClientConnect := make(chan struct{}) 413 | 414 | var received string 415 | 416 | server := startServer2(t, closeOnConnect(waitServerConnect), closeOnPassReceived(&received, waitPass)) 417 | 418 | client := newTestClient(server.host) 419 | client.OnConnect(clientCloseOnConnect(waitClientConnect)) 420 | clientDisconnected := connectAndEnsureGoodDisconnect(t, client) 421 | 422 | // Wait for correct password to be read in server 423 | if !waitWithTimeout(waitPass) { 424 | t.Fatal("no oauth read") 425 | } 426 | 427 | assertStringsEqual(t, "PASS "+oauthCode, received) 428 | 429 | // Wait for server to acknowledge connection 430 | if !waitWithTimeout(waitServerConnect) { 431 | t.Fatal("no successful connection") 432 | } 433 | 434 | // Wait for client to acknowledge connection 435 | if !waitWithTimeout(waitClientConnect) { 436 | t.Fatal("no successful connection") 437 | } 438 | 439 | // Disconnect client from server 440 | err := client.Disconnect() 441 | if err != nil { 442 | t.Error("Error during disconnect:" + err.Error()) 443 | } 444 | 445 | // Wait for client to be fully disconnected 446 | <-clientDisconnected 447 | 448 | // Wait for server to be fully disconnected 449 | <-server.stopped 450 | } 451 | 452 | func TestCanConnectAndAuthenticateAnonymous(t *testing.T) { 453 | const oauthCode = "oauth:59301" 454 | waitPass := make(chan struct{}) 455 | waitServerConnect := make(chan struct{}) 456 | waitClientConnect := make(chan struct{}) 457 | 458 | var received string 459 | 460 | server := startServer2(t, closeOnConnect(waitServerConnect), closeOnPassReceived(&received, waitPass)) 461 | 462 | client := newAnonymousTestClient(server.host) 463 | client.OnConnect(clientCloseOnConnect(waitClientConnect)) 464 | clientDisconnected := connectAndEnsureGoodDisconnect(t, client) 465 | 466 | // Wait for server to acknowledge connection 467 | if !waitWithTimeout(waitServerConnect) { 468 | t.Fatal("no successful connection") 469 | } 470 | 471 | // Wait for client to acknowledge connection 472 | if !waitWithTimeout(waitClientConnect) { 473 | t.Fatal("no successful connection") 474 | } 475 | 476 | // Wait to receive password 477 | select { 478 | case <-waitPass: 479 | case <-time.After(time.Second * 3): 480 | t.Fatal("no oauth read") 481 | } 482 | 483 | assertStringsEqual(t, "PASS "+oauthCode, received) 484 | 485 | // Disconnect client from server 486 | err := client.Disconnect() 487 | if err != nil { 488 | t.Error("Error during disconnect:" + err.Error()) 489 | } 490 | 491 | // Wait for client to be fully disconnected 492 | <-clientDisconnected 493 | 494 | // Wait for server to be fully disconnected 495 | <-server.stopped 496 | } 497 | 498 | func TestCanDisconnect(t *testing.T) { 499 | t.Parallel() 500 | wait := make(chan struct{}) 501 | 502 | host := startServer(t, nothingOnConnect, nothingOnMessage) 503 | client := newTestClient(host) 504 | 505 | client.OnConnect(func() { 506 | close(wait) 507 | }) 508 | 509 | go func() { 510 | _ = client.Connect() 511 | }() 512 | 513 | // wait for server to start 514 | select { 515 | case <-wait: 516 | case <-time.After(time.Second * 3): 517 | t.Fatal("OnConnect did not fire") 518 | } 519 | 520 | if err := client.Disconnect(); err != nil { 521 | t.Fatalf("couldn't disconnect: %s", err.Error()) 522 | } 523 | } 524 | 525 | func TestCanNotDisconnectOnClosedConnection(t *testing.T) { 526 | t.Parallel() 527 | client := NewClient("justinfan123123", "oauth:123123132") 528 | 529 | err := client.Disconnect() 530 | 531 | assertErrorsEqual(t, ErrConnectionIsNotOpen, err) 532 | } 533 | 534 | func TestCanReceivePRIVMSGMessage(t *testing.T) { 535 | t.Parallel() 536 | testMessage := "@badges=subscriber/6,premium/1;color=#FF0000;display-name=Redflamingo13;emotes=;id=2a31a9df-d6ff-4840-b211-a2547c7e656e;mod=0;room-id=11148817;subscriber=1;tmi-sent-ts=1490382457309;turbo=0;user-id=78424343;user-type= :redflamingo13!redflamingo13@redflamingo13.tmi.twitch.tv PRIVMSG #pajlada :Thrashh5, FeelsWayTooAmazingMan kinda" 537 | 538 | wait := make(chan struct{}) 539 | var received string 540 | 541 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 542 | client := newTestClient(host) 543 | 544 | client.OnPrivateMessage(func(message PrivateMessage) { 545 | received = message.Message 546 | assertMessageTypesEqual(t, PRIVMSG, message.GetType()) 547 | close(wait) 548 | }) 549 | 550 | go func() { 551 | _ = client.Connect() 552 | }() 553 | 554 | // wait for server to start 555 | select { 556 | case <-wait: 557 | case <-time.After(time.Second * 3): 558 | t.Fatal("no message sent") 559 | } 560 | 561 | assertStringsEqual(t, "Thrashh5, FeelsWayTooAmazingMan kinda", received) 562 | } 563 | 564 | func TestCanReceiveWHISPERMessage(t *testing.T) { 565 | t.Parallel() 566 | testMessage := "@badges=;color=#00FF7F;display-name=Danielps1;emotes=;message-id=20;thread-id=32591953_77829817;turbo=0;user-id=32591953;user-type= :danielps1!danielps1@danielps1.tmi.twitch.tv WHISPER gempir :i like memes" 567 | 568 | wait := make(chan struct{}) 569 | var received string 570 | 571 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 572 | client := newTestClient(host) 573 | 574 | client.OnWhisperMessage(func(message WhisperMessage) { 575 | received = message.Message 576 | assertMessageTypesEqual(t, WHISPER, message.GetType()) 577 | close(wait) 578 | }) 579 | 580 | go func() { 581 | go func() { 582 | _ = client.Connect() 583 | }() 584 | }() 585 | 586 | // wait for server to start 587 | select { 588 | case <-wait: 589 | case <-time.After(time.Second * 3): 590 | t.Fatal("no message sent") 591 | } 592 | 593 | assertStringsEqual(t, "i like memes", received) 594 | } 595 | 596 | func TestCanReceiveCLEARCHATMessage(t *testing.T) { 597 | t.Parallel() 598 | testMessage := `@ban-duration=1;ban-reason=testing\sxd;room-id=11148817;target-user-id=40910607 :tmi.twitch.tv CLEARCHAT #pajlada :ampzyh` 599 | 600 | wait := make(chan struct{}) 601 | var received int 602 | 603 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 604 | client := newTestClient(host) 605 | 606 | client.OnClearChatMessage(func(message ClearChatMessage) { 607 | received = message.BanDuration 608 | assertMessageTypesEqual(t, CLEARCHAT, message.GetType()) 609 | close(wait) 610 | }) 611 | 612 | go func() { 613 | go func() { 614 | _ = client.Connect() 615 | }() 616 | }() 617 | 618 | // wait for server to start 619 | select { 620 | case <-wait: 621 | case <-time.After(time.Second * 3): 622 | t.Fatal("no message sent") 623 | } 624 | 625 | assertIntsEqual(t, 1, received) 626 | } 627 | 628 | func TestCanReceiveCLEARMSGMessage(t *testing.T) { 629 | t.Parallel() 630 | testMessage := `@login=ronni;target-msg-id=abc-123-def :tmi.twitch.tv CLEARMSG #dallas :HeyGuys` 631 | 632 | wait := make(chan struct{}) 633 | var received string 634 | 635 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 636 | client := newTestClient(host) 637 | 638 | client.OnClearMessage(func(message ClearMessage) { 639 | received = message.Login 640 | assertMessageTypesEqual(t, CLEARMSG, message.GetType()) 641 | close(wait) 642 | }) 643 | 644 | go func() { 645 | go func() { 646 | _ = client.Connect() 647 | }() 648 | }() 649 | 650 | // wait for server to start 651 | select { 652 | case <-wait: 653 | case <-time.After(time.Second * 3): 654 | t.Fatal("no message sent") 655 | } 656 | 657 | assertStringsEqual(t, "ronni", received) 658 | } 659 | 660 | func TestCanReceiveROOMSTATEMessage(t *testing.T) { 661 | t.Parallel() 662 | testMessage := `@slow=10 :tmi.twitch.tv ROOMSTATE #gempir` 663 | 664 | wait := make(chan struct{}) 665 | var received string 666 | 667 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 668 | client := newTestClient(host) 669 | 670 | client.OnRoomStateMessage(func(message RoomStateMessage) { 671 | received = message.Tags["slow"] 672 | assertMessageTypesEqual(t, ROOMSTATE, message.GetType()) 673 | close(wait) 674 | }) 675 | 676 | go func() { 677 | _ = client.Connect() 678 | }() 679 | 680 | // wait for server to start 681 | select { 682 | case <-wait: 683 | case <-time.After(time.Second * 3): 684 | t.Fatal("no message sent") 685 | } 686 | 687 | assertStringsEqual(t, "10", received) 688 | } 689 | 690 | func TestCanReceiveUSERNOTICEMessage(t *testing.T) { 691 | t.Parallel() 692 | testMessage := `@badges=subscriber/12,premium/1;color=#5F9EA0;display-name=blahh;emotes=;id=9154ac04-c9ad-46d5-97ad-15d2dbf244f0;login=deliquid;mod=0;msg-id=resub;msg-param-months=16;msg-param-sub-plan-name=Channel\sSubscription\s(NOTHING);msg-param-sub-plan=Prime;room-id=23161357;subscriber=1;system-msg=blahh\sjust\ssubscribed\swith\sTwitch\sPrime.\sblahh\ssubscribed\sfor\s16\smonths\sin\sa\srow!;tmi-sent-ts=1517165351175;turbo=0;user-id=1234567890;user-type= :tmi.twitch.tv USERNOTICE #nothing` 693 | 694 | wait := make(chan struct{}) 695 | var received string 696 | 697 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 698 | client := newTestClient(host) 699 | 700 | client.OnUserNoticeMessage(func(message UserNoticeMessage) { 701 | received = message.Tags["msg-param-months"] 702 | assertMessageTypesEqual(t, USERNOTICE, message.GetType()) 703 | close(wait) 704 | }) 705 | 706 | go func() { 707 | go func() { 708 | _ = client.Connect() 709 | }() 710 | }() 711 | 712 | select { 713 | case <-wait: 714 | case <-time.After(time.Second * 3): 715 | t.Fatal("no message sent") 716 | } 717 | 718 | assertStringsEqual(t, "16", received) 719 | } 720 | 721 | func TestCanReceiveUSERNOTICEMessageResub(t *testing.T) { 722 | t.Parallel() 723 | testMessage := `@badges=moderator/1,subscriber/24;color=#1FD2FF;display-name=Karl_Kons;emotes=28087:0-6;flags=;id=7c95beea-a7ac-4c10-9e0a-d7dbf163c038;login=karl_kons;mod=1;msg-id=resub;msg-param-months=34;msg-param-sub-plan-name=look\sat\sthose\sshitty\semotes,\srip\s$5\sLUL;msg-param-sub-plan=1000;room-id=11148817;subscriber=1;system-msg=Karl_Kons\sjust\ssubscribed\swith\sa\sTier\s1\ssub.\sKarl_Kons\ssubscribed\sfor\s34\smonths\sin\sa\srow!;tmi-sent-ts=1540140252828;turbo=0;user-id=68706331;user-type=mod :tmi.twitch.tv USERNOTICE #pajlada :WutFace` 724 | 725 | wait := make(chan struct{}) 726 | var received string 727 | 728 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 729 | client := newTestClient(host) 730 | 731 | client.OnUserNoticeMessage(func(message UserNoticeMessage) { 732 | received = message.Tags["msg-param-months"] 733 | close(wait) 734 | }) 735 | 736 | go func() { 737 | go func() { 738 | _ = client.Connect() 739 | }() 740 | }() 741 | 742 | select { 743 | case <-wait: 744 | case <-time.After(time.Second * 3): 745 | t.Fatal("no message sent") 746 | } 747 | 748 | assertStringsEqual(t, "34", received) 749 | } 750 | 751 | func checkNoticeMessage(t *testing.T, testMessage string, requirements map[string]string) { 752 | received := map[string]string{} 753 | wait := make(chan struct{}) 754 | 755 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 756 | client := newTestClient(host) 757 | 758 | client.OnNoticeMessage(func(message NoticeMessage) { 759 | received["msg-id"] = message.Tags["msg-id"] 760 | received["channel"] = message.Channel 761 | received["text"] = message.Message 762 | received["raw"] = message.Raw 763 | assertMessageTypesEqual(t, NOTICE, message.GetType()) 764 | close(wait) 765 | }) 766 | 767 | go func() { 768 | go func() { 769 | _ = client.Connect() 770 | }() 771 | }() 772 | 773 | select { 774 | case <-wait: 775 | case <-time.After(time.Second * 3): 776 | t.Fatal("no message sent") 777 | } 778 | 779 | assertStringsEqual(t, testMessage, received["raw"]) 780 | for key, requirement := range requirements { 781 | assertStringsEqual(t, requirement, received[key]) 782 | } 783 | } 784 | 785 | func TestCanReceiveNOTICEMessage(t *testing.T) { 786 | t.Parallel() 787 | testMessage := `@msg-id=host_on :tmi.twitch.tv NOTICE #pajlada :Now hosting KKona.` 788 | checkNoticeMessage(t, testMessage, map[string]string{ 789 | "msg-id": "host_on", 790 | "channel": "pajlada", 791 | "text": "Now hosting KKona.", 792 | }) 793 | } 794 | 795 | func TestCanReceiveNOTICEMessageTimeout(t *testing.T) { 796 | t.Parallel() 797 | testMessage := `@msg-id=timeout_success :tmi.twitch.tv NOTICE #forsen :thedl0rd has been timed out for 8 minutes 11 seconds.` 798 | checkNoticeMessage(t, testMessage, map[string]string{ 799 | "msg-id": "timeout_success", 800 | "channel": "forsen", 801 | "text": "thedl0rd has been timed out for 8 minutes 11 seconds.", 802 | }) 803 | } 804 | 805 | func TestCanReceiveUSERStateMessage(t *testing.T) { 806 | t.Parallel() 807 | testMessage := `@badges=moderator/1;color=;display-name=blahh;emote-sets=0;mod=1;subscriber=0;user-type=mod :tmi.twitch.tv USERSTATE #nothing` 808 | 809 | wait := make(chan struct{}) 810 | var received string 811 | 812 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 813 | client := newTestClient(host) 814 | 815 | client.OnUserStateMessage(func(message UserStateMessage) { 816 | received = message.Tags["mod"] 817 | assertMessageTypesEqual(t, USERSTATE, message.GetType()) 818 | close(wait) 819 | }) 820 | 821 | go func() { 822 | go func() { 823 | _ = client.Connect() 824 | }() 825 | }() 826 | 827 | select { 828 | case <-wait: 829 | case <-time.After(time.Second * 3): 830 | t.Fatal("no message sent") 831 | } 832 | 833 | assertStringsEqual(t, "1", received) 834 | } 835 | 836 | func TestCanReceiveGlobalUserStateMessage(t *testing.T) { 837 | t.Parallel() 838 | testMessage := `@badge-info=;badges=;color=#00FF7F;display-name=gempbot;emote-sets=0,14417,300206298,300374282,300548762;user-id=99659894;user-type= :tmi.twitch.tv GLOBALUSERSTATE` 839 | 840 | wait := make(chan struct{}) 841 | var received string 842 | 843 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 844 | client := newTestClient(host) 845 | 846 | client.OnGlobalUserStateMessage(func(message GlobalUserStateMessage) { 847 | received = message.Tags["user-id"] 848 | assertMessageTypesEqual(t, GLOBALUSERSTATE, message.GetType()) 849 | close(wait) 850 | }) 851 | 852 | //nolint 853 | go func() { 854 | go func() { 855 | _ = client.Connect() 856 | }() 857 | }() 858 | 859 | select { 860 | case <-wait: 861 | case <-time.After(time.Second * 3): 862 | t.Fatal("no message sent") 863 | } 864 | 865 | assertStringsEqual(t, "99659894", received) 866 | } 867 | 868 | func TestCanReceiveJOINMessage(t *testing.T) { 869 | t.Parallel() 870 | testMessage := `:username123!username123@username123.tmi.twitch.tv JOIN #mychannel` 871 | 872 | wait := make(chan struct{}) 873 | var received UserJoinMessage 874 | 875 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 876 | client := newTestClient(host) 877 | 878 | client.OnUserJoinMessage(func(message UserJoinMessage) { 879 | received = message 880 | close(wait) 881 | }) 882 | 883 | go func() { 884 | go func() { 885 | _ = client.Connect() 886 | }() 887 | }() 888 | 889 | // wait for server to start 890 | select { 891 | case <-wait: 892 | case <-time.After(time.Second * 3): 893 | t.Fatal("no message sent") 894 | } 895 | 896 | assertStringsEqual(t, "username123", received.User) 897 | assertStringsEqual(t, "mychannel", received.Channel) 898 | assertMessageTypesEqual(t, JOIN, received.GetType()) 899 | } 900 | 901 | func TestReceiveJOINMessageWithSelfJOIN(t *testing.T) { 902 | t.Parallel() 903 | testMessages := []string{ 904 | `:justinfan123123!justinfan123123@justinfan123123.tmi.twitch.tv JOIN #mychannel`, 905 | `:username123!username123@username123.tmi.twitch.tv JOIN #mychannel`, 906 | } 907 | 908 | var receivedOther UserJoinMessage 909 | var receivedSelf UserJoinMessage 910 | 911 | host := startServer(t, postMessagesOnConnect(testMessages), nothingOnMessage) 912 | client := newTestClient(host) 913 | 914 | wg := new(sync.WaitGroup) 915 | wg.Add(2) 916 | 917 | client.OnUserJoinMessage(func(message UserJoinMessage) { 918 | receivedOther = message 919 | wg.Done() 920 | }) 921 | 922 | client.OnSelfJoinMessage(func(message UserJoinMessage) { 923 | receivedSelf = message 924 | wg.Done() 925 | }) 926 | 927 | go func() { 928 | go func() { 929 | _ = client.Connect() 930 | }() 931 | }() 932 | 933 | // hack with ctx makes it possible to use it in select statement below 934 | ctx, cancel := context.WithCancel(context.Background()) 935 | go func() { 936 | wg.Wait() 937 | cancel() 938 | }() 939 | 940 | // wait for server to start 941 | select { 942 | case <-ctx.Done(): 943 | case <-time.After(time.Second * 3): 944 | t.Fatal("no message sent") 945 | } 946 | 947 | assertStringsEqual(t, "username123", receivedOther.User) 948 | assertStringsEqual(t, "mychannel", receivedOther.Channel) 949 | assertMessageTypesEqual(t, JOIN, receivedOther.GetType()) 950 | 951 | assertStringsEqual(t, "justinfan123123", receivedSelf.User) 952 | assertStringsEqual(t, "mychannel", receivedSelf.Channel) 953 | assertMessageTypesEqual(t, JOIN, receivedSelf.GetType()) 954 | } 955 | 956 | func TestCanReceivePARTMessage(t *testing.T) { 957 | t.Parallel() 958 | testMessage := `:username123!username123@username123.tmi.twitch.tv PART #mychannel` 959 | 960 | wait := make(chan struct{}) 961 | var received UserPartMessage 962 | 963 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 964 | client := newTestClient(host) 965 | 966 | client.OnUserPartMessage(func(message UserPartMessage) { 967 | received = message 968 | close(wait) 969 | }) 970 | 971 | go func() { 972 | _ = client.Connect() 973 | }() 974 | 975 | // wait for server to start 976 | select { 977 | case <-wait: 978 | case <-time.After(time.Second * 3): 979 | t.Fatal("no message sent") 980 | } 981 | 982 | assertStringsEqual(t, "username123", received.User) 983 | assertStringsEqual(t, "mychannel", received.Channel) 984 | assertMessageTypesEqual(t, PART, received.GetType()) 985 | } 986 | 987 | func TestReceivePARTMessageWithSelfPART(t *testing.T) { 988 | t.Parallel() 989 | testMessages := []string{ 990 | `:justinfan123123!justinfan123123@justinfan123123.tmi.twitch.tv PART #mychannel`, 991 | `:username123!username123@username123.tmi.twitch.tv PART #mychannel`, 992 | } 993 | 994 | var receivedOther UserPartMessage 995 | var receivedSelf UserPartMessage 996 | 997 | host := startServer(t, postMessagesOnConnect(testMessages), nothingOnMessage) 998 | client := newTestClient(host) 999 | 1000 | wg := new(sync.WaitGroup) 1001 | wg.Add(2) 1002 | 1003 | client.OnUserPartMessage(func(message UserPartMessage) { 1004 | receivedOther = message 1005 | wg.Done() 1006 | }) 1007 | 1008 | client.OnSelfPartMessage(func(message UserPartMessage) { 1009 | receivedSelf = message 1010 | wg.Done() 1011 | }) 1012 | 1013 | go func() { 1014 | go func() { 1015 | _ = client.Connect() 1016 | }() 1017 | }() 1018 | 1019 | // hack with ctx makes it possible to use it in select statement below 1020 | ctx, cancel := context.WithCancel(context.Background()) 1021 | go func() { 1022 | wg.Wait() 1023 | cancel() 1024 | }() 1025 | 1026 | // wait for server to start 1027 | select { 1028 | case <-ctx.Done(): 1029 | case <-time.After(time.Second * 3): 1030 | t.Fatal("no message sent") 1031 | } 1032 | 1033 | assertStringsEqual(t, "username123", receivedOther.User) 1034 | assertStringsEqual(t, "mychannel", receivedOther.Channel) 1035 | assertMessageTypesEqual(t, PART, receivedOther.GetType()) 1036 | 1037 | assertStringsEqual(t, "justinfan123123", receivedSelf.User) 1038 | assertStringsEqual(t, "mychannel", receivedSelf.Channel) 1039 | assertMessageTypesEqual(t, PART, receivedSelf.GetType()) 1040 | } 1041 | 1042 | func TestCanReceiveUNSETMessage(t *testing.T) { 1043 | t.Parallel() 1044 | testMessage := `@badges=moderator/1,subscriber/24;color=#1FD2FF;display-name=Karl_Kons;emotes=28087:0-6;flags=;id=7c95beea-a7ac-4c10-9e0a-d7dbf163c038;login=karl_kons;mod=1;msg-id=resub;msg-param-months=34;msg-param-sub-plan-name=look\sat\sthose\sshitty\semotes,\srip\s$5\sLUL;msg-param-sub-plan=1000;room-id=11148817;subscriber=1;system-msg=Karl_Kons\sjust\ssubscribed\swith\sa\sTier\s1\ssub.\sKarl_Kons\ssubscribed\sfor\s34\smonths\sin\sa\srow!;tmi-sent-ts=1540140252828;turbo=0;user-id=68706331;user-type=mod :tmi.twitch.tv MALFORMEDMESSAGETYPETHISWILLBEUNSET #pajlada :WutFace` 1045 | 1046 | wait := make(chan struct{}) 1047 | var received RawMessage 1048 | 1049 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 1050 | client := newTestClient(host) 1051 | 1052 | client.OnUnsetMessage(func(rawMessage RawMessage) { 1053 | if rawMessage.RawType == "MALFORMEDMESSAGETYPETHISWILLBEUNSET" { 1054 | received = rawMessage 1055 | close(wait) 1056 | } 1057 | }) 1058 | 1059 | go func() { 1060 | go func() { 1061 | _ = client.Connect() 1062 | }() 1063 | }() 1064 | 1065 | select { 1066 | case <-wait: 1067 | case <-time.After(time.Second * 3): 1068 | t.Fatal("no message sent") 1069 | } 1070 | 1071 | assertStringsEqual(t, testMessage, received.Raw) 1072 | assertMessageTypesEqual(t, UNSET, received.GetType()) 1073 | } 1074 | 1075 | func TestCanHandleRECONNECTMessage(t *testing.T) { 1076 | t.Parallel() 1077 | const testMessage = ":tmi.twitch.tv RECONNECT" 1078 | 1079 | wait := make(chan bool) 1080 | 1081 | var received ReconnectMessage 1082 | 1083 | var connCount int32 1084 | 1085 | host := startServerMultiConns(t, 2, func(conn net.Conn) { 1086 | atomic.AddInt32(&connCount, 1) 1087 | wait <- true 1088 | time.AfterFunc(100*time.Millisecond, func() { 1089 | fmt.Fprintf(conn, "%s\r\n", testMessage) 1090 | }) 1091 | }, nothingOnMessage) 1092 | client := newTestClient(host) 1093 | client.OnReconnectMessage(func(msg ReconnectMessage) { 1094 | received = msg 1095 | }) 1096 | 1097 | go func() { 1098 | _ = client.Connect() 1099 | }() 1100 | 1101 | // wait for server to start 1102 | select { 1103 | case <-wait: 1104 | case <-time.After(time.Second * 3): 1105 | t.Fatal("no message sent") 1106 | } 1107 | 1108 | assertInt32sEqual(t, 1, atomic.LoadInt32(&connCount)) 1109 | 1110 | select { 1111 | case <-wait: 1112 | case <-time.After(time.Second * 3): 1113 | t.Fatal("no message sent") 1114 | } 1115 | 1116 | assertInt32sEqual(t, 2, atomic.LoadInt32(&connCount)) 1117 | 1118 | assertMessageTypesEqual(t, RECONNECT, received.GetType()) 1119 | } 1120 | 1121 | func TestCanSayMessage(t *testing.T) { 1122 | t.Parallel() 1123 | const testMessage = "Do not go gentle into that good night." 1124 | 1125 | waitEnd := make(chan struct{}) 1126 | var received string 1127 | 1128 | host := startServer(t, nothingOnConnect, func(message string) { 1129 | if strings.HasPrefix(message, "PRIVMSG") { 1130 | received = message 1131 | close(waitEnd) 1132 | } 1133 | }) 1134 | 1135 | client := newTestClient(host) 1136 | 1137 | client.OnConnect(func() { 1138 | client.Say("gempir", testMessage) 1139 | }) 1140 | 1141 | go func() { 1142 | _ = client.Connect() 1143 | }() 1144 | 1145 | // wait for server to receive message 1146 | select { 1147 | case <-waitEnd: 1148 | case <-time.After(time.Second * 3): 1149 | t.Fatal("no privmsg received") 1150 | } 1151 | 1152 | assertStringsEqual(t, "PRIVMSG #gempir :"+testMessage, received) 1153 | } 1154 | 1155 | func TestCanReplyMessage(t *testing.T) { 1156 | t.Parallel() 1157 | testMessage := "Do not go gentle into that good night." 1158 | testParentMessageID := "b34ccfc7-4977-403a-8a94-33c6bac34fb8" 1159 | 1160 | waitEnd := make(chan struct{}) 1161 | var received string 1162 | 1163 | host := startServer(t, nothingOnConnect, func(message string) { 1164 | if strings.HasPrefix(message, "@reply") { 1165 | received = message 1166 | close(waitEnd) 1167 | } 1168 | }) 1169 | 1170 | client := newTestClient(host) 1171 | 1172 | client.OnConnect(func() { 1173 | client.Reply("gempir", testParentMessageID, testMessage) 1174 | }) 1175 | 1176 | go func() { 1177 | _ = client.Connect() 1178 | }() 1179 | 1180 | // wait for server to receive message 1181 | select { 1182 | case <-waitEnd: 1183 | case <-time.After(time.Second * 3): 1184 | t.Fatal("no privmsg received") 1185 | } 1186 | 1187 | assertStringsEqual(t, "@reply-parent-msg-id="+testParentMessageID+" PRIVMSG #gempir :"+testMessage, received) 1188 | } 1189 | 1190 | func TestCanJoinChannel(t *testing.T) { 1191 | t.Parallel() 1192 | waitEnd := make(chan struct{}) 1193 | var receivedMsg string 1194 | 1195 | host := startServer(t, nothingOnConnect, func(message string) { 1196 | if strings.HasPrefix(message, "JOIN") { 1197 | receivedMsg = message 1198 | close(waitEnd) 1199 | } 1200 | }) 1201 | 1202 | client := newTestClient(host) 1203 | 1204 | client.Join("gempiR") 1205 | 1206 | go func() { 1207 | go func() { 1208 | _ = client.Connect() 1209 | }() 1210 | }() 1211 | 1212 | // wait for server to receive message 1213 | select { 1214 | case <-waitEnd: 1215 | case <-time.After(time.Second * 3): 1216 | t.Fatal("no join message received") 1217 | } 1218 | 1219 | assertStringsEqual(t, "JOIN #gempir", receivedMsg) 1220 | } 1221 | 1222 | func TestCanJoinChannelAfterConnection(t *testing.T) { 1223 | t.Parallel() 1224 | waitEnd := make(chan struct{}) 1225 | var receivedMsg string 1226 | 1227 | host := startServer(t, nothingOnConnect, func(message string) { 1228 | if strings.HasPrefix(message, "JOIN") { 1229 | receivedMsg = message 1230 | close(waitEnd) 1231 | } 1232 | }) 1233 | 1234 | client := newTestClient(host) 1235 | go func() { 1236 | go func() { 1237 | _ = client.Connect() 1238 | }() 1239 | }() 1240 | 1241 | // wait for the connection to go active 1242 | for !client.connActive.get() { 1243 | time.Sleep(time.Millisecond * 2) 1244 | } 1245 | client.Join("gempir") 1246 | 1247 | // wait for server to receive message 1248 | select { 1249 | case <-waitEnd: 1250 | case <-time.After(time.Second * 3): 1251 | t.Fatal("no join message received") 1252 | } 1253 | 1254 | assertStringsEqual(t, "JOIN #gempir", receivedMsg) 1255 | } 1256 | 1257 | func TestCanRespectDefaultJoinRateLimits(t *testing.T) { 1258 | t.Parallel() 1259 | waitEnd := make(chan struct{}) 1260 | 1261 | var joinMessages []timedTestMessage 1262 | targetJoinCount := 25 1263 | 1264 | host := startServer(t, nothingOnConnect, func(message string) { 1265 | if strings.HasPrefix(message, "JOIN ") { 1266 | joinMessages = append(joinMessages, timedTestMessage{message, time.Now()}) 1267 | 1268 | if len(joinMessages) == targetJoinCount { 1269 | close(waitEnd) 1270 | } 1271 | } 1272 | }) 1273 | 1274 | client := newTestClient(host) 1275 | client.PongTimeout = time.Second * 30 1276 | client.SetJoinRateLimiter(CreateDefaultRateLimiter()) 1277 | go func() { 1278 | go func() { 1279 | _ = client.Connect() 1280 | }() 1281 | }() //nolint 1282 | 1283 | // wait for the connection to go active 1284 | for !client.connActive.get() { 1285 | time.Sleep(time.Millisecond * 2) 1286 | } 1287 | 1288 | // send enough messages to ensure we hit the rate limit 1289 | for i := 1; i <= targetJoinCount; i++ { 1290 | client.Join(fmt.Sprintf("gempir%d", i)) 1291 | } 1292 | 1293 | // wait for server to receive message 1294 | select { 1295 | case <-waitEnd: 1296 | case <-time.After(time.Second * 30): 1297 | t.Fatal("didn't receive all messages in time") 1298 | } 1299 | 1300 | assertJoinRateLimitRespected(t, client.joinRateLimiter.GetLimit(), joinMessages) 1301 | } 1302 | 1303 | func TestCanRespectBulkDefaultJoinRateLimits(t *testing.T) { 1304 | t.Parallel() 1305 | waitEnd := make(chan struct{}) 1306 | 1307 | var joinMessages []timedTestMessage 1308 | targetJoinCount := 50 1309 | 1310 | host := startServer(t, nothingOnConnect, func(message string) { 1311 | if strings.HasPrefix(message, "JOIN ") { 1312 | splits := strings.Split(message, ",") 1313 | for _, split := range splits { 1314 | joinMessages = append(joinMessages, timedTestMessage{split, time.Now()}) 1315 | } 1316 | 1317 | if len(joinMessages) == targetJoinCount { 1318 | close(waitEnd) 1319 | } 1320 | } 1321 | }) 1322 | 1323 | client := newTestClient(host) 1324 | client.PongTimeout = time.Second * 60 1325 | client.SetJoinRateLimiter(CreateDefaultRateLimiter()) 1326 | go func() { 1327 | go func() { 1328 | _ = client.Connect() 1329 | }() 1330 | }() //nolint 1331 | 1332 | // wait for the connection to go active 1333 | for !client.connActive.get() { 1334 | time.Sleep(time.Millisecond * 2) 1335 | } 1336 | 1337 | perBulk := 25 1338 | // send enough messages to ensure we hit the rate limit 1339 | for i := 1; i <= targetJoinCount; { 1340 | channels := []string{} 1341 | for j := i; j < i+perBulk; j++ { 1342 | channels = append(channels, fmt.Sprintf("gempir%d", j)) 1343 | } 1344 | 1345 | client.Join(channels...) 1346 | i += perBulk 1347 | } 1348 | 1349 | // wait for server to receive message 1350 | select { 1351 | case <-waitEnd: 1352 | case <-time.After(time.Second * 60): 1353 | t.Fatal("didn't receive all messages in time") 1354 | } 1355 | 1356 | assertJoinRateLimitRespected(t, client.joinRateLimiter.GetLimit(), joinMessages) 1357 | } 1358 | 1359 | func TestCanRespectVerifiedJoinRateLimits(t *testing.T) { 1360 | t.Parallel() 1361 | waitEnd := make(chan struct{}) 1362 | 1363 | var joinMessages []timedTestMessage 1364 | targetJoinCount := 3000 1365 | 1366 | host := startServer(t, nothingOnConnect, func(message string) { 1367 | if strings.HasPrefix(message, "JOIN ") { 1368 | joinMessages = append(joinMessages, timedTestMessage{message, time.Now()}) 1369 | 1370 | if len(joinMessages) == targetJoinCount { 1371 | close(waitEnd) 1372 | } 1373 | } 1374 | }) 1375 | 1376 | client := newTestClient(host) 1377 | client.PongTimeout = time.Second * 30 1378 | client.SetJoinRateLimiter(CreateVerifiedRateLimiter()) 1379 | go func() { 1380 | go func() { 1381 | _ = client.Connect() 1382 | }() 1383 | }() //nolint 1384 | 1385 | // wait for the connection to go active 1386 | for !client.connActive.get() { 1387 | time.Sleep(time.Millisecond * 2) 1388 | } 1389 | 1390 | // send enough messages to ensure we hit the rate limit 1391 | for i := 1; i <= targetJoinCount; i++ { 1392 | client.Join(fmt.Sprintf("gempir%d", i)) 1393 | } 1394 | 1395 | // wait for server to receive message 1396 | select { 1397 | case <-waitEnd: 1398 | case <-time.After(time.Second * 30): 1399 | t.Fatal("didn't receive all messages in time") 1400 | } 1401 | 1402 | assertJoinRateLimitRespected(t, client.joinRateLimiter.GetLimit(), joinMessages) 1403 | } 1404 | 1405 | func TestCanIgnoreJoinRateLimits(t *testing.T) { 1406 | t.Parallel() 1407 | waitEnd := make(chan struct{}) 1408 | 1409 | var messages []timedTestMessage 1410 | targetJoinCount := 3000 // this breaks when above 700, why? the fuck? 1411 | 1412 | host := startServer(t, nothingOnConnect, func(message string) { 1413 | if strings.HasPrefix(message, "JOIN ") { 1414 | messages = append(messages, timedTestMessage{message, time.Now()}) 1415 | 1416 | if len(messages) == targetJoinCount { 1417 | close(waitEnd) 1418 | } 1419 | } 1420 | }) 1421 | 1422 | client := newTestClient(host) 1423 | client.PongTimeout = time.Second * 30 1424 | client.SetJoinRateLimiter(CreateUnlimitedRateLimiter()) 1425 | go func() { 1426 | go func() { 1427 | _ = client.Connect() 1428 | }() 1429 | }() //nolint 1430 | 1431 | // wait for the connection to go active 1432 | for !client.connActive.get() { 1433 | time.Sleep(time.Millisecond * 2) 1434 | } 1435 | 1436 | // send enough messages to ensure we hit the rate limit 1437 | for i := 1; i <= targetJoinCount; i++ { 1438 | client.Join(fmt.Sprintf("gempir%d", i)) 1439 | } 1440 | 1441 | // wait for server to receive message 1442 | select { 1443 | case <-waitEnd: 1444 | case <-time.After(time.Second * 10): 1445 | t.Fatal("didn't receive all messages in time") 1446 | } 1447 | 1448 | lastMessageTime := messages[len(messages)-1].time 1449 | firstMessageTime := messages[0].time 1450 | 1451 | assertTrue(t, lastMessageTime.Sub(firstMessageTime).Seconds() <= 10, fmt.Sprintf("join ratelimit not skipped last message time: %s, first message time: %s", lastMessageTime, firstMessageTime)) 1452 | } 1453 | 1454 | func TestCanDepartChannel(t *testing.T) { 1455 | t.Parallel() 1456 | waitEnd := make(chan struct{}) 1457 | var receivedMsg string 1458 | 1459 | host := startServer(t, nothingOnConnect, func(message string) { 1460 | if strings.HasPrefix(message, "PART") { 1461 | receivedMsg = message 1462 | close(waitEnd) 1463 | } 1464 | }) 1465 | 1466 | client := newTestClient(host) 1467 | go func() { 1468 | go func() { 1469 | _ = client.Connect() 1470 | }() 1471 | }() 1472 | 1473 | // wait for the connection to go active 1474 | for !client.connActive.get() { 1475 | time.Sleep(time.Millisecond * 2) 1476 | } 1477 | client.Depart("gempir") 1478 | 1479 | // wait for server to receive message 1480 | select { 1481 | case <-waitEnd: 1482 | case <-time.After(time.Second * 3): 1483 | t.Fatal("no depart message received") 1484 | } 1485 | 1486 | assertStringsEqual(t, "PART #gempir", receivedMsg) 1487 | } 1488 | 1489 | func TestCanGetUserlist(t *testing.T) { 1490 | t.Parallel() 1491 | expectedNames := []string{"username1", "username2"} 1492 | testMessages := []string{ 1493 | `:justinfan123123.tmi.twitch.tv 353 justinfan123123 = #channel123 :username1 username2`, 1494 | `@badges=subscriber/6,premium/1;color=#FF0000;display-name=Redflamingo13;emotes=;id=2a31a9df-d6ff-4840-b211-a2547c7e656e;mod=0;room-id=11148817;subscriber=1;tmi-sent-ts=1490382457309;turbo=0;user-id=78424343;user-type= :redflamingo13!redflamingo13@redflamingo13.tmi.twitch.tv PRIVMSG #anythingbutchannel123 :ok go now`, 1495 | } 1496 | waitEnd := make(chan struct{}) 1497 | 1498 | host := startServer(t, postMessagesOnConnect(testMessages), nothingOnMessage) 1499 | 1500 | client := newTestClient(host) 1501 | 1502 | var received NamesMessage 1503 | 1504 | client.OnNamesMessage(func(message NamesMessage) { 1505 | received = message 1506 | }) 1507 | 1508 | client.Join("channel123") 1509 | 1510 | client.OnPrivateMessage(func(message PrivateMessage) { 1511 | if message.Message == "ok go now" { 1512 | // test a valid channel 1513 | got, err := client.Userlist("channel123") 1514 | if err != nil { 1515 | t.Fatal("error not nil for client.Userlist") 1516 | } 1517 | 1518 | sort.Strings(got) 1519 | 1520 | assertStringSlicesEqual(t, expectedNames, got) 1521 | 1522 | // test an unknown channel 1523 | got, err = client.Userlist("random_channel123") 1524 | if err == nil || got != nil { 1525 | t.Fatal("error expected on unknown channel for client.Userlist") 1526 | } 1527 | 1528 | close(waitEnd) 1529 | } 1530 | }) 1531 | 1532 | go func() { 1533 | go func() { 1534 | _ = client.Connect() 1535 | }() 1536 | }() 1537 | 1538 | // wait for the connection to go active 1539 | for !client.connActive.get() { 1540 | time.Sleep(time.Millisecond * 5) 1541 | } 1542 | 1543 | // wait for server to receive message 1544 | select { 1545 | case <-waitEnd: 1546 | case <-time.After(time.Second * 3): 1547 | t.Fatal("no userlist received") 1548 | } 1549 | 1550 | assertStringsEqual(t, "channel123", received.Channel) 1551 | assertStringSlicesEqual(t, expectedNames, received.Users) 1552 | assertMessageTypesEqual(t, NAMES, received.GetType()) 1553 | } 1554 | 1555 | func TestDepartNegatesJoinIfNotConnected(t *testing.T) { 1556 | t.Parallel() 1557 | waitErrorPart := make(chan struct{}) 1558 | waitErrorJoin := make(chan struct{}) 1559 | 1560 | host := startServer(t, nothingOnConnect, func(message string) { 1561 | if strings.HasPrefix(message, "PART") { 1562 | close(waitErrorPart) 1563 | } 1564 | if strings.HasPrefix(message, "JOIN") { 1565 | close(waitErrorJoin) 1566 | } 1567 | }) 1568 | 1569 | client := newTestClient(host) 1570 | 1571 | client.Join("gempir") 1572 | client.Depart("gempir") 1573 | 1574 | go func() { 1575 | _ = client.Connect() 1576 | }() 1577 | 1578 | // wait for the connection to go active 1579 | for !client.connActive.get() { 1580 | time.Sleep(time.Millisecond * 2) 1581 | } 1582 | 1583 | // wait for server to receive message 1584 | select { 1585 | case <-waitErrorPart: 1586 | t.Fatal("erroneously received part message") 1587 | case <-waitErrorJoin: 1588 | t.Fatal("erroneously received join message") 1589 | case <-time.After(time.Millisecond * 100): 1590 | } 1591 | } 1592 | 1593 | func TestCanRespondToPING1(t *testing.T) { 1594 | t.Parallel() 1595 | testMessage := `PING` 1596 | expectedMessage := `PONG` 1597 | waitEnd := make(chan struct{}) 1598 | 1599 | host := startServer(t, postMessageOnConnect(testMessage), func(message string) { 1600 | // On message received 1601 | if message == expectedMessage { 1602 | close(waitEnd) 1603 | } 1604 | }) 1605 | 1606 | client := newTestClient(host) 1607 | 1608 | go func() { 1609 | go func() { 1610 | _ = client.Connect() 1611 | }() 1612 | }() 1613 | 1614 | // wait for server to receive message 1615 | select { 1616 | case <-waitEnd: 1617 | case <-time.After(time.Second * 3): 1618 | t.Fatal("no pong message received") 1619 | } 1620 | } 1621 | 1622 | func TestCanRespondToPING2(t *testing.T) { 1623 | t.Parallel() 1624 | testMessage := `:tmi.twitch.tv PING` 1625 | expectedMessage := `PONG` 1626 | waitEnd := make(chan struct{}) 1627 | 1628 | host := startServer(t, postMessageOnConnect(testMessage), func(message string) { 1629 | // On message received 1630 | if message == expectedMessage { 1631 | close(waitEnd) 1632 | } 1633 | }) 1634 | 1635 | client := newTestClient(host) 1636 | 1637 | go func() { 1638 | go func() { 1639 | _ = client.Connect() 1640 | }() 1641 | }() 1642 | 1643 | // wait for server to receive message 1644 | select { 1645 | case <-waitEnd: 1646 | case <-time.After(time.Second * 3): 1647 | t.Fatal("no pong message received") 1648 | } 1649 | } 1650 | 1651 | func TestCanAttachToPingMessageCallback(t *testing.T) { 1652 | t.Parallel() 1653 | testMessage := `:tmi.twitch.tv PING` 1654 | wait := make(chan struct{}) 1655 | 1656 | host := startServer(t, postMessageOnConnect(testMessage), nothingOnMessage) 1657 | 1658 | client := newTestClient(host) 1659 | 1660 | client.OnPingMessage(func(msg PingMessage) { 1661 | close(wait) 1662 | }) 1663 | 1664 | go func() { 1665 | go func() { 1666 | _ = client.Connect() 1667 | }() 1668 | }() 1669 | 1670 | // wait for server to receive message 1671 | select { 1672 | case <-wait: 1673 | case <-time.After(time.Second * 3): 1674 | t.Fatal("no ping message received") 1675 | } 1676 | } 1677 | 1678 | func TestCanPong(t *testing.T) { 1679 | t.Parallel() 1680 | testMessage := `PING :hello` 1681 | expectedMessage := `PONG :hello` 1682 | waitEnd := make(chan struct{}) 1683 | 1684 | host := startServer(t, postMessageOnConnect(testMessage), func(message string) { 1685 | // On message received 1686 | if message == expectedMessage { 1687 | close(waitEnd) 1688 | } 1689 | }) 1690 | 1691 | client := newTestClient(host) 1692 | 1693 | go func() { 1694 | go func() { 1695 | _ = client.Connect() 1696 | }() 1697 | }() 1698 | 1699 | // wait for server to receive message 1700 | select { 1701 | case <-waitEnd: 1702 | case <-time.After(time.Second * 3): 1703 | t.Fatal("no pong message received") 1704 | } 1705 | } 1706 | 1707 | func TestCanNotDialInvalidAddress(t *testing.T) { 1708 | t.Parallel() 1709 | client := NewClient("justinfan123123", "oauth:123123132") 1710 | client.IrcAddress = "127.0.0.1:123123123123" 1711 | 1712 | err := client.Connect() 1713 | if !strings.Contains(err.Error(), "invalid port") { 1714 | t.Fatal("wrong Connect() error: " + err.Error()) 1715 | } 1716 | } 1717 | 1718 | func TestCanNotUseImproperlyFormattedOauthPENIS(t *testing.T) { 1719 | t.Parallel() 1720 | host := startServer(t, nothingOnConnect, nothingOnMessage) 1721 | client := NewClient("justinfan123123", "imrpproperlyformattedoauth") 1722 | client.IrcAddress = host 1723 | 1724 | err := client.Connect() 1725 | if err != ErrLoginAuthenticationFailed { 1726 | t.Fatal("wrong Connect() error: " + err.Error()) 1727 | } 1728 | } 1729 | 1730 | func TestCanNotUseWrongOauthPENIS123(t *testing.T) { 1731 | t.Parallel() 1732 | host := startServer(t, nothingOnConnect, nothingOnMessage) 1733 | client := NewClient("justinfan123123", "oauth:wrong") 1734 | client.IrcAddress = host 1735 | 1736 | err := client.Connect() 1737 | if err != ErrLoginAuthenticationFailed { 1738 | t.Fatal("wrong Connect() error: " + err.Error()) 1739 | } 1740 | } 1741 | 1742 | func TestCanConnectToTwitch(t *testing.T) { 1743 | t.Parallel() 1744 | client := NewClient("justinfan123123", "oauth:123123132") 1745 | 1746 | client.OnConnect(func() { 1747 | err := client.Disconnect() 1748 | if err != nil { 1749 | t.Error(err) 1750 | } 1751 | }) 1752 | 1753 | err := client.Connect() 1754 | assertErrorsEqual(t, ErrClientDisconnected, err) 1755 | } 1756 | 1757 | func TestCanConnectToTwitchWithoutTLS(t *testing.T) { 1758 | t.Parallel() 1759 | client := NewClient("justinfan123123", "oauth:123123132") 1760 | client.TLS = false 1761 | wait := make(chan struct{}) 1762 | 1763 | client.OnConnect(func() { 1764 | err := client.Disconnect() 1765 | if err != nil { 1766 | t.Error(err) 1767 | } 1768 | }) 1769 | 1770 | go func() { 1771 | err := client.Connect() 1772 | assertErrorsEqual(t, ErrClientDisconnected, err) 1773 | close(wait) 1774 | }() 1775 | 1776 | select { 1777 | case <-wait: 1778 | case <-time.After(time.Second * 3): 1779 | t.Fatal("Did not establish a connection") 1780 | } 1781 | } 1782 | 1783 | func TestCanHandleInvalidNick(t *testing.T) { 1784 | t.Parallel() 1785 | client := NewClient("", "") 1786 | client.TLS = false 1787 | wait := make(chan struct{}) 1788 | 1789 | client.OnConnect(func() { 1790 | t.Fatal("A connection should not be able to be established with an invalid nick") 1791 | }) 1792 | 1793 | go func() { 1794 | err := client.Connect() 1795 | assertErrorsEqual(t, ErrLoginAuthenticationFailed, err) 1796 | close(wait) 1797 | }() 1798 | 1799 | select { 1800 | case <-wait: 1801 | case <-time.After(time.Second * 3): 1802 | t.Fatal("Did not establish a connection") 1803 | } 1804 | } 1805 | 1806 | func TestLocalSendingPingsReceivedPong(t *testing.T) { 1807 | t.Parallel() 1808 | const idlePingInterval = 300 * time.Millisecond 1809 | 1810 | wait := make(chan bool) 1811 | 1812 | var conn net.Conn 1813 | 1814 | host := startServer(t, func(c net.Conn) { 1815 | conn = c 1816 | }, func(message string) { 1817 | if message == pingMessage { 1818 | // Send an emulated pong 1819 | pongMessage := formatPong(strings.Split(message, " :")[1]) 1820 | _, _ = fmt.Fprintf(conn, "%s\r\n", pongMessage) 1821 | wait <- true 1822 | } 1823 | }) 1824 | client := newTestClient(host) 1825 | client.IdlePingInterval = idlePingInterval 1826 | 1827 | go func() { 1828 | _ = client.Connect() 1829 | }() 1830 | 1831 | select { 1832 | case <-wait: 1833 | case <-time.After(time.Second * 3): 1834 | t.Fatal("Did not establish a connection") 1835 | } 1836 | 1837 | err := client.Disconnect() 1838 | if err != nil { 1839 | t.Error(err) 1840 | } 1841 | } 1842 | 1843 | func TestLocalCanReconnectAfterNoPongResponse(t *testing.T) { 1844 | t.Parallel() 1845 | const idlePingInterval = 300 * time.Millisecond 1846 | const pongTimeout = 300 * time.Millisecond 1847 | 1848 | wait := make(chan bool) 1849 | 1850 | var connCount int32 1851 | 1852 | host := startServerMultiConns(t, 3, func(conn net.Conn) { 1853 | atomic.AddInt32(&connCount, 1) 1854 | wait <- true 1855 | }, nothingOnMessage) 1856 | client := newTestClient(host) 1857 | client.IdlePingInterval = idlePingInterval 1858 | client.PongTimeout = pongTimeout 1859 | 1860 | go func() { 1861 | _ = client.Connect() 1862 | }() 1863 | 1864 | select { 1865 | case <-wait: 1866 | case <-time.After(time.Second * 3): 1867 | t.Fatal("Did not establish a connection") 1868 | } 1869 | 1870 | assertInt32sEqual(t, 1, atomic.LoadInt32(&connCount)) 1871 | 1872 | // Wait for reconnect based on lack of ping response 1873 | select { 1874 | case <-wait: 1875 | case <-time.After(time.Second * 3): 1876 | t.Fatal("Did not establish a connection") 1877 | } 1878 | 1879 | assertInt32sEqual(t, 2, atomic.LoadInt32(&connCount)) 1880 | 1881 | // Wait for another reconnect based on lack of ping response 1882 | select { 1883 | case <-wait: 1884 | case <-time.After(time.Second * 3): 1885 | t.Fatal("Did not establish a connection") 1886 | } 1887 | 1888 | assertInt32sEqual(t, 3, atomic.LoadInt32(&connCount)) 1889 | } 1890 | 1891 | func TestLocalSendingPingsReceivedPongAlsoDisconnect(t *testing.T) { 1892 | t.Parallel() 1893 | const idlePingInterval = 300 * time.Millisecond 1894 | 1895 | wait := make(chan bool) 1896 | 1897 | var conn net.Conn 1898 | 1899 | host := startServer(t, func(c net.Conn) { 1900 | conn = c 1901 | }, func(message string) { 1902 | if message == pingMessage { 1903 | // Send an emulated pong 1904 | pongMessage := formatPong(strings.Split(message, " :")[1]) 1905 | _, _ = fmt.Fprintf(conn, "%s\r\n", pongMessage) 1906 | conn.Close() 1907 | wait <- true 1908 | } 1909 | }) 1910 | client := newTestClient(host) 1911 | client.IdlePingInterval = idlePingInterval 1912 | 1913 | go func() { 1914 | _ = client.Connect() 1915 | }() 1916 | 1917 | select { 1918 | case <-wait: 1919 | case <-time.After(time.Second * 3): 1920 | t.Fatal("Did not establish a connection") 1921 | } 1922 | 1923 | err := client.Disconnect() 1924 | if err != nil { 1925 | t.Error(err) 1926 | } 1927 | } 1928 | 1929 | func TestPinger(t *testing.T) { 1930 | t.Parallel() 1931 | const idlePingInterval = 300 * time.Millisecond 1932 | 1933 | wait := make(chan bool) 1934 | 1935 | var conn net.Conn 1936 | 1937 | var pingpongMutex sync.Mutex 1938 | var pingsSent int 1939 | var pongsReceived int 1940 | 1941 | host := startServer(t, func(c net.Conn) { 1942 | conn = c 1943 | }, func(message string) { 1944 | if message == pingMessage { 1945 | // Send an emulated pong 1946 | pongMessage := formatPong(strings.Split(message, " :")[1]) 1947 | _, _ = fmt.Fprintf(conn, "%s\r\n", pongMessage) 1948 | wait <- true 1949 | } 1950 | }) 1951 | client := newTestClient(host) 1952 | client.IdlePingInterval = idlePingInterval 1953 | client.OnPingSent(func() { 1954 | pingpongMutex.Lock() 1955 | pingsSent++ 1956 | pingpongMutex.Unlock() 1957 | }) 1958 | 1959 | client.OnPongMessage(func(msg PongMessage) { 1960 | pingpongMutex.Lock() 1961 | pongsReceived++ 1962 | pingpongMutex.Unlock() 1963 | }) 1964 | 1965 | go func() { 1966 | _ = client.Connect() 1967 | }() 1968 | 1969 | select { 1970 | case <-wait: 1971 | case <-time.After(time.Second * 3): 1972 | t.Fatal("Did not establish a connection") 1973 | } 1974 | 1975 | wait = make(chan bool) 1976 | 1977 | // Ping has been sent by server 1978 | go func() { 1979 | for { 1980 | <-time.After(5 * time.Millisecond) 1981 | pingpongMutex.Lock() 1982 | if pingsSent == pongsReceived { 1983 | wait <- pingsSent == 1 1984 | pingpongMutex.Unlock() 1985 | return 1986 | } 1987 | pingpongMutex.Unlock() 1988 | } 1989 | }() 1990 | 1991 | select { 1992 | case res := <-wait: 1993 | assertTrue(t, res, "did not send a ping??????") 1994 | case <-time.After(time.Second * 3): 1995 | t.Fatal("Did not receive a pong") 1996 | } 1997 | 1998 | err := client.Disconnect() 1999 | if err != nil { 2000 | t.Error(err) 2001 | } 2002 | } 2003 | 2004 | func TestLatencySendPingsFalse(t *testing.T) { 2005 | t.Parallel() 2006 | client := newTestClient("") 2007 | client.SendPings = false 2008 | if _, err := client.Latency(); err == nil { 2009 | t.Fatal("Should not be able to measure latency when SendPings is false") 2010 | } 2011 | } 2012 | 2013 | func TestLatencyBeforePings(t *testing.T) { 2014 | t.Parallel() 2015 | var ( 2016 | client *Client 2017 | latency time.Duration 2018 | err error 2019 | ) 2020 | client = newTestClient("") 2021 | if latency, err = client.Latency(); err != nil { 2022 | t.Fatal(fmt.Errorf("Failed to measure latency: %w", err)) 2023 | } 2024 | 2025 | if latency != 0 { 2026 | t.Fatal("Latency should be zero before a ping is sent") 2027 | } 2028 | } 2029 | 2030 | func TestLatency(t *testing.T) { 2031 | t.Parallel() 2032 | const idlePingInterval = 10 * time.Millisecond 2033 | const expectedLatency = 50 * time.Millisecond 2034 | const toleranceLatency = 5 * time.Millisecond 2035 | 2036 | wait := make(chan bool) 2037 | 2038 | var conn net.Conn 2039 | 2040 | host := startServer(t, func(c net.Conn) { 2041 | conn = c 2042 | }, func(message string) { 2043 | if message == pingMessage { 2044 | // Send an emulated pong 2045 | <-time.After(expectedLatency) 2046 | wait <- true 2047 | pongMessage := formatPong(strings.Split(message, " :")[1]) 2048 | _, _ = fmt.Fprintf(conn, "%s\r\n", pongMessage) 2049 | } 2050 | }) 2051 | client := newTestClient(host) 2052 | client.IdlePingInterval = idlePingInterval 2053 | 2054 | go func() { 2055 | _ = client.Connect() 2056 | }() 2057 | 2058 | select { 2059 | case <-wait: 2060 | case <-time.After(time.Second * 3): 2061 | t.Fatal("Did not establish a connection") 2062 | } 2063 | 2064 | var ( 2065 | returnedLatency time.Duration 2066 | err error 2067 | ) 2068 | for i := 0; i < 5; i++ { 2069 | // Wait for the client to send a ping 2070 | <-time.After(idlePingInterval + time.Millisecond*10) 2071 | 2072 | if returnedLatency, err = client.Latency(); err != nil { 2073 | t.Fatal(fmt.Errorf("Failed to measure latency: %w", err)) 2074 | } 2075 | 2076 | returnedLatency = returnedLatency.Round(time.Millisecond) 2077 | 2078 | latencyDiff := func() time.Duration { 2079 | diff := returnedLatency - expectedLatency 2080 | if diff < 0 { 2081 | return -diff 2082 | } 2083 | return diff 2084 | }() 2085 | 2086 | if latencyDiff > toleranceLatency { 2087 | t.Fatalf("Latency %s should be within 5ms of %s", returnedLatency, expectedLatency) 2088 | } 2089 | } 2090 | 2091 | err = client.Disconnect() 2092 | if err != nil { 2093 | t.Error(err) 2094 | } 2095 | } 2096 | 2097 | func TestCanAttachToPongMessageCallback(t *testing.T) { 2098 | t.Parallel() 2099 | 2100 | pongMessage := `:tmi.twitch.tv PONG tmi.twitch.tv :go-twitch-irc` 2101 | 2102 | wait := make(chan struct{}) 2103 | 2104 | host := startServer(t, postMessageOnConnect(pongMessage), nothingOnMessage) 2105 | 2106 | client := newTestClient(host) 2107 | 2108 | var received string 2109 | 2110 | client.OnPongMessage(func(msg PongMessage) { 2111 | received = msg.Message 2112 | close(wait) 2113 | }) 2114 | 2115 | go func() { 2116 | _ = client.Connect() 2117 | }() 2118 | 2119 | select { 2120 | case <-wait: 2121 | case <-time.After(time.Second * 3): 2122 | t.Fatal("Did not establish a connection") 2123 | } 2124 | 2125 | err := client.Disconnect() 2126 | if err != nil { 2127 | t.Error(err) 2128 | } 2129 | 2130 | assertStringsEqual(t, "go-twitch-irc", received) 2131 | } 2132 | 2133 | type createJoinMessageResult struct { 2134 | messages []string 2135 | joined []string 2136 | } 2137 | 2138 | func TestCreateJoinMessagesCreatesMessages(t *testing.T) { 2139 | cases := []struct { 2140 | channels []string 2141 | expected createJoinMessageResult 2142 | }{ 2143 | { 2144 | channels: nil, 2145 | expected: createJoinMessageResult{ 2146 | messages: []string{}, 2147 | joined: []string{}, 2148 | }, 2149 | }, 2150 | { 2151 | channels: []string{}, 2152 | expected: createJoinMessageResult{ 2153 | messages: []string{}, 2154 | joined: []string{}, 2155 | }, 2156 | }, 2157 | { 2158 | channels: []string{"pajlada", "forsen"}, 2159 | expected: createJoinMessageResult{ 2160 | messages: []string{"JOIN #pajlada,#forsen"}, 2161 | joined: []string{"pajlada", "forsen"}, 2162 | }, 2163 | }, 2164 | { 2165 | channels: []string{ 2166 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2167 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2168 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2169 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2170 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2171 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2172 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2173 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2174 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2175 | "aaaaaaaaa", 2176 | }, 2177 | expected: createJoinMessageResult{ 2178 | messages: []string{"JOIN #aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaa"}, 2179 | joined: []string{ 2180 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2181 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2182 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2183 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2184 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2185 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2186 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2187 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2188 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2189 | "aaaaaaaaa", 2190 | }, 2191 | }, 2192 | }, 2193 | { 2194 | channels: []string{ 2195 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2196 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2197 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2198 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2199 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2200 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2201 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2202 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2203 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2204 | "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 2205 | }, 2206 | expected: createJoinMessageResult{ 2207 | messages: []string{ 2208 | "JOIN #aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2209 | "JOIN #bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 2210 | }, 2211 | joined: []string{ 2212 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2213 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2214 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2215 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2216 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2217 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2218 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2219 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2220 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 2221 | "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 2222 | }, 2223 | }, 2224 | }, 2225 | } 2226 | 2227 | client := NewAnonymousClient() 2228 | client.channels = make(map[string]bool) 2229 | 2230 | for _, test := range cases { 2231 | messages, joined := client.createJoinMessages(test.channels...) 2232 | assertStringSlicesEqual(t, test.expected.messages, messages) 2233 | assertStringSlicesEqual(t, test.expected.joined, joined) 2234 | } 2235 | } 2236 | 2237 | func TestCreateJoinMessageReturnsLowercase(t *testing.T) { 2238 | channels := []string{"PAJLADA", "FORSEN"} 2239 | joined := make(map[string]bool) 2240 | expected := []string{"JOIN #pajlada,#forsen"} 2241 | expectedJoined := []string{"pajlada", "forsen"} 2242 | 2243 | client := NewAnonymousClient() 2244 | client.channels = joined 2245 | actual, actualJoined := client.createJoinMessages(channels...) 2246 | assertStringSlicesEqual(t, expected, actual) 2247 | assertStringSlicesEqual(t, expectedJoined, actualJoined) 2248 | } 2249 | 2250 | func TestCreateJoinMessageSkipsJoinedChannels(t *testing.T) { 2251 | channels := []string{"pajlada", "forsen", "nymn"} 2252 | joined := map[string]bool{ 2253 | "pajlada": true, 2254 | "forsen": false, 2255 | } 2256 | expected := []string{"forsen", "nymn"} 2257 | 2258 | client := NewAnonymousClient() 2259 | client.channels = joined 2260 | _, actual := client.createJoinMessages(channels...) 2261 | assertStringSlicesEqual(t, expected, actual) 2262 | } 2263 | 2264 | func TestRejoinOnReconnect(t *testing.T) { 2265 | t.Parallel() 2266 | waitEnd := make(chan struct{}) 2267 | var receivedMsg string 2268 | 2269 | host := startServerMultiConns(t, 2, nothingOnConnect, func(message string) { 2270 | if strings.HasPrefix(message, "JOIN") { 2271 | receivedMsg = message 2272 | close(waitEnd) 2273 | } 2274 | }) 2275 | 2276 | client := newTestClient(host) 2277 | 2278 | client.Join("gempiR") 2279 | 2280 | clientDisconnected := connectAndEnsureGoodDisconnect(t, client) 2281 | 2282 | // wait for server to receive message 2283 | select { 2284 | case <-waitEnd: 2285 | case <-time.After(time.Second * 3): 2286 | t.Fatal("no join message received") 2287 | } 2288 | 2289 | // Server received first JOIN message 2290 | assertStringsEqual(t, "JOIN #gempir", receivedMsg) 2291 | 2292 | receivedMsg = "" 2293 | 2294 | // Manually disconnect 2295 | err := client.Disconnect() 2296 | if err != nil { 2297 | t.Error(err) 2298 | } 2299 | 2300 | <-clientDisconnected 2301 | 2302 | waitEnd = make(chan struct{}) 2303 | 2304 | // Manually reconnect 2305 | go func() { 2306 | go func() { 2307 | _ = client.Connect() 2308 | }() 2309 | }() 2310 | 2311 | select { 2312 | case <-waitEnd: 2313 | case <-time.After(time.Second * 3): 2314 | t.Fatal("no join message received 2") 2315 | } 2316 | 2317 | // Server received second JOIN message 2318 | assertStringsEqual(t, "JOIN #gempir", receivedMsg) 2319 | } 2320 | 2321 | func TestCapabilities(t *testing.T) { 2322 | type testTable struct { 2323 | name string 2324 | in []string 2325 | expected string 2326 | } 2327 | tests := []testTable{ 2328 | { 2329 | "Default Capabilities (not modifying)", 2330 | nil, 2331 | "CAP REQ :" + TagsCapability + " " + CommandsCapability, 2332 | }, 2333 | { 2334 | "Modified Capabilities", 2335 | []string{CommandsCapability, MembershipCapability}, 2336 | "CAP REQ :" + CommandsCapability + " " + MembershipCapability, 2337 | }, 2338 | } 2339 | 2340 | for _, tt := range tests { 2341 | func(tt testTable) { 2342 | t.Run(tt.name, func(t *testing.T) { 2343 | t.Parallel() 2344 | waitRecv := make(chan struct{}) 2345 | waitServerConnect := make(chan struct{}) 2346 | waitClientConnect := make(chan struct{}) 2347 | 2348 | var received string 2349 | 2350 | server := startServer2(t, closeOnConnect(waitServerConnect), func(message string) { 2351 | if strings.HasPrefix(message, "CAP REQ") { 2352 | received = message 2353 | close(waitRecv) 2354 | } 2355 | }) 2356 | 2357 | client := newTestClient(server.host) 2358 | if tt.in != nil { 2359 | client.Capabilities = tt.in 2360 | } 2361 | client.OnConnect(clientCloseOnConnect(waitClientConnect)) 2362 | clientDisconnected := connectAndEnsureGoodDisconnect(t, client) 2363 | 2364 | // Wait for correct password to be read in server 2365 | if !waitWithTimeout(waitRecv) { 2366 | t.Fatal("no oauth read") 2367 | } 2368 | 2369 | assertStringsEqual(t, tt.expected, received) 2370 | 2371 | // Wait for server to acknowledge connection 2372 | if !waitWithTimeout(waitServerConnect) { 2373 | t.Fatal("no successful connection") 2374 | } 2375 | 2376 | // Wait for client to acknowledge connection 2377 | if !waitWithTimeout(waitClientConnect) { 2378 | t.Fatal("no successful connection") 2379 | } 2380 | 2381 | // Disconnect client from server 2382 | err := client.Disconnect() 2383 | if err != nil { 2384 | t.Error("Error during disconnect:" + err.Error()) 2385 | } 2386 | 2387 | // Wait for client to be fully disconnected 2388 | <-clientDisconnected 2389 | 2390 | // Wait for server to be fully disconnected 2391 | <-server.stopped 2392 | }) 2393 | }(tt) 2394 | } 2395 | } 2396 | 2397 | func TestEmptyCapabilities(t *testing.T) { 2398 | type testTable struct { 2399 | name string 2400 | in []string 2401 | } 2402 | tests := []testTable{ 2403 | {"nil", nil}, 2404 | {"Empty list", []string{}}, 2405 | } 2406 | 2407 | for _, tt := range tests { 2408 | func(tt testTable) { 2409 | t.Run(tt.name, func(t *testing.T) { 2410 | t.Parallel() 2411 | // we will modify the clients caps to only send commands and membership 2412 | receivedCapabilities := false 2413 | waitRecv := make(chan struct{}) 2414 | waitServerConnect := make(chan struct{}) 2415 | waitClientConnect := make(chan struct{}) 2416 | 2417 | server := startServer2(t, closeOnConnect(waitServerConnect), func(message string) { 2418 | if strings.HasPrefix(message, "CAP REQ") { 2419 | receivedCapabilities = true 2420 | } else if strings.HasPrefix(message, "PASS") { 2421 | close(waitRecv) 2422 | } 2423 | }) 2424 | 2425 | client := newTestClient(server.host) 2426 | client.Capabilities = tt.in 2427 | client.OnConnect(clientCloseOnConnect(waitClientConnect)) 2428 | clientDisconnected := connectAndEnsureGoodDisconnect(t, client) 2429 | 2430 | // Wait for correct password to be read in server 2431 | if !waitWithTimeout(waitRecv) { 2432 | t.Fatal("no oauth read") 2433 | } 2434 | 2435 | assertFalse(t, receivedCapabilities, "We should NOT have received caps since we sent an empty list of caps") 2436 | 2437 | // Wait for server to acknowledge connection 2438 | if !waitWithTimeout(waitServerConnect) { 2439 | t.Fatal("no successful connection") 2440 | } 2441 | 2442 | // Wait for client to acknowledge connection 2443 | if !waitWithTimeout(waitClientConnect) { 2444 | t.Fatal("no successful connection") 2445 | } 2446 | 2447 | // Disconnect client from server 2448 | err := client.Disconnect() 2449 | if err != nil { 2450 | t.Error("Error during disconnect:" + err.Error()) 2451 | } 2452 | 2453 | // Wait for client to be fully disconnected 2454 | <-clientDisconnected 2455 | 2456 | // Wait for server to be fully disconnected 2457 | <-server.stopped 2458 | }) 2459 | }(tt) 2460 | } 2461 | } 2462 | --------------------------------------------------------------------------------