├── .circleci └── config.yml ├── .gitignore ├── AUTHORS ├── Dockerfile ├── LICENSE ├── README.md ├── botbot.go ├── certs ├── README.md ├── cert.pem └── key.pem ├── common ├── common.go ├── mock.go ├── queue.go └── storage.go ├── dispatch ├── dispatch.go └── dispatch_test.go ├── line ├── line.go └── line_test.go ├── main.go ├── main_test.go ├── network ├── irc │ ├── irc.go │ └── irc_test.go └── network.go ├── sql ├── botbot_sample.dump └── schema.sql └── user ├── user.go └── user_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: golang:1.10 6 | 7 | working_directory: /go/src/github.com/BotBotMe/botbot-bot 8 | 9 | steps: 10 | - checkout 11 | - run: 12 | name: Get Dependencies 13 | command: go get -v . 14 | - run: 15 | name: Test 16 | command: go test -v -race ./... 17 | - run: 18 | name: Build 19 | command: | 20 | go build -v . 21 | sha256sum botbot-bot 22 | - store_artifacts: 23 | path: botbot-bot 24 | prefix: bin 25 | - run: 26 | name: Push to S3 27 | branches: 28 | only: 29 | - master 30 | command: | 31 | apt-get update -q && apt-get install -y awscli 32 | aws s3 cp botbot-bot s3://${S3_BUCKET}/ 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Graham King 2 | Yann Malet 3 | Peter Baumgartner 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang 2 | 3 | WORKDIR /go/src/github.com/BotBotMe/botbot-bot 4 | COPY . . 5 | RUN go get -v . && go build -v -o . 6 | 7 | CMD ["./botbot-bot"] 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Lincoln Loop 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://img.shields.io/circleci/project/github/BotBotMe/botbot-bot.svg)](https://circleci.com/gh/BotBotMe/botbot-bot) 2 | 3 | 4 | The bot used in botbot.me is a Go (1.2+) program. To install: 5 | 6 | go get github.com/BotBotMe/botbot-bot 7 | 8 | External resources: 9 | 10 | * A Postgres database with the schema as defined in `schema.sql`. 11 | * A Redis database used as a message bus between the plugins and the bot. 12 | 13 | Before loading the sample data from `botbot_sample.dump` you will need to update the script with the irc nick and password. 14 | Installing the database schema and loading sample data: 15 | 16 | psql -U botbot -h localhost -W botbot -f schema.sql 17 | psql -U botbot -h localhost -W botbot -f botbot_sample.dump 18 | 19 | Configuration is handled via environment variables: 20 | 21 | STORAGE_URL=postgres://user:password@host:port/db_name \ 22 | QUEUE_URL=redis://host:port/db_number botbot-bot 23 | 24 | ## Architecture 25 | 26 | Execution starts in `main.go`, in function `main`. That starts the chatbots (via `NetworkManager`), the goroutine which listens for commands from Redis, and the `mainLoop` goroutine, then waits for a Ctrl-C or kill to quit. 27 | 28 | The core of the bot is in `mainLoop` (`main.go`). That listens to two Go channels, `fromServer` and `fromBus`. `fromServer` receives everything coming in from IRC. `fromBus` receives commands from the plugins, sent via a Redis list. 29 | 30 | A typical incoming request to a plugin would take this path: 31 | 32 | ``` 33 | IRC -> TCP socket -> ChatBot.listen (irc.go) -> fromServer channel -> mainLoop (main.go) -> Dispatcher (dispatch.go) -> redis PUBLISH -> plugin 34 | ``` 35 | 36 | A reply from the plugin takes this path: 37 | 38 | ``` 39 | plugin -> redis LPUSH -> listenCmd (main.go) -> fromBus channel -> mainLoop (main.go) -> NetworkManager.Send (network.go) -> ChatBot.Send (irc.go) -> TCP socket -> IRC 40 | ``` 41 | 42 | And now, in ASCII art: 43 | 44 | ``` 45 | plugins <--> REDIS -BLPOP-> listenCmd (main.go) --> fromBus --> mainLoop (main.go) <-- fromServer <-- n ChatBots (irc.go) <--> IRC 46 | ^ | | ^ 47 | | PUBLISH | | | 48 | ------------ Dispatcher (dispatch.go) <---------- ----> NetworkManager (network.go) ---- 49 | ``` 50 | -------------------------------------------------------------------------------- /botbot.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | 8 | "github.com/golang/glog" 9 | 10 | "github.com/BotBotMe/botbot-bot/common" 11 | "github.com/BotBotMe/botbot-bot/dispatch" 12 | "github.com/BotBotMe/botbot-bot/line" 13 | "github.com/BotBotMe/botbot-bot/network" 14 | "github.com/BotBotMe/botbot-bot/user" 15 | ) 16 | 17 | /* 18 | * BOTBOT - the main object 19 | */ 20 | 21 | type BotBot struct { 22 | netMan *network.NetworkManager 23 | dis *dispatch.Dispatcher 24 | users *user.UserManager 25 | storage common.Storage 26 | queue common.Queue 27 | fromServer chan *line.Line 28 | fromBus chan string 29 | } 30 | 31 | func NewBotBot(storage common.Storage, queue common.Queue) *BotBot { 32 | 33 | fromServer := make(chan *line.Line) 34 | fromBus := make(chan string) 35 | 36 | netMan := network.NewNetworkManager(storage, fromServer) 37 | netMan.RefreshChatbots() 38 | go netMan.MonitorChatbots() 39 | 40 | dis := dispatch.NewDispatcher(queue) 41 | 42 | users := user.NewUserManager() 43 | 44 | return &BotBot{ 45 | netMan: netMan, 46 | dis: dis, 47 | users: users, 48 | queue: queue, 49 | storage: storage, 50 | fromServer: fromServer, 51 | fromBus: fromBus} 52 | } 53 | 54 | // Listen for incoming commands 55 | func (bot *BotBot) listen(queueName string) { 56 | 57 | var msg []byte 58 | var err error 59 | 60 | for { 61 | _, msg, err = bot.queue.Blpop([]string{queueName}, 0) 62 | if err != nil { 63 | glog.Fatal("Error reading (BLPOP) from queue. ", err) 64 | } 65 | if len(msg) != 0 { 66 | if glog.V(1) { 67 | glog.Infoln("Command: ", string(msg)) 68 | } 69 | bot.fromBus <- string(msg) 70 | } 71 | } 72 | } 73 | 74 | func (bot *BotBot) mainLoop() { 75 | // TODO (yml) comment out bot.recordUserCounts because I think it is 76 | // leaking postgres connection. 77 | //go bot.recordUserCounts() 78 | 79 | var busCommand string 80 | var args string 81 | for { 82 | select { 83 | case serverLine, ok := <-bot.fromServer: 84 | if !ok { 85 | // Channel is closed, we're offline. Stop. 86 | break 87 | } 88 | 89 | switch serverLine.Command { 90 | 91 | // QUIT and NICK don't have a channel name 92 | // They need to go to all channels the user is in 93 | case "QUIT", "NICK": 94 | bot.dis.DispatchMany(serverLine, bot.users.In(serverLine.User)) 95 | 96 | default: 97 | bot.dis.Dispatch(serverLine) 98 | } 99 | 100 | bot.users.Act(serverLine) 101 | 102 | case busMessage, ok := <-bot.fromBus: 103 | if !ok { 104 | break 105 | } 106 | 107 | parts := strings.SplitN(busMessage, " ", 2) 108 | busCommand = parts[0] 109 | if len(parts) > 1 { 110 | args = parts[1] 111 | } 112 | 113 | bot.handleCommand(busCommand, args) 114 | } 115 | } 116 | } 117 | 118 | // Handle a command send from a plugin. 119 | // Current commands: 120 | // - WRITE : Send message to server 121 | // - REFRESH: Reload plugin configuration 122 | func (bot *BotBot) handleCommand(cmd string, args string) { 123 | if glog.V(2) { 124 | glog.Infoln("HandleCommand:", cmd) 125 | } 126 | switch cmd { 127 | case "WRITE": 128 | parts := strings.SplitN(args, " ", 3) 129 | chatbotId, err := strconv.Atoi(parts[0]) 130 | if err != nil { 131 | if glog.V(1) { 132 | glog.Errorln("Invalid chatbot id: ", parts[0]) 133 | } 134 | return 135 | } 136 | 137 | bot.netMan.Send(chatbotId, parts[1], parts[2]) 138 | 139 | // Now send it back to ourself, so other plugins see it 140 | internalLine := &line.Line{ 141 | ChatBotId: chatbotId, 142 | Raw: args, 143 | User: bot.netMan.GetUserByChatbotId(chatbotId), 144 | Command: "PRIVMSG", 145 | Received: time.Now().UTC().Format(time.RFC3339Nano), 146 | Content: parts[2], 147 | Channel: strings.TrimSpace(parts[1])} 148 | 149 | bot.dis.Dispatch(internalLine) 150 | 151 | case "REFRESH": 152 | if glog.V(1) { 153 | glog.Infoln("Reloading configuration from database") 154 | } 155 | bot.netMan.RefreshChatbots() 156 | } 157 | } 158 | 159 | // Writes the number of users per channel, every hour. Run in go routine. 160 | func (bot *BotBot) recordUserCounts() { 161 | 162 | for { 163 | 164 | for ch := range bot.users.Channels() { 165 | bot.storage.SetCount(ch, bot.users.Count(ch)) 166 | } 167 | time.Sleep(1 * time.Hour) 168 | } 169 | } 170 | 171 | // Stop 172 | func (bot *BotBot) shutdown() { 173 | bot.netMan.Shutdown() 174 | } 175 | -------------------------------------------------------------------------------- /certs/README.md: -------------------------------------------------------------------------------- 1 | The certs included in this repo has been with `generate_cert` from the GO 2 | standard library. 3 | 4 | ``` 5 | generate_cert -ca=true -duration=8760h0m0s -host="127.0.0.1" -start-date="Jan 1 15:04:05 2014" 6 | ``` 7 | 8 | The generate_cert can be build like this 9 | 10 | ``` 11 | go build $GOROOT/src/pkg/crypto/tls/generate_cert.go 12 | ``` 13 | -------------------------------------------------------------------------------- /certs/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC9DCCAd6gAwIBAgIRAIk9mWMjb6kq2LkR+D7zAyQwCwYJKoZIhvcNAQELMBIx 3 | EDAOBgNVBAoTB0FjbWUgQ28wHhcNMTUwNDA5MDAwMDAxWhcNMjUwNDA2MDAwMDAx 4 | WjASMRAwDgYDVQQKEwdBY21lIENvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 5 | CgKCAQEA0Kud/K0cCkiGZ+qNU7n0QgYXLoouN+BK/KxEfduuGv5BhG9+yD3H703H 6 | elmWa+qIlesvCy+zKi7sQVXCJQYVk9mFDVxcQnwNF8tw/D1G0MRIDeWWzmU7Y/uX 7 | 9GKWVsEIzVVCQf+RYd5hdjvWmuP38NKCDi82vPqwiisZKdpCu0qNaQuSn6UqP9xg 8 | Avjm1t0223VifJEIkshI77VXEsoGIqqUrcNoQxNhyyKJPyzH6G2sg6i8npY+d1bR 9 | h6rlrfIGXaubgtYrSEds3txONqe7S7GzyTRtRSkchWiKZ9xF5UBBKkEtYgVZ+JU3 10 | jmxZ+1ok4pv1GiPqWmcnRufkOYHHaQIDAQABo0kwRzAOBgNVHQ8BAf8EBAMCAKQw 11 | EwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB/zAPBgNVHREECDAG 12 | hwR/AAABMAsGCSqGSIb3DQEBCwOCAQEANLEIMCNnp216FFDGbceNVbeKVWMJHrXE 13 | 7wMoCe2Vn38A2MSgb2IUhMQgWZtYkeoTruI0fOY/oM/KYX2hS79HtwQC2gdrOrR8 14 | j5Py6Tv3N6VXr5/osvssuijqGCUvJ67gHcxahfoB49+qymBvEMLR4WjJKKqVKMbP 15 | P7uK64Xu882U+SeuMi7QpS18G0kBJK3HfUS1YxqHwOaKOG6rm99yuTWvFXuLAqxO 16 | N10fP5Hd8is9EMmocRO2jyYdbW+pGKhf/aTImx4qMAYQWuZYDogsU1PqB4XXGcQU 17 | cMhZzS9Gemy/fVxO/9YayP/hIIRcxTeDtSrfWc71Z1sWUGgsyhX0wA== 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /certs/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA0Kud/K0cCkiGZ+qNU7n0QgYXLoouN+BK/KxEfduuGv5BhG9+ 3 | yD3H703HelmWa+qIlesvCy+zKi7sQVXCJQYVk9mFDVxcQnwNF8tw/D1G0MRIDeWW 4 | zmU7Y/uX9GKWVsEIzVVCQf+RYd5hdjvWmuP38NKCDi82vPqwiisZKdpCu0qNaQuS 5 | n6UqP9xgAvjm1t0223VifJEIkshI77VXEsoGIqqUrcNoQxNhyyKJPyzH6G2sg6i8 6 | npY+d1bRh6rlrfIGXaubgtYrSEds3txONqe7S7GzyTRtRSkchWiKZ9xF5UBBKkEt 7 | YgVZ+JU3jmxZ+1ok4pv1GiPqWmcnRufkOYHHaQIDAQABAoIBAH1qOCkubgTsNAPu 8 | 4AQrZlfsSzCIkmC46LjWXM/8IbdNi1kqnduB7lGwwKyTfancqzzXvk2N3LQEwbA+ 9 | 99HCx2M2QBaYpUa8Qi5D8uNXfOFqpxfbHnlsNHbSNzEFs+/uUvj+PjVmgh19R7yF 10 | GGW9kD5odwxJai/IyCQy5QvXh3YCsOFbW7msV/Ke8LqHKHYiuCn1T7XQbTFksFOA 11 | 15KNLflTC+5ITiF3dOkVDesFwdhzEW4rAA3GcxDYphdXUr4s7YHB8TJuqVwi0pDV 12 | HjqdKW/RLaaDHw/dHJ6YMKnHC23pZJMl28P0Xzc/lUaJvLgtiB7cXAYYklrUWWTT 13 | J0P7fgECgYEA/THggaFN40oh0n/iwznZBJoGXavXQ8H43OoYg8I5484oDawgdsH+ 14 | 4036GqVCZQ21qEP+GZnbxY+q8W+MacX7KtV/4ax0mW/m85xgp+sql+uPCrwJOZj+ 15 | GnQD3DmWyWdmPgcaZj0EEhYRhCPHTTP9nwg7hJ9B20d0XHqAI/HccakCgYEA0vt1 16 | M/3uV6Mi+xs3PXCHFVKqqjHyi+PBo4mZS7y7anZZ+gqcR2gZegHklF/hD3/zUsIn 17 | /k8LW1gmVCHighVsIEUzCApw9hq4ZC2kUlhweIQk1abH1/sYFeYuSvMzj1imod9D 18 | gwOLu9jt6JsaboJJFOL1ij8mnD4AZMqK/VVLv8ECgYEAwoGQgMUT+qm2dek8oNFN 19 | wFU60rbyNeFLdxp3HrEUm8aByo8SmWjKkIAUxGd0LAFuLgedqrkhthF6NuOEsLUh 20 | EHTXOtyq7jyi5T6amiT0oaSaTJrLU24Otu+tD39GMQ634qq+QxBYkjRV3HdH4i0w 21 | hv1iC630f6nS4EBTNEnXGZECgYA16P73vAjs58iGdVvWHSzHLApj1sNtL1NJYF2F 22 | VsJk37z6AUARlu37mQQ5TY6KkV0xZl8lwjjarFmO4eGo76RjUotJoLFgkU9QecEl 23 | MWf7w3hOB4HFFGoBHoHxsNcZ58McVZpAneVUqIeSCh/k4PGfnqazHpPVFJqxJngB 24 | Z4wlgQKBgQCq+uvAdp3iFoPjWao4VU5IDUFOKDT7yWjXXsemqqEA0eas5ZuSBRlP 25 | khIXWqyO1tkTusWZ1vGtKQn1VxlPhnEr7yBDamZ/iPztzTTWpXnRhdVOns0CMcdL 26 | vN/NyVk5ShT1Uxi7wFdI26Wu/8woD+JDd3HCjVc5I4qPRLuqil4BUw== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | ) 7 | 8 | // Interface all bots must implement 9 | type ChatBot interface { 10 | io.Closer 11 | Send(channel, msg string) 12 | Update(config *BotConfig) 13 | IsRunning() bool 14 | GetUser() string 15 | } 16 | 17 | // Configuration for a 'chatbot', which is what clients pay for 18 | type BotConfig struct { 19 | Id int 20 | Config map[string]string 21 | Channels []*Channel 22 | } 23 | 24 | // Configuration for a channel 25 | type Channel struct { 26 | Id int 27 | Name string 28 | Pwd string 29 | Fingerprint string 30 | } 31 | 32 | func (cc *Channel) Credential() string { 33 | return strings.TrimSpace(cc.Name + " " + cc.Pwd) 34 | } 35 | 36 | func (cc *Channel) String() string { 37 | return cc.Name 38 | } 39 | -------------------------------------------------------------------------------- /common/mock.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | "flag" 7 | "log" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "github.com/golang/glog" 13 | ) 14 | 15 | // SetGlogFlags walk around a glog issue and force it to log to stderr. 16 | // It need to be called at the beginning of each test. 17 | func SetGlogFlags() { 18 | flag.Set("alsologtostderr", "true") 19 | flag.Set("v", "3") 20 | } 21 | 22 | // MockSocket is a dummy implementation of ReadWriteCloser 23 | type MockSocket struct { 24 | sync.RWMutex 25 | Counter chan bool 26 | Receiver chan string 27 | } 28 | 29 | func (sock *MockSocket) Write(data []byte) (int, error) { 30 | glog.V(3).Infoln("[Debug]: Starting MockSocket.Write of:", string(data)) 31 | if sock.Counter != nil { 32 | sock.Counter <- true 33 | } 34 | if sock.Receiver != nil { 35 | sock.Receiver <- string(data) 36 | } 37 | 38 | return len(data), nil 39 | } 40 | 41 | func (sock *MockSocket) Read(into []byte) (int, error) { 42 | sock.RLock() 43 | defer sock.RUnlock() 44 | time.Sleep(time.Second) // Prevent busy loop 45 | return 0, nil 46 | } 47 | 48 | func (sock *MockSocket) Close() error { 49 | if sock.Receiver != nil { 50 | close(sock.Receiver) 51 | } 52 | if sock.Counter != nil { 53 | close(sock.Counter) 54 | } 55 | return nil 56 | } 57 | 58 | /* 59 | * Mock IRC server 60 | */ 61 | 62 | type MockIRCServer struct { 63 | sync.RWMutex 64 | Port string 65 | Message string 66 | Got []string 67 | } 68 | 69 | func NewMockIRCServer(msg, port string) *MockIRCServer { 70 | return &MockIRCServer{ 71 | Port: port, 72 | Message: msg, 73 | Got: make([]string, 0), 74 | } 75 | } 76 | 77 | func (srv *MockIRCServer) GotLength() int { 78 | srv.RLock() 79 | defer srv.RUnlock() 80 | return len(srv.Got) 81 | } 82 | 83 | func (srv *MockIRCServer) Run(t *testing.T) { 84 | // Use the certs generated with generate_certs 85 | cert, err := tls.LoadX509KeyPair("certs/cert.pem", "certs/key.pem") 86 | if err != nil { 87 | log.Fatalf("server: loadkeys: %s", err) 88 | } 89 | config := tls.Config{Certificates: []tls.Certificate{cert}} 90 | listener, err := tls.Listen("tcp", "127.0.0.1:"+srv.Port, &config) 91 | if err != nil { 92 | t.Error("Error starting mock server on "+srv.Port, err) 93 | return 94 | } 95 | 96 | for { 97 | conn, lerr := listener.Accept() 98 | // If create a new connection throw the old data away 99 | // This can happen if a client trys to connect with tls 100 | // Got will store the handshake data. The cient will try 101 | // connect with a plaintext connect after the tls fails. 102 | srv.Lock() 103 | srv.Got = make([]string, 0) 104 | srv.Unlock() 105 | 106 | if lerr != nil { 107 | t.Error("Error on IRC server on Accept. ", err) 108 | } 109 | 110 | // First message triggers BotBot to send USER and NICK messages 111 | conn.Write([]byte(":hybrid7.debian.local NOTICE AUTH :*** Looking up your hostname...\n")) 112 | // Ask for NickServ auth, and pretend we got it 113 | conn.Write([]byte(":NickServ!NickServ@services. NOTICE graham_king :This nickname is registered. Please choose a different nickname, or identify via /msg NickServ identify \n")) 114 | conn.Write([]byte(":NickServ!NickServ@services. NOTICE graham_king :You are now identified for graham_king.\n")) 115 | conn.Write([]byte(":wolfe.freenode.net 001 graham_king :Welcome to the freenode Internet Relay Chat Network graham_king\n")) 116 | // This should get sent to plugins 117 | conn.Write([]byte(":yml!~yml@li148-151.members.linode.com PRIVMSG #unit :" + srv.Message + "\n")) 118 | conn.Write([]byte("test: " + srv.Message + "\n")) 119 | 120 | var derr error 121 | var data []byte 122 | 123 | bufRead := bufio.NewReader(conn) 124 | for { 125 | data, derr = bufRead.ReadBytes('\n') 126 | if derr != nil { 127 | // Client closed connection 128 | break 129 | } 130 | srv.Lock() 131 | srv.Got = append(srv.Got, string(data)) 132 | srv.Unlock() 133 | } 134 | } 135 | 136 | } 137 | -------------------------------------------------------------------------------- /common/queue.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "net" 5 | "net/url" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/golang/glog" 13 | "github.com/monnand/goredis" 14 | ) 15 | 16 | // Message queue 17 | type Queue interface { 18 | 19 | // Publish 'message' on 'queue' (Redis calls it 'channel') 20 | Publish(queue string, message []byte) error 21 | 22 | // Append item to the end (right) of a list. Creates the list if needed. 23 | Rpush(key string, val []byte) error 24 | 25 | // Append item to the beginning (left) of a list. Creates the list if needed. 26 | Lpush(key string, val []byte) error 27 | 28 | // Blocking Pop from one or more Redis lists 29 | Blpop(keys []string, timeoutsecs uint) (*string, []byte, error) 30 | 31 | // Check if queue is available. First return arg is "PONG". 32 | Ping() (string, error) 33 | 34 | // List length 35 | Llen(string) (int, error) 36 | 37 | // Trim list to given range 38 | Ltrim(string, int, int) error 39 | } 40 | 41 | /* 42 | * Mock QUEUE 43 | */ 44 | 45 | // Simplistic Queue implementation used by the test suite 46 | type MockQueue struct { 47 | sync.RWMutex 48 | Got map[string][]string 49 | ReadChannel chan string 50 | } 51 | 52 | func NewMockQueue() *MockQueue { 53 | return &MockQueue{ 54 | Got: make(map[string][]string), 55 | ReadChannel: make(chan string), 56 | } 57 | } 58 | 59 | func (mq *MockQueue) Publish(queue string, message []byte) error { 60 | mq.Lock() 61 | defer mq.Unlock() 62 | mq.Got[queue] = append(mq.Got[queue], string(message)) 63 | return nil 64 | } 65 | 66 | func (mq *MockQueue) Rpush(key string, val []byte) error { 67 | mq.Lock() 68 | defer mq.Unlock() 69 | mq.Got[key] = append(mq.Got[key], string(val)) 70 | return nil 71 | } 72 | 73 | func (mq *MockQueue) Lpush(key string, val []byte) error { 74 | mq.Lock() 75 | defer mq.Unlock() 76 | // TODO insert at the beginning of the slice 77 | mq.Got[key] = append(mq.Got[key], string(val)) 78 | return nil 79 | } 80 | 81 | func (mq *MockQueue) Blpop(keys []string, timeoutsecs uint) (*string, []byte, error) { 82 | val := <-mq.ReadChannel 83 | return &keys[0], []byte(val), nil 84 | } 85 | 86 | func (mq *MockQueue) Llen(key string) (int, error) { 87 | mq.RLock() 88 | defer mq.RUnlock() 89 | return len(mq.Got), nil 90 | } 91 | 92 | func (mq *MockQueue) Ltrim(key string, start int, end int) error { 93 | return nil 94 | } 95 | 96 | func (mq *MockQueue) Ping() (string, error) { 97 | return "PONG", nil 98 | } 99 | 100 | /* 101 | * REDIS WRAPPER 102 | * Survives Redis restarts, waits for Redis to be available. 103 | * Implements common.Queue 104 | */ 105 | type RedisQueue struct { 106 | queue Queue 107 | } 108 | 109 | func NewRedisQueue() Queue { 110 | redisUrlString := os.Getenv("REDIS_PLUGIN_QUEUE_URL") 111 | if redisUrlString == "" { 112 | glog.Fatal("REDIS_PLUGIN_QUEUE_URL cannot be empty.\nexport REDIS_PLUGIN_QUEUE_URL=redis://host:port/db_number") 113 | } 114 | redisUrl, err := url.Parse(redisUrlString) 115 | if err != nil { 116 | glog.Fatal("Could not read Redis string", err) 117 | } 118 | 119 | redisDb, err := strconv.Atoi(strings.TrimLeft(redisUrl.Path, "/")) 120 | if err != nil { 121 | glog.Fatal("Could not read Redis path", err) 122 | } 123 | 124 | redisQueue := goredis.Client{Addr: redisUrl.Host, Db: redisDb} 125 | rq := RedisQueue{queue: &redisQueue} 126 | rq.waitForRedis() 127 | return &rq 128 | } 129 | 130 | func (rq *RedisQueue) waitForRedis() { 131 | 132 | _, err := rq.queue.Ping() 133 | for err != nil { 134 | glog.Errorln("Waiting for redis...") 135 | time.Sleep(1 * time.Second) 136 | 137 | _, err = rq.queue.Ping() 138 | } 139 | } 140 | 141 | func (rq *RedisQueue) Publish(queue string, message []byte) error { 142 | 143 | err := rq.queue.Publish(queue, message) 144 | if err == nil { 145 | return nil 146 | } 147 | 148 | netErr := err.(net.Error) 149 | if netErr.Timeout() || netErr.Temporary() { 150 | return err 151 | } 152 | 153 | rq.waitForRedis() 154 | return rq.Publish(queue, message) // Recurse 155 | } 156 | 157 | func (rq *RedisQueue) Blpop(keys []string, timeoutsecs uint) (*string, []byte, error) { 158 | 159 | key, val, err := rq.queue.Blpop(keys, timeoutsecs) 160 | if err == nil { 161 | return key, val, nil 162 | } 163 | 164 | netErr := err.(net.Error) 165 | if netErr.Timeout() || netErr.Temporary() { 166 | return key, val, err 167 | } 168 | 169 | rq.waitForRedis() 170 | return rq.Blpop(keys, timeoutsecs) // Recurse 171 | } 172 | 173 | func (rq *RedisQueue) Rpush(key string, val []byte) error { 174 | 175 | err := rq.queue.Rpush(key, val) 176 | if err == nil { 177 | return nil 178 | } 179 | 180 | netErr := err.(net.Error) 181 | if netErr.Timeout() || netErr.Temporary() { 182 | return err 183 | } 184 | 185 | rq.waitForRedis() 186 | return rq.Rpush(key, val) // Recurse 187 | } 188 | 189 | func (rq *RedisQueue) Lpush(key string, val []byte) error { 190 | 191 | err := rq.queue.Lpush(key, val) 192 | if err == nil { 193 | return nil 194 | } 195 | 196 | netErr := err.(net.Error) 197 | if netErr.Timeout() || netErr.Temporary() { 198 | return err 199 | } 200 | 201 | rq.waitForRedis() 202 | return rq.Lpush(key, val) // Recurse 203 | } 204 | 205 | func (rq *RedisQueue) Llen(key string) (int, error) { 206 | 207 | size, err := rq.queue.Llen(key) 208 | if err == nil { 209 | return size, nil 210 | } 211 | 212 | netErr := err.(net.Error) 213 | if netErr.Timeout() || netErr.Temporary() { 214 | return size, err 215 | } 216 | 217 | rq.waitForRedis() 218 | return rq.Llen(key) // Recurse 219 | } 220 | 221 | func (rq *RedisQueue) Ltrim(key string, start int, end int) error { 222 | 223 | err := rq.queue.Ltrim(key, start, end) 224 | if err == nil { 225 | return nil 226 | } 227 | 228 | netErr := err.(net.Error) 229 | if netErr.Timeout() || netErr.Temporary() { 230 | return err 231 | } 232 | 233 | rq.waitForRedis() 234 | return rq.Ltrim(key, start, end) // Recurse 235 | } 236 | 237 | func (rq *RedisQueue) Ping() (string, error) { 238 | return rq.queue.Ping() 239 | } 240 | -------------------------------------------------------------------------------- /common/storage.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "database/sql" 5 | "os" 6 | "time" 7 | 8 | "github.com/golang/glog" 9 | "github.com/lib/pq" 10 | ) 11 | 12 | // Storage. Wraps the database 13 | type Storage interface { 14 | BotConfig() []*BotConfig 15 | SetCount(string, int) error 16 | } 17 | 18 | /* 19 | * Mock STORAGE 20 | */ 21 | 22 | // Simplistic Storage implementation used by the test suite 23 | type MockStorage struct { 24 | botConfs []*BotConfig 25 | } 26 | 27 | func NewMockStorage(serverPort string) Storage { 28 | 29 | conf := map[string]string{ 30 | "nick": "test", 31 | "password": "testxyz", 32 | "server": "127.0.0.1:" + serverPort} 33 | channels := make([]*Channel, 0) 34 | channels = append(channels, &Channel{Id: 1, Name: "#unit", Fingerprint: "5876HKJGYUT"}) 35 | botConf := &BotConfig{Id: 1, Config: conf, Channels: channels} 36 | 37 | return &MockStorage{botConfs: []*BotConfig{botConf}} 38 | } 39 | 40 | func (ms *MockStorage) BotConfig() []*BotConfig { 41 | return ms.botConfs 42 | } 43 | 44 | func (ms *MockStorage) SetCount(channel string, count int) error { 45 | return nil 46 | } 47 | 48 | /* 49 | * POSTGRES STORAGE 50 | */ 51 | 52 | type PostgresStorage struct { 53 | db *sql.DB 54 | } 55 | 56 | // Connect to the database. 57 | func NewPostgresStorage() *PostgresStorage { 58 | postgresUrlString := os.Getenv("STORAGE_URL") 59 | if glog.V(2) { 60 | glog.Infoln("postgresUrlString: ", postgresUrlString) 61 | } 62 | if postgresUrlString == "" { 63 | glog.Fatal("STORAGE_URL cannot be empty.\nexport STORAGE_URL=postgres://user:password@host:port/db_name") 64 | } 65 | dataSource, err := pq.ParseURL(postgresUrlString) 66 | if err != nil { 67 | glog.Fatal("Could not read database string", err) 68 | } 69 | db, err := sql.Open("postgres", dataSource+" sslmode=disable fallback_application_name=bot") 70 | if err != nil { 71 | glog.Fatal("Could not connect to database.", err) 72 | } 73 | 74 | // The following 2 lines mitigate the leak of postgresql connection leak 75 | // explicitly setting a maximum number of postgresql connections 76 | db.SetMaxOpenConns(10) 77 | // explicitly setting a maximum number of Idle postgresql connections 78 | db.SetMaxIdleConns(2) 79 | 80 | return &PostgresStorage{db} 81 | } 82 | 83 | func (ps *PostgresStorage) BotConfig() []*BotConfig { 84 | 85 | var err error 86 | var rows *sql.Rows 87 | 88 | configs := make([]*BotConfig, 0) 89 | 90 | sql := "SELECT id, server, server_password, nick, password, real_name, server_identifier FROM bots_chatbot WHERE is_active=true" 91 | rows, err = ps.db.Query(sql) 92 | if err != nil { 93 | glog.Fatal("Error running: ", sql, " ", err) 94 | } 95 | defer rows.Close() 96 | 97 | var chatbotId int 98 | var server, server_password, nick, password, real_name, server_identifier []byte 99 | 100 | for rows.Next() { 101 | rows.Scan( 102 | &chatbotId, &server, &server_password, &nick, &password, 103 | &real_name, &server_identifier) 104 | 105 | confMap := map[string]string{ 106 | "server": string(server), 107 | "server_password": string(server_password), 108 | "nick": string(nick), 109 | "password": string(password), 110 | "realname": string(real_name), 111 | "server_identifier": string(server_identifier), 112 | } 113 | 114 | config := &BotConfig{ 115 | Id: chatbotId, 116 | Config: confMap, 117 | Channels: make([]*Channel, 0), 118 | } 119 | 120 | configs = append(configs, config) 121 | glog.Infoln("config.Id:", config.Id) 122 | } 123 | channelStmt, err := ps.db.Prepare("SELECT id, name, password, fingerprint FROM bots_channel WHERE status=$1 and chatbot_id=$2") 124 | if err != nil { 125 | glog.Fatal("[Error] Error while preparing the statements to retrieve the channel:", err) 126 | } 127 | defer channelStmt.Close() 128 | 129 | for i := range configs { 130 | config := configs[i] 131 | rows, err = channelStmt.Query("ACTIVE", config.Id) 132 | if err != nil { 133 | glog.Fatal("Error running:", err) 134 | } 135 | defer rows.Close() 136 | 137 | var channelId int 138 | var channelName, channelPwd, channelFingerprint string 139 | for rows.Next() { 140 | rows.Scan(&channelId, &channelName, &channelPwd, &channelFingerprint) 141 | config.Channels = append(config.Channels, 142 | &Channel{Id: channelId, Name: channelName, 143 | Pwd: channelPwd, Fingerprint: channelFingerprint}) 144 | } 145 | glog.Infoln("config.Channel:", config.Channels) 146 | } 147 | 148 | return configs 149 | } 150 | 151 | func (ms *PostgresStorage) SetCount(channel string, count int) error { 152 | 153 | now := time.Now() 154 | hour := now.Hour() 155 | 156 | channelId, err := ms.channelId(channel) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | // Write the count 162 | updateSQL := "UPDATE bots_usercount SET counts[$1] = $2 WHERE channel_id = $3 AND dt = $4" 163 | 164 | var res sql.Result 165 | res, err = ms.db.Exec(updateSQL, hour, count, channelId, now) 166 | if err != nil { 167 | return err 168 | } 169 | 170 | var rowCount int64 171 | rowCount, err = res.RowsAffected() 172 | if err != nil { 173 | return err 174 | } 175 | 176 | if rowCount == 1 { 177 | // Success - the update worked 178 | return nil 179 | } 180 | 181 | // Update failed, need to create the row first 182 | 183 | insSQL := "INSERT INTO bots_usercount (channel_id, dt, counts) VALUES ($1, $2, '{NULL}')" 184 | 185 | _, err = ms.db.Exec(insSQL, channelId, now) 186 | if err != nil { 187 | return err 188 | } 189 | 190 | // Run the update again 191 | _, err = ms.db.Query(updateSQL, hour, count, channelId, now) 192 | if err != nil { 193 | return err 194 | } 195 | 196 | return nil 197 | } 198 | 199 | // The channel Id for a given channel name 200 | func (ms *PostgresStorage) channelId(name string) (int, error) { 201 | 202 | var channelId int 203 | query := "SELECT id from bots_channel WHERE name = $1" 204 | 205 | rows, err := ms.db.Query(query, name) 206 | if err != nil { 207 | return -1, err 208 | } 209 | defer rows.Close() 210 | 211 | rows.Next() 212 | rows.Scan(&channelId) 213 | 214 | if rows.Next() { 215 | glog.Fatal("More than one result. "+ 216 | "Same name channels on different nets not yet supported. ", query) 217 | } 218 | 219 | return channelId, nil 220 | } 221 | 222 | func (ms *PostgresStorage) Close() error { 223 | return ms.db.Close() 224 | } 225 | -------------------------------------------------------------------------------- /dispatch/dispatch.go: -------------------------------------------------------------------------------- 1 | package dispatch 2 | 3 | import ( 4 | "github.com/golang/glog" 5 | 6 | "github.com/BotBotMe/botbot-bot/common" 7 | "github.com/BotBotMe/botbot-bot/line" 8 | ) 9 | 10 | const ( 11 | // Prefix of Redis channel to publish messages on 12 | QUEUE_PREFIX = "q" 13 | MAX_QUEUE_SIZE = 4096 14 | ) 15 | 16 | type Dispatcher struct { 17 | queue common.Queue 18 | } 19 | 20 | func NewDispatcher(queue common.Queue) *Dispatcher { 21 | 22 | dis := &Dispatcher{queue: queue} 23 | return dis 24 | } 25 | 26 | // Main method - send the line to relevant plugins 27 | func (dis *Dispatcher) Dispatch(l *line.Line) { 28 | 29 | var err error 30 | err = dis.queue.Rpush(QUEUE_PREFIX, l.JSON()) 31 | if err != nil { 32 | glog.Fatal("Error writing (RPUSH) to queue. ", err) 33 | } 34 | dis.limitQueue(QUEUE_PREFIX) 35 | } 36 | 37 | // Ensure the redis queue doesn't exceed a certain size 38 | func (dis *Dispatcher) limitQueue(key string) { 39 | 40 | size, err := dis.queue.Llen(key) 41 | if err != nil { 42 | glog.Fatal("Error LLEN on queue. ", err) 43 | } 44 | 45 | if size < MAX_QUEUE_SIZE { 46 | return 47 | } 48 | 49 | err = dis.queue.Ltrim(key, 0, MAX_QUEUE_SIZE) 50 | if err != nil { 51 | glog.Fatal("Error LTRIM on queue. ", err) 52 | } 53 | } 54 | 55 | // Dispatch the line to several channels. 56 | // We need this for QUIT for example, which goes to all channels 57 | // that user was in. 58 | func (dis *Dispatcher) DispatchMany(l *line.Line, channels []string) { 59 | 60 | for _, chName := range channels { 61 | l.Channel = chName 62 | dis.Dispatch(l) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /dispatch/dispatch_test.go: -------------------------------------------------------------------------------- 1 | package dispatch 2 | 3 | import ( 4 | "github.com/BotBotMe/botbot-bot/common" 5 | "github.com/BotBotMe/botbot-bot/line" 6 | "testing" 7 | ) 8 | 9 | func TestDispatch(t *testing.T) { 10 | 11 | dis := &Dispatcher{} 12 | l := line.Line{ 13 | ChatBotId: 12, 14 | Command: "PRIVMSG", 15 | Content: "Hello", 16 | Channel: "#foo"} 17 | 18 | queue := common.NewMockQueue() 19 | dis.queue = queue 20 | 21 | dis.Dispatch(&l) 22 | 23 | if len(queue.Got) != 1 { 24 | t.Error("Dispatch did not go on queue") 25 | } 26 | 27 | received, _ := queue.Got[QUEUE_PREFIX] 28 | if received == nil { 29 | t.Error("Expected '", QUEUE_PREFIX, "' as queue name") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /line/line.go: -------------------------------------------------------------------------------- 1 | package line 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | "github.com/golang/glog" 8 | ) 9 | 10 | // Custom error 11 | var ( 12 | ErrLineShort = errors.New("line too short") 13 | ErrLineMalformed = errors.New("malformed line") 14 | ) 15 | 16 | // Line represent an IRC line 17 | type Line struct { 18 | ChatBotId int 19 | Raw string 20 | Received string 21 | User string 22 | Host string 23 | Command string 24 | Args []string 25 | Content string 26 | IsCTCP bool 27 | Channel string 28 | BotNick string 29 | } 30 | 31 | // NewFromJSON returns a pointer to line 32 | func NewFromJSON(b []byte) (*Line, error) { 33 | var l Line 34 | err := json.Unmarshal(b, &l) 35 | if err != nil { 36 | glog.Errorln("An error occured while unmarshalling the line") 37 | return nil, err 38 | } 39 | glog.V(2).Infoln("line", l) 40 | return &l, nil 41 | 42 | } 43 | 44 | // String returns a JSON string 45 | func (l *Line) String() string { 46 | return string(l.JSON()) 47 | } 48 | 49 | // JSON returns a JSON []byte 50 | func (l *Line) JSON() []byte { 51 | jsonData, err := json.Marshal(l) 52 | if err != nil { 53 | glog.Infoln("Error on json Marshal of "+l.Raw, err) 54 | } 55 | // client expects lines to have an ending 56 | jsonData = append(jsonData, '\n') 57 | return jsonData 58 | } 59 | -------------------------------------------------------------------------------- /line/line_test.go: -------------------------------------------------------------------------------- 1 | package line 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func testLine() (l Line) { 12 | l = Line{ 13 | ChatBotId: 1, 14 | Raw: "Raw", 15 | Received: "Received", 16 | User: "bob", 17 | Host: "example.com", 18 | Command: "PRIVMSG", 19 | Args: []string{"one", "two"}, 20 | Content: "The content", 21 | Channel: "lincolnloop"} 22 | return 23 | } 24 | 25 | // Create a Line object, and call String method. 26 | func TestCreateLine(t *testing.T) { 27 | l := testLine() 28 | if !strings.Contains(l.String(), "bob") { 29 | t.Error("Line did not render as string correctly") 30 | } 31 | } 32 | 33 | func TestLineString(t *testing.T) { 34 | srcLine := testLine() 35 | var trgtLine Line 36 | err := json.Unmarshal(srcLine.JSON(), &trgtLine) 37 | if err != nil { 38 | t.Error("Cannot Unmarshal a Line:", srcLine.String()) 39 | } 40 | srcValue := reflect.ValueOf(&srcLine).Elem() 41 | trgtValue := reflect.ValueOf(&trgtLine).Elem() 42 | for i := 0; i < srcValue.NumField(); i++ { 43 | if srcValue.Field(i).Kind() != trgtValue.Field(i).Kind() { 44 | t.Error("Field", i, "does not have the same kind") 45 | } 46 | if fmt.Sprintf("%v", srcValue.Field(i)) != fmt.Sprintf("%v", trgtValue.Field(i)) { 47 | t.Error("Field", i, "does not have the same string representation") 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "expvar" 5 | "flag" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/BotBotMe/botbot-bot/common" 13 | "github.com/golang/glog" 14 | _ "net/http/pprof" 15 | 16 | ) 17 | 18 | const ( 19 | // Prefix of Redis channel to listen for messages on 20 | LISTEN_QUEUE_PREFIX = "bot" 21 | ) 22 | 23 | func main() { 24 | flag.Parse() 25 | glog.Infoln("START. Use 'botbot -help' for command line options.") 26 | 27 | storage := common.NewPostgresStorage() 28 | defer storage.Close() 29 | 30 | queue := common.NewRedisQueue() 31 | 32 | botbot := NewBotBot(storage, queue) 33 | 34 | // Listen for incoming commands 35 | go botbot.listen(LISTEN_QUEUE_PREFIX) 36 | 37 | // Start the main loop 38 | go botbot.mainLoop() 39 | 40 | // Start and http server to serve the stats from expvar 41 | log.Fatal(http.ListenAndServe(":3030", nil)) 42 | 43 | // Trap stop signal (Ctrl-C, kill) to exit 44 | kill := make(chan os.Signal) 45 | signal.Notify(kill, syscall.SIGINT, syscall.SIGKILL, syscall.SIGTERM) 46 | 47 | // Wait for stop signal 48 | for { 49 | <-kill 50 | glog.Infoln("Graceful shutdown") 51 | botbot.shutdown() 52 | break 53 | } 54 | 55 | glog.Infoln("Bye") 56 | } 57 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/BotBotMe/botbot-bot/common" 9 | "github.com/golang/glog" 10 | ) 11 | 12 | const ( 13 | SERVER_PORT = "60667" 14 | TEST_MSG = "q: Something new" 15 | ) 16 | 17 | func GetQueueLength(queue *common.MockQueue) int { 18 | queue.RLock() 19 | q := queue.Got["q"] 20 | queue.RUnlock() 21 | return len(q) 22 | } 23 | 24 | // TODO (yml) this test is broken because ircBot establish first a tls conn 25 | // we need to find a better way to handle this. 26 | 27 | // A serious integration test for BotBot. 28 | // This covers BotBot, the IRC code, and the dispatcher. 29 | func TestBotBotIRC(t *testing.T) { 30 | common.SetGlogFlags() 31 | 32 | // Create a mock storage with configuration in it 33 | storage := common.NewMockStorage(SERVER_PORT) 34 | 35 | // Create a mock queue to gather BotBot output 36 | queue := common.NewMockQueue() 37 | 38 | // Start a Mock IRC server, and gather writes to it 39 | server := common.NewMockIRCServer(TEST_MSG, SERVER_PORT) 40 | go server.Run(t) 41 | 42 | // Run BotBot 43 | time.Sleep(time.Second) // Sleep of one second to avoid the 5s backoff 44 | botbot := NewBotBot(storage, queue) 45 | go botbot.listen("testcmds") 46 | go botbot.mainLoop() 47 | waitForServer(server, 4) 48 | 49 | // this sleep allow us to keep the answer in the right order 50 | time.Sleep(time.Second) 51 | // Test sending a reply - should probably be separate test 52 | queue.ReadChannel <- "WRITE 1 #unit I am a plugin response" 53 | waitForServer(server, 6) 54 | 55 | tries := 0 56 | queue.RLock() 57 | q := queue.Got["q"] 58 | queue.RUnlock() 59 | 60 | for len(q) < 4 && tries < 4 { 61 | queue.RLock() 62 | q = queue.Got["q"] 63 | queue.RUnlock() 64 | 65 | glog.V(4).Infoln("[Debug] queue.Got[\"q\"]", len(q), "/", 4, q) 66 | time.Sleep(time.Second) 67 | tries++ 68 | } 69 | checkContains(q, TEST_MSG, t) 70 | 71 | // Check IRC server expectations 72 | 73 | if server.GotLength() != 7 { 74 | t.Fatal("Expected exactly 7 IRC messages from the bot. Got ", server.GotLength()) 75 | } 76 | 77 | glog.Infoln("[Debug] server.Got", server.Got) 78 | expect := []string{"PING", "CAP", "USER", "NICK", "NickServ", "JOIN", "PRIVMSG"} 79 | for i := 0; i < 5; i++ { 80 | if !strings.Contains(string(server.Got[i]), expect[i]) { 81 | t.Error("Line ", i, " did not contain ", expect[i], ". It is: ", server.Got[i]) 82 | } 83 | } 84 | 85 | // test shutdown - should probably be separate test 86 | 87 | botbot.shutdown() 88 | 89 | tries = 0 90 | val := 5 91 | for len(q) < val && tries < val { 92 | queue.RLock() 93 | q = queue.Got["q"] 94 | queue.RUnlock() 95 | glog.V(4).Infoln("[Debug] queue.Got[\"q\"]", len(q), "/", val, q) 96 | time.Sleep(time.Second) 97 | tries++ 98 | } 99 | 100 | queue.RLock() 101 | checkContains(queue.Got["q"], "SHUTDOWN", t) 102 | queue.RUnlock() 103 | } 104 | 105 | // Block until len(target.Get) is at least val, or timeout 106 | func waitForServer(target *common.MockIRCServer, val int) { 107 | tries := 0 108 | for target.GotLength() < val && tries < val*3 { 109 | time.Sleep(time.Millisecond * 500) 110 | glog.V(4).Infoln("[Debug] val", target.GotLength(), "/", val, " target.Got:", target.Got) 111 | tries++ 112 | } 113 | glog.Infoln("[Debug] waitForServer val", target.GotLength(), "/", val, " target.Got:", target.Got) 114 | 115 | } 116 | 117 | // Check that "val" is in one of the strings in "arr". t.Error if not. 118 | func checkContains(arr []string, val string, t *testing.T) { 119 | glog.Infoln("[Debug] checkContains", val, "in", arr) 120 | 121 | isFound := false 122 | for _, item := range arr { 123 | if strings.Contains(item, val) { 124 | isFound = true 125 | break 126 | } 127 | } 128 | if !isFound { 129 | t.Error("Queue did not get a message containing:", val) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /network/irc/irc.go: -------------------------------------------------------------------------------- 1 | // IRC server connection 2 | // 3 | // Connecting to an IRC server goes like this: 4 | // 1. Connect to the conn. Wait for a response (anything will do). 5 | // 2. Send USER and NICK. Wait for a response (anything). 6 | // 2.5 If we have a password, wait for NickServ to ask for it, and to confirm authentication 7 | // 3. JOIN channels 8 | 9 | package irc 10 | 11 | import ( 12 | "bufio" 13 | "bytes" 14 | "crypto/tls" 15 | "crypto/x509" 16 | "encoding/base64" 17 | "expvar" 18 | "fmt" 19 | "io" 20 | "net" 21 | "sort" 22 | "strings" 23 | "sync" 24 | "time" 25 | "unicode/utf8" 26 | 27 | "github.com/golang/glog" 28 | 29 | "github.com/BotBotMe/botbot-bot/common" 30 | "github.com/BotBotMe/botbot-bot/line" 31 | ) 32 | 33 | const ( 34 | // VERSION of the botbot-bot 35 | VERSION = "botbot v0.3.0" 36 | // RPL_WHOISCHANNELS IRC command code from the spec 37 | RPL_WHOISCHANNELS = "319" 38 | ) 39 | 40 | type chatBotStats struct { 41 | sync.RWMutex 42 | m map[string]*expvar.Map 43 | } 44 | 45 | func (s *chatBotStats) GetOrCreate(identifier string) (*expvar.Map, bool) { 46 | s.RLock() 47 | chatbotStats, ok := s.m[identifier] 48 | s.RUnlock() 49 | if !ok { 50 | chatbotStats = expvar.NewMap(identifier) 51 | s.Lock() 52 | s.m[identifier] = chatbotStats 53 | s.Unlock() 54 | } 55 | return chatbotStats, ok 56 | } 57 | 58 | var ( 59 | // BotStats hold the references to the expvar.Map for each ircBot instance 60 | BotStats = chatBotStats{m: make(map[string]*expvar.Map)} 61 | ) 62 | 63 | type ircBot struct { 64 | sync.RWMutex 65 | id int 66 | address string 67 | nick string 68 | realname string 69 | password string 70 | serverPass string 71 | serverIdentifier string 72 | rateLimit time.Duration // Duration used to rate limit send 73 | channels []*common.Channel 74 | isConnecting bool 75 | isAuthenticating bool 76 | isClosed bool 77 | sendQueue chan []byte 78 | fromServer chan *line.Line 79 | pingResponse chan struct{} 80 | closing chan struct{} 81 | } 82 | 83 | // NewBot create an irc instance of ChatBot 84 | func NewBot(config *common.BotConfig, fromServer chan *line.Line) common.ChatBot { 85 | // realname is set to config["realname"] or config["nick"] 86 | realname := config.Config["realname"] 87 | if realname == "" { 88 | realname = config.Config["nick"] 89 | } 90 | 91 | // Initialize the bot. 92 | chatbot := &ircBot{ 93 | id: config.Id, 94 | address: config.Config["server"], 95 | nick: config.Config["nick"], 96 | realname: realname, 97 | password: config.Config["password"], // NickServ password 98 | serverPass: config.Config["server_password"], // PASS password 99 | serverIdentifier: config.Config["server_identifier"], 100 | rateLimit: time.Second, 101 | fromServer: fromServer, 102 | channels: config.Channels, 103 | pingResponse: make(chan struct{}, 10), // HACK: This is to avoid the current deadlock 104 | sendQueue: make(chan []byte, 256), 105 | closing: make(chan struct{}), 106 | } 107 | 108 | chatbotStats, ok := BotStats.GetOrCreate(chatbot.serverIdentifier) 109 | 110 | // Initialize the counter for the exported variable 111 | if !ok { 112 | chatbotStats.Add("channels", 0) 113 | chatbotStats.Add("sent_messages", 0) 114 | chatbotStats.Add("received_messages", 0) 115 | chatbotStats.Add("ping", 0) 116 | chatbotStats.Add("pong", 0) 117 | chatbotStats.Add("missed_ping", 0) 118 | chatbotStats.Add("restart", 0) 119 | chatbotStats.Add("reply_whoischannels", 0) 120 | } 121 | 122 | conn := chatbot.connect() 123 | chatbot.init(conn) 124 | return chatbot 125 | } 126 | 127 | // GetUser returns the bot.nick 128 | func (bot *ircBot) GetUser() string { 129 | bot.RLock() 130 | defer bot.RUnlock() 131 | return bot.nick 132 | } 133 | 134 | // IsRunning the isRunning field 135 | func (bot *ircBot) IsRunning() bool { 136 | bot.RLock() 137 | defer bot.RUnlock() 138 | return !bot.isClosed 139 | } 140 | 141 | // GetStats returns the expvar.Map for this bot 142 | func (bot *ircBot) GetStats() *expvar.Map { 143 | stats, _ := BotStats.GetOrCreate(bot.serverIdentifier) 144 | return stats 145 | } 146 | 147 | // String returns the string representation of the bot 148 | func (bot *ircBot) String() string { 149 | bot.RLock() 150 | defer bot.RUnlock() 151 | return fmt.Sprintf("%s on %s (%p)", bot.nick, bot.address, bot) 152 | } 153 | 154 | // listenSendMonitor is the main goroutine of the ircBot it listens to the conn 155 | // send response to irc via the conn and it check that the conn is healthy if 156 | // it is not it try to reconnect. 157 | func (bot *ircBot) listenSendMonitor(quit chan struct{}, receive chan string, conn io.ReadWriteCloser) { 158 | var pingTimeout <-chan time.Time 159 | reconnect := make(chan struct{}) 160 | // TODO maxPongWithoutMessage should probably be a field of ircBot 161 | maxPingWithoutResponse := 1 // put it back to 3 162 | maxPongWithoutMessage := 150 163 | pongCounter := 0 164 | missedPing := 0 165 | whoisTimerChan := time.After(time.Minute * 5) 166 | 167 | botStats := bot.GetStats() 168 | for { 169 | select { 170 | case <-quit: 171 | return 172 | case <-reconnect: 173 | glog.Infoln("IRC monitoring KO shutting down", bot) 174 | botStats.Add("restart", 1) 175 | err := bot.Close() 176 | if err != nil { 177 | glog.Errorln("An error occured while Closing the bot", bot, ": ", err) 178 | } 179 | return 180 | case <-whoisTimerChan: 181 | bot.Whois() 182 | whoisTimerChan = time.After(time.Minute * 5) 183 | case <-time.After(time.Second * 60): 184 | glog.Infoln("[Info] Ping the ircBot server", pongCounter, bot) 185 | botStats.Add("ping", 1) 186 | bot.SendRaw("PING 1") 187 | // Activate the ping timeout case 188 | pingTimeout = time.After(time.Second * 10) 189 | case <-bot.pingResponse: 190 | // deactivate the case waiting for a pingTimeout because we got a response 191 | pingTimeout = nil 192 | botStats.Add("pong", 1) 193 | pongCounter++ 194 | if glog.V(1) { 195 | glog.Infoln("[Info] Pong from ircBot server", bot) 196 | } 197 | if pongCounter > maxPongWithoutMessage { 198 | close(reconnect) 199 | } 200 | case <-pingTimeout: 201 | // Deactivate the pingTimeout case 202 | pingTimeout = nil 203 | botStats.Add("missed_ping", 1) 204 | missedPing++ 205 | glog.Infoln("[Info] No pong from ircBot server", bot, "missed", missedPing) 206 | if missedPing > maxPingWithoutResponse { 207 | close(reconnect) 208 | } 209 | case content := <-receive: 210 | theLine, err := parseLine(content) 211 | if err == nil { 212 | theLine.BotNick = bot.nick 213 | botStats.Add("received_messages", 1) 214 | bot.RLock() 215 | theLine.ChatBotId = bot.id 216 | bot.RUnlock() 217 | bot.act(theLine) 218 | pongCounter = 0 219 | missedPing = 0 220 | // Deactivate the pingTimeout case 221 | pingTimeout = nil 222 | 223 | } else { 224 | glog.Errorln("Invalid line:", content) 225 | } 226 | // Rate limit to one message every tempo 227 | // // https://github.com/BotBotMe/botbot-bot/issues/2 228 | case data := <-bot.sendQueue: 229 | glog.V(3).Infoln(bot, " Pulled data from bot.sendQueue chan:", string(data)) 230 | if glog.V(2) { 231 | glog.Infoln("[RAW", bot, "] -->", string(data)) 232 | } 233 | _, err := conn.Write(data) 234 | if err != nil { 235 | glog.Errorln("Error writing to conn to", bot, ": ", err) 236 | close(reconnect) 237 | } 238 | botStats.Add("sent_messages", 1) 239 | time.Sleep(bot.rateLimit) 240 | 241 | } 242 | } 243 | } 244 | 245 | // init initializes the conn to the ircServer and start all the gouroutines 246 | // requires to run ircBot 247 | func (bot *ircBot) init(conn io.ReadWriteCloser) { 248 | glog.Infoln("Init bot", bot) 249 | 250 | quit := make(chan struct{}) 251 | receive := make(chan string) 252 | 253 | go bot.readSocket(quit, receive, conn) 254 | 255 | // Listen for incoming messages in background thread 256 | go bot.listenSendMonitor(quit, receive, conn) 257 | 258 | go func(bot *ircBot, conn io.Closer) { 259 | for { 260 | select { 261 | case <-bot.closing: 262 | err := conn.Close() 263 | if err != nil { 264 | glog.Errorln("An error occured while closing the conn of", bot, err) 265 | } 266 | close(quit) 267 | return 268 | } 269 | } 270 | }(bot, conn) 271 | 272 | bot.RLock() 273 | if bot.serverPass != "" { 274 | bot.SendRaw("PASS " + bot.serverPass) 275 | } 276 | bot.RUnlock() 277 | 278 | bot.SendRaw("PING Bonjour") 279 | } 280 | 281 | // connect to the server. Here we keep trying every 10 seconds until we manage 282 | // to Dial to the server. 283 | func (bot *ircBot) connect() (conn io.ReadWriteCloser) { 284 | 285 | var ( 286 | err error 287 | counter int 288 | ) 289 | 290 | connectTimeout := time.After(0) 291 | 292 | bot.Lock() 293 | bot.isConnecting = true 294 | bot.isAuthenticating = false 295 | bot.Unlock() 296 | 297 | for { 298 | select { 299 | case <-connectTimeout: 300 | counter++ 301 | connectTimeout = nil 302 | glog.Infoln("[Info] Connecting to IRC server: ", bot.address) 303 | conn, err = tls.Dial("tcp", bot.address, nil) // Always try TLS first 304 | if err == nil { 305 | glog.Infoln("Connected: TLS secure") 306 | return conn 307 | } else if _, ok := err.(x509.HostnameError); ok { 308 | glog.Errorln("Could not connect using TLS because: ", err) 309 | // Certificate might not match. This happens on irc.cloudfront.net 310 | insecure := &tls.Config{InsecureSkipVerify: true} 311 | conn, err = tls.Dial("tcp", bot.address, insecure) 312 | 313 | if err == nil && isCertValid(conn.(*tls.Conn)) { 314 | glog.Errorln("Connected: TLS with awkward certificate") 315 | return conn 316 | } 317 | } else if _, ok := err.(x509.UnknownAuthorityError); ok { 318 | glog.Errorln("x509.UnknownAuthorityError : ", err) 319 | insecure := &tls.Config{InsecureSkipVerify: true} 320 | conn, err = tls.Dial("tcp", bot.address, insecure) 321 | if err == nil { 322 | glog.Infoln("Connected: TLS with an x509.UnknownAuthorityError", err) 323 | return conn 324 | } 325 | } else { 326 | glog.Errorln("Could not establish a tls connection", err) 327 | 328 | } 329 | 330 | conn, err = net.Dial("tcp", bot.address) 331 | if err == nil { 332 | glog.Infoln("Connected: Plain text insecure") 333 | return conn 334 | } 335 | // TODO (yml) At some point we might want to panic 336 | delay := 5 * counter 337 | glog.Infoln("IRC Connect error. Will attempt to re-connect. ", err, "in", delay, "seconds") 338 | connectTimeout = time.After(time.Duration(delay) * time.Second) 339 | } 340 | } 341 | } 342 | 343 | /* Check that the TLS connection's certficate can be applied to this connection. 344 | Because irc.coldfront.net presents a certificate not as irc.coldfront.net, but as it's actual host (e.g. snow.coldfront.net), 345 | 346 | We do this by comparing the IP address of the certs name to the IP address of our connection. 347 | If they match we're OK. 348 | */ 349 | func isCertValid(conn *tls.Conn) bool { 350 | connAddr := strings.Split(conn.RemoteAddr().String(), ":")[0] 351 | cert := conn.ConnectionState().PeerCertificates[0] 352 | 353 | if len(cert.DNSNames) == 0 { 354 | // Cert has single name, the usual case 355 | return isIPMatch(cert.Subject.CommonName, connAddr) 356 | 357 | } 358 | // Cert has several valid names 359 | for _, certname := range cert.DNSNames { 360 | if isIPMatch(certname, connAddr) { 361 | return true 362 | } 363 | } 364 | 365 | return false 366 | } 367 | 368 | // Does hostname have IP address connIP? 369 | func isIPMatch(hostname string, connIP string) bool { 370 | glog.Infoln("Checking IP of", hostname) 371 | 372 | addrs, err := net.LookupIP(hostname) 373 | if err != nil { 374 | glog.Errorln("Error DNS lookup of ", hostname, ": ", err) 375 | return false 376 | } 377 | 378 | for _, ip := range addrs { 379 | if ip.String() == connIP { 380 | glog.Infoln("Accepting certificate anyway. ", hostname, " has same IP as connection") 381 | return true 382 | } 383 | } 384 | return false 385 | } 386 | 387 | // Update bot configuration. Called when webapp changes a chatbot's config. 388 | func (bot *ircBot) Update(config *common.BotConfig) { 389 | 390 | isNewServer := bot.updateServer(config) 391 | if isNewServer { 392 | glog.Infoln("[Info] the config is from a new server.") 393 | // If the server changed, we've already done nick and channel changes too 394 | return 395 | } 396 | glog.Infoln("[Info] bot.Update -- It is not a new server.") 397 | 398 | bot.updateNick(config.Config["nick"], config.Config["password"]) 399 | bot.updateChannels(config.Channels) 400 | } 401 | 402 | // Update the IRC server we're connected to 403 | func (bot *ircBot) updateServer(config *common.BotConfig) bool { 404 | 405 | addr := config.Config["server"] 406 | if addr == bot.address { 407 | return false 408 | } 409 | 410 | glog.Infoln("[Info] Changing IRC server from ", bot.address, " to ", addr) 411 | 412 | err := bot.Close() 413 | if err != nil { 414 | glog.Errorln("An error occured while Closing the bot", bot, ": ", err) 415 | } 416 | 417 | bot.address = addr 418 | bot.nick = config.Config["nick"] 419 | bot.password = config.Config["password"] 420 | bot.channels = config.Channels 421 | 422 | conn := bot.connect() 423 | bot.init(conn) 424 | 425 | return true 426 | } 427 | 428 | // Update the nickname we're registered under, if needed 429 | func (bot *ircBot) updateNick(newNick, newPass string) { 430 | glog.Infoln("[Info] Starting bot.updateNick()") 431 | 432 | bot.RLock() 433 | nick := bot.nick 434 | bot.RUnlock() 435 | if newNick == nick { 436 | glog.Infoln("[Info] bot.updateNick() -- the nick has not changed so return") 437 | return 438 | } 439 | glog.Infoln("[Info] bot.updateNick() -- set the new nick") 440 | 441 | bot.Lock() 442 | bot.nick = newNick 443 | bot.password = newPass 444 | bot.Unlock() 445 | bot.setNick() 446 | } 447 | 448 | // Update the channels based on new configuration, leaving old ones and joining new ones 449 | func (bot *ircBot) updateChannels(newChannels []*common.Channel) { 450 | glog.Infoln("[Info] Starting bot.updateChannels") 451 | bot.RLock() 452 | channels := bot.channels 453 | bot.RUnlock() 454 | 455 | glog.V(3).Infoln("[Debug] newChannels: ", newChannels, "bot.channels:", channels) 456 | 457 | if isEqual(newChannels, channels) { 458 | if glog.V(2) { 459 | glog.Infoln("Channels comparison is equals for bot: ", bot.nick) 460 | } 461 | return 462 | } 463 | glog.Infoln("[Info] The channels the bot is connected to need to be updated") 464 | 465 | // PART old ones 466 | for _, channel := range channels { 467 | if !isIn(channel, newChannels) { 468 | glog.Infoln("[Info] Parting new channel: ", channel.Credential()) 469 | bot.part(channel.Credential()) 470 | } 471 | } 472 | 473 | // JOIN new ones 474 | for _, channel := range newChannels { 475 | if !isIn(channel, channels) { 476 | glog.Infoln("[Info] Joining new channel: ", channel.Credential()) 477 | bot.join(channel.Credential()) 478 | } 479 | } 480 | bot.Lock() 481 | bot.channels = newChannels 482 | bot.Unlock() 483 | } 484 | 485 | // Join channels 486 | func (bot *ircBot) JoinAll() { 487 | for _, channel := range bot.channels { 488 | bot.join(channel.Credential()) 489 | } 490 | } 491 | 492 | // Whois is used to query information about the bot 493 | func (bot *ircBot) Whois() { 494 | // reset channel count so we can add them up from the response 495 | botStats := bot.GetStats() 496 | botStats.Set("reply_whoischannels", new(expvar.Int)) 497 | bot.SendRaw("WHOIS " + bot.nick) 498 | } 499 | 500 | // Join an IRC channel 501 | func (bot *ircBot) join(channel string) { 502 | bot.SendRaw("JOIN " + channel) 503 | botStats := bot.GetStats() 504 | botStats.Add("channels", 1) 505 | } 506 | 507 | // Leave an IRC channel 508 | func (bot *ircBot) part(channel string) { 509 | bot.SendRaw("PART " + channel) 510 | botStats := bot.GetStats() 511 | botStats.Add("channels", -1) 512 | } 513 | 514 | // Send a regular (non-system command) IRC message 515 | func (bot *ircBot) Send(channel, msg string) { 516 | fullmsg := "PRIVMSG " + channel + " :" + msg 517 | bot.SendRaw(fullmsg) 518 | } 519 | 520 | // Send message down conn. Add \n at end first. 521 | func (bot *ircBot) SendRaw(msg string) { 522 | bot.sendQueue <- []byte(msg + "\n") 523 | } 524 | 525 | // Tell the irc server who we are - we can't do anything until this is done. 526 | func (bot *ircBot) login() { 527 | 528 | bot.isAuthenticating = true 529 | 530 | bot.SendRaw("CAP REQ :sasl") 531 | // We use the botname as the 'realname', because bot's don't have real names! 532 | bot.SendRaw("USER " + bot.nick + " 0 * :" + bot.realname) 533 | 534 | bot.setNick() 535 | } 536 | 537 | // Tell the network our 538 | func (bot *ircBot) setNick() { 539 | bot.SendRaw("NICK " + bot.nick) 540 | } 541 | 542 | // Tell NickServ our password 543 | func (bot *ircBot) sendPassword() { 544 | bot.Send("NickServ", "identify "+bot.password) 545 | } 546 | 547 | func (bot *ircBot) sendSaslStart() { 548 | bot.SendRaw("AUTHENTICATE PLAIN") 549 | } 550 | 551 | func (bot *ircBot) sendSaslPass() { 552 | out := bytes.Join([][]byte{[]byte(bot.nick), []byte(bot.nick), []byte(bot.password)}, []byte{0}) 553 | encpass := base64.StdEncoding.EncodeToString(out) 554 | bot.SendRaw("AUTHENTICATE " + encpass) 555 | } 556 | 557 | func (bot *ircBot) sendSaslEnd() { 558 | bot.SendRaw("CAP END") 559 | } 560 | 561 | // Read from the conn 562 | func (bot *ircBot) readSocket(quit chan struct{}, receive chan string, conn io.ReadWriteCloser) { 563 | 564 | bufRead := bufio.NewReader(conn) 565 | for { 566 | select { 567 | case <-quit: 568 | return 569 | default: 570 | contentData, err := bufRead.ReadBytes('\n') 571 | if err != nil { 572 | netErr, ok := err.(net.Error) 573 | if ok && netErr.Timeout() == true { 574 | continue 575 | } else { 576 | glog.Errorln("An Error occured while reading from conn ", err) 577 | return 578 | } 579 | } 580 | 581 | if len(contentData) == 0 { 582 | continue 583 | } 584 | 585 | content := toUnicode(contentData) 586 | if glog.V(2) { 587 | glog.Infoln("[RAW", bot, "] <--", content) 588 | } 589 | receive <- content 590 | } 591 | } 592 | } 593 | 594 | func (bot *ircBot) act(theLine *line.Line) { 595 | // Notify the monitor goroutine that we receive a PONG 596 | if theLine.Command == "PONG" { 597 | if glog.V(2) { 598 | glog.Infoln("Sending the signal in bot.pingResponse") 599 | } 600 | bot.pingResponse <- struct{}{} 601 | return 602 | } 603 | 604 | bot.RLock() 605 | isConnecting := bot.isConnecting 606 | bot.RUnlock() 607 | // As soon as we receive a message from the server, complete initiatization 608 | if isConnecting { 609 | bot.Lock() 610 | bot.isConnecting = false 611 | bot.Unlock() 612 | bot.login() 613 | return 614 | } 615 | 616 | isAskingForSasl := strings.ToUpper(theLine.Command) == "CAP" && len(theLine.Args) == 2 && strings.ToUpper(theLine.Args[1]) == "ACK" && theLine.Content == "sasl" 617 | if isAskingForSasl { 618 | bot.sendSaslStart() 619 | return 620 | } 621 | 622 | isRefusingSasl := strings.ToUpper(theLine.Command) == "CAP" && len(theLine.Args) == 2 && strings.ToUpper(theLine.Args[1]) == "NAK" 623 | if isRefusingSasl { 624 | bot.sendSaslEnd() 625 | return 626 | } 627 | 628 | isAskingForSaslPass := theLine.User == "" && strings.ToUpper(theLine.Command) == "AUTHENTICATE" && len(theLine.Args) == 1 && theLine.Args[0] == "+" 629 | if isAskingForSaslPass { 630 | bot.sendSaslPass() 631 | return 632 | } 633 | 634 | isSaslConfirm := theLine.User == "" && theLine.Command == "903" 635 | // After SASL is accepted, join all the channels 636 | if isSaslConfirm { 637 | bot.sendSaslEnd() 638 | bot.Lock() 639 | bot.isAuthenticating = false 640 | bot.Unlock() 641 | bot.JoinAll() 642 | return 643 | } 644 | 645 | // NickServ interactions 646 | isNickServ := strings.Contains(theLine.User, "NickServ") 647 | 648 | // freenode, coldfront 649 | isAskingForPW := strings.Contains(theLine.Content, "This nickname is registered") 650 | // irc.mozilla.org - and probably others, they often remind us how to identify 651 | isAskingForPW = isAskingForPW || strings.Contains(theLine.Content, "NickServ IDENTIFY") 652 | 653 | // freenode 654 | isConfirm := strings.Contains(theLine.Content, "You are now identified") 655 | // irc.mozilla.org, coldfront 656 | isConfirm = isConfirm || strings.Contains(theLine.Content, "you are now recognized") 657 | 658 | if isNickServ { 659 | 660 | if isAskingForPW { 661 | bot.sendPassword() 662 | return 663 | 664 | } else if isConfirm { 665 | bot.Lock() 666 | bot.isAuthenticating = false 667 | bot.Unlock() 668 | bot.JoinAll() 669 | return 670 | } 671 | } 672 | 673 | // After USER / NICK is accepted, join all the channels, 674 | // assuming we don't need to identify with NickServ 675 | bot.RLock() 676 | shouldIdentify := bot.isAuthenticating && len(bot.password) == 0 677 | bot.RUnlock() 678 | if shouldIdentify { 679 | bot.Lock() 680 | bot.isAuthenticating = false 681 | bot.Unlock() 682 | bot.JoinAll() 683 | return 684 | } 685 | 686 | if theLine.Command == "PING" { 687 | // Reply, and send message on to client 688 | bot.SendRaw("PONG " + theLine.Content) 689 | } else if theLine.Command == "VERSION" { 690 | versionMsg := "NOTICE " + theLine.User + " :\u0001VERSION " + VERSION + "\u0001\n" 691 | bot.SendRaw(versionMsg) 692 | } else if theLine.Command == RPL_WHOISCHANNELS { 693 | glog.Infoln("[Info] reply_whoischannels -- len:", 694 | len(strings.Split(theLine.Content, " ")), "content:", theLine.Content) 695 | botStats := bot.GetStats() 696 | botStats.Add("reply_whoischannels", int64(len(strings.Split(theLine.Content, " ")))) 697 | } 698 | 699 | bot.fromServer <- theLine 700 | } 701 | 702 | // Close ircBot 703 | func (bot *ircBot) Close() (err error) { 704 | // Send a signal to all goroutine to return 705 | glog.Infoln("[Info] Closing bot.") 706 | bot.sendShutdown() 707 | close(bot.closing) 708 | bot.Lock() 709 | bot.isClosed = true 710 | bot.Unlock() 711 | botStats := bot.GetStats() 712 | // zero out gauges 713 | botStats.Set("channels", new(expvar.Int)) 714 | botStats.Set("reply_whoischannels", new(expvar.Int)) 715 | return err 716 | } 717 | 718 | // Send a non-standard SHUTDOWN message to the plugins 719 | // This allows them to know that this channel is offline 720 | func (bot *ircBot) sendShutdown() { 721 | glog.Infoln("[Info] Logging Shutdown command in the channels monitored by:", bot) 722 | bot.RLock() 723 | shutLine := &line.Line{ 724 | Command: "SHUTDOWN", 725 | Received: time.Now().UTC().Format(time.RFC3339Nano), 726 | ChatBotId: bot.id, 727 | User: bot.nick, 728 | Raw: "", 729 | Content: ""} 730 | 731 | for _, channel := range bot.channels { 732 | shutLine.Channel = channel.Credential() 733 | bot.fromServer <- shutLine 734 | } 735 | bot.RUnlock() 736 | } 737 | 738 | /* 739 | * UTIL 740 | */ 741 | 742 | // Split a string into sorted array of strings: 743 | // e.g. "#bob, #alice" becomes ["#alice", "#bob"] 744 | func splitChannels(rooms string) []string { 745 | var channels = make([]string, 0) 746 | for _, s := range strings.Split(rooms, ",") { 747 | channels = append(channels, strings.TrimSpace(s)) 748 | } 749 | sort.Strings(channels) 750 | return channels 751 | } 752 | 753 | // Takes a raw string from IRC server and parses it 754 | func parseLine(data string) (*line.Line, error) { 755 | 756 | var prefix, command, trailing, user, host, raw string 757 | var args, parts []string 758 | var isCTCP bool 759 | 760 | data = sane(data) 761 | 762 | if len(data) <= 2 { 763 | return nil, line.ErrLineShort 764 | } 765 | 766 | raw = data 767 | if data[0] == ':' { // Do we have a prefix? 768 | parts = strings.SplitN(data[1:], " ", 2) 769 | if len(parts) != 2 { 770 | return nil, line.ErrLineMalformed 771 | } 772 | 773 | prefix = parts[0] 774 | data = parts[1] 775 | 776 | if strings.Contains(prefix, "!") { 777 | parts = strings.Split(prefix, "!") 778 | if len(parts) != 2 { 779 | return nil, line.ErrLineMalformed 780 | } 781 | user = parts[0] 782 | host = parts[1] 783 | 784 | } else { 785 | host = prefix 786 | } 787 | } 788 | 789 | if strings.Index(data, " :") != -1 { 790 | parts = strings.SplitN(data, " :", 2) 791 | if len(parts) != 2 { 792 | return nil, line.ErrLineMalformed 793 | } 794 | data = parts[0] 795 | args = strings.Split(data, " ") 796 | 797 | trailing = parts[1] 798 | 799 | // IRC CTCP uses ascii null byte 800 | if len(trailing) > 0 && trailing[0] == '\001' { 801 | isCTCP = true 802 | } 803 | trailing = sane(trailing) 804 | 805 | } else { 806 | args = strings.Split(data, " ") 807 | } 808 | 809 | command = args[0] 810 | args = args[1:len(args)] 811 | 812 | channel := "" 813 | for _, arg := range args { 814 | if strings.HasPrefix(arg, "#") { 815 | channel = arg 816 | break 817 | } 818 | } 819 | 820 | if len(channel) == 0 { 821 | if command == "PRIVMSG" { 822 | // A /query or /msg message, channel is first arg 823 | channel = args[0] 824 | } else if command == "JOIN" { 825 | // JOIN commands say which channel in content part of msg 826 | channel = trailing 827 | } 828 | } 829 | 830 | if strings.HasPrefix(trailing, "ACTION") { 831 | // Received a /me line 832 | parts = strings.SplitN(trailing, " ", 2) 833 | if len(parts) != 2 { 834 | return nil, line.ErrLineMalformed 835 | } 836 | trailing = parts[1] 837 | command = "ACTION" 838 | } else if strings.HasPrefix(trailing, "VERSION") { 839 | trailing = "" 840 | command = "VERSION" 841 | } 842 | 843 | theLine := &line.Line{ 844 | ChatBotId: -1, // Set later 845 | Raw: raw, 846 | Received: time.Now().UTC().Format(time.RFC3339Nano), 847 | User: user, 848 | Host: host, 849 | Command: command, 850 | Args: args, 851 | Content: trailing, 852 | IsCTCP: isCTCP, 853 | Channel: channel, 854 | } 855 | 856 | return theLine, nil 857 | } 858 | 859 | /* Trims a string to not include junk such as: 860 | - the null bytes after a character return 861 | - \n and \r 862 | - whitespace 863 | - Ascii char \001, which is the extended data delimiter, 864 | used for example in a /me command before 'ACTION'. 865 | See http://www.irchelp.org/irchelp/rfc/ctcpspec.html 866 | - Null bytes: \000 867 | */ 868 | func sane(data string) string { 869 | parts := strings.SplitN(data, "\n", 2) 870 | return strings.Trim(parts[0], " \n\r\001\000") 871 | } 872 | 873 | // Converts an array of bytes to a string 874 | // If the bytes are valid UTF-8, return those (as string), 875 | // otherwise assume we have ISO-8859-1 (latin1, and kinda windows-1252), 876 | // and use the bytes as unicode code points, because ISO-8859-1 is a 877 | // subset of unicode 878 | func toUnicode(data []byte) string { 879 | 880 | var result string 881 | 882 | if utf8.Valid(data) { 883 | result = string(data) 884 | } else { 885 | runes := make([]rune, len(data)) 886 | for index, val := range data { 887 | runes[index] = rune(val) 888 | } 889 | result = string(runes) 890 | } 891 | 892 | return result 893 | } 894 | 895 | // Are a and b equal? 896 | func isEqual(a, b []*common.Channel) (flag bool) { 897 | if len(a) == len(b) { 898 | for _, aCc := range a { 899 | flag = false 900 | for _, bCc := range b { 901 | if aCc.Fingerprint == bCc.Fingerprint { 902 | flag = true 903 | break 904 | } 905 | } 906 | if flag == false { 907 | return flag 908 | } 909 | } 910 | return true 911 | } 912 | return false 913 | } 914 | 915 | // Is a in b? container must be sorted 916 | func isIn(a *common.Channel, channels []*common.Channel) (flag bool) { 917 | flag = false 918 | for _, cc := range channels { 919 | if a.Fingerprint == cc.Fingerprint { 920 | flag = true 921 | break 922 | } 923 | } 924 | if flag == false { 925 | return flag 926 | } 927 | return true 928 | } 929 | -------------------------------------------------------------------------------- /network/irc/irc_test.go: -------------------------------------------------------------------------------- 1 | package irc 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/BotBotMe/botbot-bot/common" 10 | "github.com/BotBotMe/botbot-bot/line" 11 | "github.com/golang/glog" 12 | ) 13 | 14 | var ( 15 | NEW_CHANNEL = common.Channel{Name: "#unitnew", Fingerprint: "new-channel-uuid"} 16 | ) 17 | 18 | func TestParseLine_welcome(t *testing.T) { 19 | common.SetGlogFlags() 20 | 21 | line1 := ":barjavel.freenode.net 001 graham_king :Welcome to the freenode Internet Relay Chat Network graham_king" 22 | line, err := parseLine(line1) 23 | 24 | if err != nil { 25 | t.Error("parseLine error: ", err) 26 | } 27 | 28 | if line.Command != "001" { 29 | t.Error("Command incorrect") 30 | } 31 | if line.Host != "barjavel.freenode.net" { 32 | t.Error("Host incorrect") 33 | } 34 | } 35 | 36 | func TestParseLine_privmsg(t *testing.T) { 37 | common.SetGlogFlags() 38 | line1 := ":rnowak!~rnowak@q.ovron.com PRIVMSG #linode :totally" 39 | line, err := parseLine(line1) 40 | 41 | if err != nil { 42 | t.Error("parseLine error: ", err) 43 | } 44 | 45 | if line.Command != "PRIVMSG" { 46 | t.Error("Command incorrect. Got", line.Command) 47 | } 48 | if line.Host != "~rnowak@q.ovron.com" { 49 | t.Error("Host incorrect. Got", line.Host) 50 | } 51 | if line.User != "rnowak" { 52 | t.Error("User incorrect. Got", line.User) 53 | } 54 | if line.Channel != "#linode" { 55 | t.Error("Channel incorrect. Got", line.Channel) 56 | } 57 | if line.Content != "totally" { 58 | t.Error("Content incorrect. Got", line.Content) 59 | } 60 | } 61 | 62 | func TestParseLine_pm(t *testing.T) { 63 | common.SetGlogFlags() 64 | 65 | line1 := ":graham_king!graham_kin@i.love.debian.org PRIVMSG botbotme :hello" 66 | line, err := parseLine(line1) 67 | 68 | if err != nil { 69 | t.Error("parseLine error: ", err) 70 | } 71 | 72 | if line.Command != "PRIVMSG" { 73 | t.Error("Command incorrect. Got", line.Command) 74 | } 75 | if line.Channel != "botbotme" { 76 | t.Error("Channel incorrect. Got", line.Channel) 77 | } 78 | if line.Content != "hello" { 79 | t.Error("Content incorrect. Got", line.Content) 80 | } 81 | } 82 | 83 | func TestParseLine_list(t *testing.T) { 84 | common.SetGlogFlags() 85 | line1 := ":oxygen.oftc.net 322 graham_king #linode 412 :Linode Community Support | http://www.linode.com/ | Linodes in Asia-Pacific! - http://bit.ly/ooBzhV" 86 | line, err := parseLine(line1) 87 | 88 | if err != nil { 89 | t.Error("parseLine error: ", err) 90 | } 91 | 92 | if line.Command != "322" { 93 | t.Error("Command incorrect. Got", line.Command) 94 | } 95 | if line.Host != "oxygen.oftc.net" { 96 | t.Error("Host incorrect. Got", line.Host) 97 | } 98 | if line.Channel != "#linode" { 99 | t.Error("Channel incorrect. Got", line.Channel) 100 | } 101 | if !strings.Contains(line.Content, "Community Support") { 102 | t.Error("Content incorrect. Got", line.Content) 103 | } 104 | if line.Args[2] != "412" { 105 | t.Error("Args incorrect. Got", line.Args) 106 | } 107 | } 108 | 109 | func TestParseLine_quit(t *testing.T) { 110 | common.SetGlogFlags() 111 | line1 := ":nicolaslara!~nicolasla@c83-250-0-151.bredband.comhem.se QUIT :" 112 | line, err := parseLine(line1) 113 | if err != nil { 114 | t.Error("parse line error:", err) 115 | } 116 | if line.Command != "QUIT" { 117 | t.Error("Command incorrect. Got", line) 118 | } 119 | } 120 | 121 | func TestParseLine_part(t *testing.T) { 122 | common.SetGlogFlags() 123 | line1 := ":nicolaslara!~nicolasla@c83-250-0-151.bredband.comhem.se PART #lincolnloop-internal" 124 | line, err := parseLine(line1) 125 | if err != nil { 126 | t.Error("parse line error:", err) 127 | } 128 | if line.Command != "PART" { 129 | t.Error("Command incorrect. Got", line) 130 | } 131 | if line.Channel != "#lincolnloop-internal" { 132 | t.Error("Channel incorrect. Got", line.Channel) 133 | } 134 | } 135 | 136 | func TestParseLine_353(t *testing.T) { 137 | common.SetGlogFlags() 138 | line1 := ":hybrid7.debian.local 353 botbot = #test :@botbot graham_king" 139 | line, err := parseLine(line1) 140 | if err != nil { 141 | t.Error("parse line error:", err) 142 | } 143 | if line.Command != "353" { 144 | t.Error("Command incorrect. Got", line) 145 | } 146 | if line.Channel != "#test" { 147 | t.Error("Channel incorrect. Got", line.Channel) 148 | } 149 | if line.Content != "@botbot graham_king" { 150 | t.Error("Content incorrect. Got", line.Content) 151 | } 152 | } 153 | 154 | // Test sending messages too fast 155 | func TestFlood(t *testing.T) { 156 | common.SetGlogFlags() 157 | 158 | NUM := 5 159 | 160 | fromServer := make(chan *line.Line) 161 | receivedCounter := make(chan bool) 162 | mockSocket := common.MockSocket{Counter: receivedCounter} 163 | channels := make([]*common.Channel, 1) 164 | channels = append(channels, &common.Channel{Name: "test", Fingerprint: "uuid-string"}) 165 | 166 | chatbot := &ircBot{ 167 | id: 99, 168 | address: "fakehost", 169 | nick: "test", 170 | realname: "Unit Test", 171 | password: "test", 172 | serverIdentifier: "localhost.test", 173 | rateLimit: time.Second, 174 | fromServer: fromServer, 175 | channels: channels, 176 | pingResponse: make(chan struct{}, 10), // HACK: This is to avoid the current deadlock 177 | sendQueue: make(chan []byte, 256), 178 | } 179 | chatbot.init(&mockSocket) 180 | 181 | startTime := time.Now() 182 | 183 | // Send the messages 184 | for i := 0; i < NUM; i++ { 185 | chatbot.Send("test", "Msg "+strconv.Itoa(i)) 186 | } 187 | 188 | // Wait for them to 'arrive' at the socket 189 | for numGot := 0; numGot <= NUM; numGot++ { 190 | <-receivedCounter 191 | } 192 | 193 | elapsed := int64(time.Since(startTime)) 194 | 195 | expected := int64((NUM-1)/4) * int64(chatbot.rateLimit) 196 | if elapsed < expected { 197 | t.Error("Flood prevention did not work") 198 | } 199 | 200 | } 201 | 202 | // Test joining additional channels 203 | func TestUpdate(t *testing.T) { 204 | common.SetGlogFlags() 205 | glog.Infoln("[DEBUG] starting TestUpdate") 206 | 207 | fromServer := make(chan *line.Line) 208 | receiver := make(chan string, 10) 209 | mockSocket := common.MockSocket{Receiver: receiver} 210 | channels := make([]*common.Channel, 0, 2) 211 | channel := common.Channel{Name: "#test", Fingerprint: "uuid-string"} 212 | channels = append(channels, &channel) 213 | 214 | chatbot := &ircBot{ 215 | id: 99, 216 | address: "localhost", 217 | nick: "test", 218 | realname: "Unit Test", 219 | password: "test", 220 | serverIdentifier: "localhost.test1", 221 | fromServer: fromServer, 222 | channels: channels, 223 | rateLimit: time.Second, 224 | pingResponse: make(chan struct{}, 10), // HACK: This is to avoid the current deadlock 225 | sendQueue: make(chan []byte, 256), 226 | } 227 | chatbot.init(&mockSocket) 228 | conf := map[string]string{ 229 | "nick": "test", "password": "testxyz", "server": "localhost"} 230 | channels = append(channels, &NEW_CHANNEL) 231 | newConfig := &common.BotConfig{Id: 1, Config: conf, Channels: channels} 232 | 233 | // TODO (yml) there is probably better than sleeping but we need to wait 234 | // until chatbot is fully ready 235 | time.Sleep(time.Second * 2) 236 | chatbot.Update(newConfig) 237 | isFound := false 238 | for received := range mockSocket.Receiver { 239 | glog.Infoln("[DEBUG] received", received) 240 | if strings.TrimSpace(received) == "JOIN "+NEW_CHANNEL.Credential() { 241 | isFound = true 242 | close(mockSocket.Receiver) 243 | } else if received == "JOIN #test" { 244 | t.Error("Should not rejoin channels already in, can cause flood") 245 | } 246 | } 247 | if !isFound { 248 | t.Error("Expected JOIN " + NEW_CHANNEL.Credential()) 249 | } 250 | } 251 | 252 | func TestToUnicodeUTF8(t *testing.T) { 253 | common.SetGlogFlags() 254 | msg := "ελληνικά" 255 | result := toUnicode([]byte(msg)) 256 | if result != msg { 257 | t.Error("UTF8 error.", msg, "became", result) 258 | } 259 | } 260 | 261 | func TestToUnicodeLatin1(t *testing.T) { 262 | common.SetGlogFlags() 263 | msg := "âôé" 264 | latin1_bytes := []byte{0xe2, 0xf4, 0xe9} 265 | result := toUnicode(latin1_bytes) 266 | if result != msg { 267 | t.Error("ISO-8859-1 error.", msg, "became", result) 268 | } 269 | } 270 | 271 | func TestSplitChannels(t *testing.T) { 272 | common.SetGlogFlags() 273 | input := "#aone, #btwo, #cthree" 274 | result := splitChannels(input) 275 | if len(result) != 3 || result[2] != "#cthree" { 276 | t.Error("Error. Splitting", input, "gave", result) 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /network/network.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "sort" 5 | "sync" 6 | "time" 7 | 8 | "github.com/golang/glog" 9 | 10 | "github.com/BotBotMe/botbot-bot/common" 11 | "github.com/BotBotMe/botbot-bot/line" 12 | "github.com/BotBotMe/botbot-bot/network/irc" 13 | ) 14 | 15 | type NetworkManager struct { 16 | sync.RWMutex 17 | chatbots map[int]common.ChatBot 18 | fromServer chan *line.Line 19 | storage common.Storage 20 | isRunning bool 21 | } 22 | 23 | func NewNetworkManager(storage common.Storage, fromServer chan *line.Line) *NetworkManager { 24 | 25 | netMan := &NetworkManager{ 26 | chatbots: make(map[int]common.ChatBot), 27 | fromServer: fromServer, 28 | storage: storage, 29 | isRunning: true, 30 | } 31 | 32 | return netMan 33 | } 34 | 35 | func (nm *NetworkManager) IsRunning() bool { 36 | nm.RLock() 37 | defer nm.RUnlock() 38 | return nm.isRunning 39 | } 40 | 41 | // Get the User for a ChatbotId 42 | func (nm *NetworkManager) GetUserByChatbotId(id int) string { 43 | return nm.getChatbotById(id).GetUser() 44 | } 45 | 46 | // Connect to networks / start chatbots. Loads chatbot configuration from DB. 47 | func (nm *NetworkManager) RefreshChatbots() { 48 | if glog.V(2) { 49 | glog.Infoln("Entering in NetworkManager.RefreshChatbots") 50 | } 51 | botConfigs := nm.storage.BotConfig() 52 | 53 | var current common.ChatBot 54 | var id int 55 | active := make(sort.IntSlice, 0) 56 | 57 | // Create new ones 58 | for _, config := range botConfigs { 59 | id = config.Id 60 | active = append(active, id) 61 | 62 | nm.RLock() 63 | current = nm.chatbots[id] 64 | nm.RUnlock() 65 | if current == nil { 66 | // Create 67 | if glog.V(2) { 68 | glog.Infoln("Connect the bot with the following config:", config) 69 | } 70 | nm.Lock() 71 | nm.chatbots[id] = nm.Connect(config) 72 | nm.Unlock() 73 | } else { 74 | // Update 75 | if glog.V(2) { 76 | glog.Infoln("Update the bot with the following config:", config) 77 | } 78 | nm.chatbots[id].Update(config) 79 | } 80 | 81 | } 82 | 83 | // Stop old ones 84 | active.Sort() 85 | numActive := len(active) 86 | nm.Lock() 87 | for currId := range nm.chatbots { 88 | 89 | if active.Search(currId) == numActive { // if currId not in active: 90 | glog.Infoln("Stopping chatbot: ", currId) 91 | 92 | nm.chatbots[currId].Close() 93 | delete(nm.chatbots, currId) 94 | } 95 | } 96 | nm.Unlock() 97 | if glog.V(2) { 98 | glog.Infoln("Exiting NetworkManager.RefreshChatbots") 99 | } 100 | 101 | } 102 | 103 | func (nm *NetworkManager) Connect(config *common.BotConfig) common.ChatBot { 104 | 105 | glog.Infoln("Creating chatbot as:,", config) 106 | return irc.NewBot(config, nm.fromServer) 107 | } 108 | 109 | func (nm *NetworkManager) Send(chatbotId int, channel, msg string) { 110 | nm.RLock() 111 | nm.chatbots[chatbotId].Send(channel, msg) 112 | nm.RUnlock() 113 | } 114 | 115 | // Check out chatbots are alive, recreating them if not. Run this in go-routine. 116 | func (nm *NetworkManager) MonitorChatbots() { 117 | for nm.IsRunning() { 118 | for id, bot := range nm.chatbots { 119 | if !bot.IsRunning() { 120 | nm.restart(id) 121 | } 122 | } 123 | time.Sleep(1 * time.Second) 124 | } 125 | } 126 | 127 | // get a chatbot by id 128 | func (nm *NetworkManager) getChatbotById(id int) common.ChatBot { 129 | nm.RLock() 130 | defer nm.RUnlock() 131 | return nm.chatbots[id] 132 | } 133 | 134 | // Restart a chatbot 135 | func (nm *NetworkManager) restart(botId int) { 136 | 137 | glog.Infoln("Restarting bot ", botId) 138 | 139 | var config *common.BotConfig 140 | 141 | // Find configuration for this bot 142 | 143 | botConfigs := nm.storage.BotConfig() 144 | for _, botConf := range botConfigs { 145 | if botConf.Id == botId { 146 | config = botConf 147 | break 148 | } 149 | } 150 | 151 | if config == nil { 152 | glog.Infoln("Could not find configuration for bot ", botId, ". Bot will not run.") 153 | delete(nm.chatbots, botId) 154 | return 155 | } 156 | 157 | nm.Lock() 158 | nm.chatbots[botId] = nm.Connect(config) 159 | nm.Unlock() 160 | } 161 | 162 | // Stop all bots 163 | func (nm *NetworkManager) Shutdown() { 164 | nm.Lock() 165 | nm.isRunning = false 166 | for _, bot := range nm.chatbots { 167 | err := bot.Close() 168 | if err != nil { 169 | glog.Errorln("An error occured while Closing the bot", bot, ": ", err) 170 | } 171 | } 172 | nm.Unlock() 173 | } 174 | -------------------------------------------------------------------------------- /sql/botbot_sample.dump: -------------------------------------------------------------------------------- 1 | -- 2 | -- Data for Name: bots_chatbot; Type: TABLE DATA; Schema: public; Owner: botbot 3 | -- 4 | 5 | COPY bots_chatbot (id, is_active, server, server_password, server_identifier, password, real_name, nick, slug, max_channels) FROM stdin WITH CSV; 6 | 1,t,chat.freenode.net:6697,,chat-freenode-net-6697.botbot-test1,secret-password,https://botbot.me,botbot-test1,freenode,200 7 | \. 8 | 9 | -- 10 | -- Data for Name: bots_channel; Type: TABLE DATA; Schema: public; Owner: botbot 11 | -- 12 | 13 | COPY bots_channel (id, created, updated, name, slug, private_slug, password, is_public, is_featured, fingerprint, public_kudos, notes, chatbot_id, status) FROM stdin WITH CSV; 14 | 1,2017-05-30 14:48:10.559787-06,2017-05-30 15:47:48.704735-06,#botbot-test,botbot-test,,"",t,f,e193ef0f-aeae-4aa6-a17f-82897fa4c9c8,t,"",1,ACTIVE 15 | \. 16 | -------------------------------------------------------------------------------- /sql/schema.sql: -------------------------------------------------------------------------------- 1 | -- pg_dump --schema-only --no-owner --table bots_chatbot --table bots_channel --table bots_usercount botbot > sql/schema.sql 2 | -- 3 | -- PostgreSQL database dump 4 | -- 5 | 6 | -- Dumped from database version 9.6.2 7 | -- Dumped by pg_dump version 9.6.2 8 | 9 | SET statement_timeout = 0; 10 | SET lock_timeout = 0; 11 | SET idle_in_transaction_session_timeout = 0; 12 | SET client_encoding = 'UTF8'; 13 | SET standard_conforming_strings = on; 14 | SET check_function_bodies = false; 15 | SET client_min_messages = warning; 16 | SET row_security = off; 17 | 18 | SET search_path = public, pg_catalog; 19 | 20 | SET default_tablespace = ''; 21 | 22 | SET default_with_oids = false; 23 | 24 | -- 25 | -- Name: bots_channel; Type: TABLE; Schema: public; Owner: - 26 | -- 27 | 28 | CREATE TABLE bots_channel ( 29 | id integer NOT NULL, 30 | created timestamp with time zone NOT NULL, 31 | updated timestamp with time zone NOT NULL, 32 | name character varying(250) NOT NULL, 33 | slug character varying(50) NOT NULL, 34 | private_slug character varying(50), 35 | password character varying(250), 36 | is_public boolean NOT NULL, 37 | is_featured boolean NOT NULL, 38 | fingerprint character varying(36), 39 | public_kudos boolean NOT NULL, 40 | notes text NOT NULL, 41 | chatbot_id integer NOT NULL, 42 | status character varying(20) NOT NULL 43 | ); 44 | 45 | 46 | -- 47 | -- Name: bots_channel_id_seq; Type: SEQUENCE; Schema: public; Owner: - 48 | -- 49 | 50 | CREATE SEQUENCE bots_channel_id_seq 51 | START WITH 1 52 | INCREMENT BY 1 53 | NO MINVALUE 54 | NO MAXVALUE 55 | CACHE 1; 56 | 57 | 58 | -- 59 | -- Name: bots_channel_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 60 | -- 61 | 62 | ALTER SEQUENCE bots_channel_id_seq OWNED BY bots_channel.id; 63 | 64 | 65 | -- 66 | -- Name: bots_chatbot; Type: TABLE; Schema: public; Owner: - 67 | -- 68 | 69 | CREATE TABLE bots_chatbot ( 70 | id integer NOT NULL, 71 | is_active boolean NOT NULL, 72 | server character varying(100) NOT NULL, 73 | server_password character varying(100), 74 | server_identifier character varying(164) NOT NULL, 75 | nick character varying(64) NOT NULL, 76 | password character varying(100), 77 | real_name character varying(250) NOT NULL, 78 | slug character varying(50) NOT NULL, 79 | max_channels integer NOT NULL 80 | ); 81 | 82 | 83 | -- 84 | -- Name: bots_chatbot_id_seq; Type: SEQUENCE; Schema: public; Owner: - 85 | -- 86 | 87 | CREATE SEQUENCE bots_chatbot_id_seq 88 | START WITH 1 89 | INCREMENT BY 1 90 | NO MINVALUE 91 | NO MAXVALUE 92 | CACHE 1; 93 | 94 | 95 | -- 96 | -- Name: bots_chatbot_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 97 | -- 98 | 99 | ALTER SEQUENCE bots_chatbot_id_seq OWNED BY bots_chatbot.id; 100 | 101 | 102 | -- 103 | -- Name: bots_usercount; Type: TABLE; Schema: public; Owner: - 104 | -- 105 | 106 | CREATE TABLE bots_usercount ( 107 | id integer NOT NULL, 108 | dt date NOT NULL, 109 | counts integer[], 110 | channel_id integer NOT NULL 111 | ); 112 | 113 | 114 | -- 115 | -- Name: bots_usercount_id_seq; Type: SEQUENCE; Schema: public; Owner: - 116 | -- 117 | 118 | CREATE SEQUENCE bots_usercount_id_seq 119 | START WITH 1 120 | INCREMENT BY 1 121 | NO MINVALUE 122 | NO MAXVALUE 123 | CACHE 1; 124 | 125 | 126 | -- 127 | -- Name: bots_usercount_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 128 | -- 129 | 130 | ALTER SEQUENCE bots_usercount_id_seq OWNED BY bots_usercount.id; 131 | 132 | 133 | -- 134 | -- Name: bots_channel id; Type: DEFAULT; Schema: public; Owner: - 135 | -- 136 | 137 | ALTER TABLE ONLY bots_channel ALTER COLUMN id SET DEFAULT nextval('bots_channel_id_seq'::regclass); 138 | 139 | 140 | -- 141 | -- Name: bots_chatbot id; Type: DEFAULT; Schema: public; Owner: - 142 | -- 143 | 144 | ALTER TABLE ONLY bots_chatbot ALTER COLUMN id SET DEFAULT nextval('bots_chatbot_id_seq'::regclass); 145 | 146 | 147 | -- 148 | -- Name: bots_usercount id; Type: DEFAULT; Schema: public; Owner: - 149 | -- 150 | 151 | ALTER TABLE ONLY bots_usercount ALTER COLUMN id SET DEFAULT nextval('bots_usercount_id_seq'::regclass); 152 | 153 | 154 | -- 155 | -- Name: bots_channel bots_channel_name_2eb90853f7a0f4bc_uniq; Type: CONSTRAINT; Schema: public; Owner: - 156 | -- 157 | 158 | ALTER TABLE ONLY bots_channel 159 | ADD CONSTRAINT bots_channel_name_2eb90853f7a0f4bc_uniq UNIQUE (name, chatbot_id); 160 | 161 | 162 | -- 163 | -- Name: bots_channel bots_channel_pkey; Type: CONSTRAINT; Schema: public; Owner: - 164 | -- 165 | 166 | ALTER TABLE ONLY bots_channel 167 | ADD CONSTRAINT bots_channel_pkey PRIMARY KEY (id); 168 | 169 | 170 | -- 171 | -- Name: bots_channel bots_channel_private_slug_key; Type: CONSTRAINT; Schema: public; Owner: - 172 | -- 173 | 174 | ALTER TABLE ONLY bots_channel 175 | ADD CONSTRAINT bots_channel_private_slug_key UNIQUE (private_slug); 176 | 177 | 178 | -- 179 | -- Name: bots_channel bots_channel_slug_7ed9a1a261704004_uniq; Type: CONSTRAINT; Schema: public; Owner: - 180 | -- 181 | 182 | ALTER TABLE ONLY bots_channel 183 | ADD CONSTRAINT bots_channel_slug_7ed9a1a261704004_uniq UNIQUE (slug, chatbot_id); 184 | 185 | 186 | -- 187 | -- Name: bots_chatbot bots_chatbot_pkey; Type: CONSTRAINT; Schema: public; Owner: - 188 | -- 189 | 190 | ALTER TABLE ONLY bots_chatbot 191 | ADD CONSTRAINT bots_chatbot_pkey PRIMARY KEY (id); 192 | 193 | 194 | -- 195 | -- Name: bots_usercount bots_usercount_pkey; Type: CONSTRAINT; Schema: public; Owner: - 196 | -- 197 | 198 | ALTER TABLE ONLY bots_usercount 199 | ADD CONSTRAINT bots_usercount_pkey PRIMARY KEY (id); 200 | 201 | 202 | -- 203 | -- Name: bots_channel_2dbcba41; Type: INDEX; Schema: public; Owner: - 204 | -- 205 | 206 | CREATE INDEX bots_channel_2dbcba41 ON bots_channel USING btree (slug); 207 | 208 | 209 | -- 210 | -- Name: bots_channel_78239581; Type: INDEX; Schema: public; Owner: - 211 | -- 212 | 213 | CREATE INDEX bots_channel_78239581 ON bots_channel USING btree (chatbot_id); 214 | 215 | 216 | -- 217 | -- Name: bots_channel_private_slug_159f495e180a884e_like; Type: INDEX; Schema: public; Owner: - 218 | -- 219 | 220 | CREATE INDEX bots_channel_private_slug_159f495e180a884e_like ON bots_channel USING btree (private_slug varchar_pattern_ops); 221 | 222 | 223 | -- 224 | -- Name: bots_channel_slug_5af8c5a20a5fbc3a_like; Type: INDEX; Schema: public; Owner: - 225 | -- 226 | 227 | CREATE INDEX bots_channel_slug_5af8c5a20a5fbc3a_like ON bots_channel USING btree (slug varchar_pattern_ops); 228 | 229 | 230 | -- 231 | -- Name: bots_chatbot_2dbcba41; Type: INDEX; Schema: public; Owner: - 232 | -- 233 | 234 | CREATE INDEX bots_chatbot_2dbcba41 ON bots_chatbot USING btree (slug); 235 | 236 | 237 | -- 238 | -- Name: bots_chatbot_slug_58696fc5be763136_like; Type: INDEX; Schema: public; Owner: - 239 | -- 240 | 241 | CREATE INDEX bots_chatbot_slug_58696fc5be763136_like ON bots_chatbot USING btree (slug varchar_pattern_ops); 242 | 243 | 244 | -- 245 | -- Name: bots_usercount_72eb6c85; Type: INDEX; Schema: public; Owner: - 246 | -- 247 | 248 | CREATE INDEX bots_usercount_72eb6c85 ON bots_usercount USING btree (channel_id); 249 | 250 | 251 | -- 252 | -- Name: bots_channel bots_channel_chatbot_id_4185ac24f448d436_fk_bots_chatbot_id; Type: FK CONSTRAINT; Schema: public; Owner: - 253 | -- 254 | 255 | ALTER TABLE ONLY bots_channel 256 | ADD CONSTRAINT bots_channel_chatbot_id_4185ac24f448d436_fk_bots_chatbot_id FOREIGN KEY (chatbot_id) REFERENCES bots_chatbot(id) DEFERRABLE INITIALLY DEFERRED; 257 | 258 | 259 | -- 260 | -- Name: bots_usercount bots_usercount_channel_id_27c71f9cd90272cf_fk_bots_channel_id; Type: FK CONSTRAINT; Schema: public; Owner: - 261 | -- 262 | 263 | ALTER TABLE ONLY bots_usercount 264 | ADD CONSTRAINT bots_usercount_channel_id_27c71f9cd90272cf_fk_bots_channel_id FOREIGN KEY (channel_id) REFERENCES bots_channel(id) DEFERRABLE INITIALLY DEFERRED; 265 | 266 | 267 | -- 268 | -- PostgreSQL database dump complete 269 | -- 270 | -------------------------------------------------------------------------------- /user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/BotBotMe/botbot-bot/line" 5 | "strings" 6 | ) 7 | 8 | /* 9 | * USER MANAGER 10 | */ 11 | 12 | type UserManager struct { 13 | channels map[string]umChannel 14 | } 15 | 16 | // User Manager Channel - what user manager needs to know about a channel 17 | type umChannel struct { 18 | users map[string]bool 19 | } 20 | 21 | func NewUserManager() *UserManager { 22 | 23 | channels := make(map[string]umChannel, 10) // 10 is just a hint to Go 24 | 25 | users := UserManager{channels} 26 | return &users 27 | } 28 | 29 | // Remember which users are in which channels. 30 | // Call this on every server line. 31 | func (um *UserManager) Act(line *line.Line) { 32 | 33 | switch line.Command { 34 | 35 | case "NICK": 36 | oldNick := line.User 37 | newNick := line.Content 38 | um.replace(oldNick, newNick) 39 | 40 | case "JOIN": 41 | um.add(line.User, line.Channel) 42 | 43 | case "PART": 44 | um.remove(line.User) 45 | 46 | case "QUIT": 47 | um.remove(line.User) 48 | 49 | case "353": // Reply to /names 50 | content := line.Content 51 | for _, nick := range strings.Split(content, " ") { 52 | nick = strings.Trim(nick, "@+") 53 | um.add(nick, line.Channel) 54 | } 55 | } 56 | 57 | } 58 | 59 | // Number of current users in the channel 60 | func (um *UserManager) Count(channel string) int { 61 | 62 | ch, ok := um.channels[channel] 63 | if !ok { 64 | return 0 65 | } 66 | return len(ch.users) 67 | } 68 | 69 | // List of channels nick is in 70 | func (um *UserManager) In(nick string) []string { 71 | 72 | var res []string 73 | 74 | for name, ch := range um.channels { 75 | 76 | _, ok := ch.users[nick] 77 | if ok { 78 | res = append(res, name) 79 | } 80 | } 81 | 82 | return res 83 | } 84 | 85 | // All the channels we have user counts for, as keys in a map 86 | func (um *UserManager) Channels() map[string]umChannel { 87 | return um.channels 88 | } 89 | 90 | // Add user to channel 91 | func (um *UserManager) add(nick, channel string) { 92 | 93 | ch, ok := um.channels[channel] 94 | if !ok { // First user for that channel 95 | ch = umChannel{make(map[string]bool)} 96 | um.channels[channel] = ch 97 | } 98 | 99 | ch.users[nick] = true 100 | } 101 | 102 | // Remove user from all channels 103 | func (um *UserManager) remove(nick string) { 104 | 105 | for _, ch := range um.channels { 106 | delete(ch.users, nick) // If nick not in map, delete does nothing 107 | } 108 | } 109 | 110 | // Replace oldNick in every channel with newNick 111 | func (um *UserManager) replace(oldNick, newNick string) { 112 | 113 | for _, ch := range um.channels { 114 | 115 | _, ok := ch.users[oldNick] 116 | if ok { 117 | delete(ch.users, oldNick) 118 | ch.users[newNick] = true 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /user/user_test.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/BotBotMe/botbot-bot/line" 5 | "testing" 6 | ) 7 | 8 | // Add a nick to user manager 9 | func TestAdd(t *testing.T) { 10 | 11 | u := NewUserManager() 12 | u.add("bob", "test") 13 | u.add("bob", "test2") 14 | u.add("bill", "test") 15 | 16 | _, ok := u.channels["test"] 17 | if !ok { 18 | t.Error("UserManager did not have channel") 19 | } 20 | 21 | if !has(u, "test", "bob") { 22 | t.Error("Channel test did not have bob") 23 | } 24 | if !has(u, "test", "bill") { 25 | t.Error("Channel test did not have bill") 26 | } 27 | if !has(u, "test2", "bob") { 28 | t.Error("Channel test2 did not have bob") 29 | } 30 | } 31 | 32 | // Remove a nick from user manager 33 | func TestRemove(t *testing.T) { 34 | 35 | u := NewUserManager() 36 | u.add("bob", "test") 37 | u.add("bob", "test2") 38 | 39 | u.remove("bob") 40 | 41 | if has(u, "test", "bob") { 42 | t.Error("User not removed from test") 43 | } 44 | 45 | if has(u, "test2", "bob") { 46 | t.Error("User not removed from test2") 47 | } 48 | } 49 | 50 | // User changed their nick 51 | func TestReplace(t *testing.T) { 52 | 53 | u := NewUserManager() 54 | u.add("bob", "test") 55 | u.add("bob", "test2") 56 | u.add("bill", "test") 57 | 58 | u.replace("bob", "bob|away") 59 | 60 | if has(u, "test", "bob") { 61 | t.Error("bob was not renamed in test") 62 | } 63 | if !has(u, "test", "bob|away") { 64 | t.Error("bob|away not found in test") 65 | } 66 | 67 | if has(u, "test2", "bob") { 68 | t.Error("bob was not renamed in test2") 69 | } 70 | if !has(u, "test2", "bob|away") { 71 | t.Error("bob|away not found in test2") 72 | } 73 | 74 | if !has(u, "test", "bill") { 75 | t.Error("bill was renamed or removed") 76 | } 77 | } 78 | 79 | // Add several users at once 80 | func TestAddAct(t *testing.T) { 81 | 82 | l := line.Line{ 83 | Command: "353", 84 | Content: "@alice bob charles", 85 | Channel: "test"} 86 | 87 | u := NewUserManager() 88 | u.Act(&l) 89 | 90 | if !has(u, "test", "alice") { 91 | t.Error("alice missing") 92 | } 93 | if !has(u, "test", "bob") { 94 | t.Error("bob missing") 95 | } 96 | if !has(u, "test", "charles") { 97 | t.Error("charles missing") 98 | } 99 | } 100 | 101 | // List of channels a user is in 102 | func TestIn(t *testing.T) { 103 | 104 | u := NewUserManager() 105 | u.add("bob", "test") 106 | u.add("bob", "test2") 107 | u.add("bill", "test3") 108 | 109 | c := u.In("bob") 110 | 111 | if len(c) != 2 { 112 | t.Error("bob was not in exactly 2 channels") 113 | } 114 | 115 | if !(c[0] == "test" || c[1] == "test") { 116 | t.Error("test not in channel list") 117 | } 118 | if !(c[0] == "test2" || c[1] == "test2") { 119 | t.Error("test2 not in channel list") 120 | } 121 | } 122 | 123 | // Count the number of users in a channel 124 | func TestCount(t *testing.T) { 125 | 126 | u := NewUserManager() 127 | u.add("bob", "test") 128 | u.add("bob", "test2") 129 | u.add("bill", "test") 130 | u.add("bill", "test3") 131 | 132 | if u.Count("test") != 2 { 133 | t.Error("#test should have 2 users") 134 | } 135 | if u.Count("test2") != 1 { 136 | t.Error("#test2 should have 1 user") 137 | } 138 | if u.Count("test3") != 1 { 139 | t.Error("#test3 should have 1 user") 140 | } 141 | } 142 | 143 | // utility 144 | func has(u *UserManager, channel, nick string) bool { 145 | _, ok := u.channels[channel].users[nick] 146 | return ok 147 | } 148 | --------------------------------------------------------------------------------