├── .gitignore ├── go.mod ├── README.md ├── examples ├── eventschan.go ├── getandreadmessages.go ├── watchevents.go ├── getsubscriptions.go ├── privatemessage.go ├── publicmessage.go ├── subscribe.go └── atmessages.go ├── LICENSE ├── flag.go ├── message_test.go ├── bot_test.go ├── bot.go ├── queue.go └── message.go /.gitignore: -------------------------------------------------------------------------------- 1 | # helper scripts 2 | scripts/ 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ifo/gozulipbot 2 | 3 | go 1.14 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## GoZulipBot 2 | 3 | `gozulipbot` is a library to interact with Zulip in Go. 4 | It is primarily targeted toward making bots. 5 | 6 | ## Installation 7 | 8 | `go get github.com/ifo/gozulipbot` 9 | 10 | ## Usage 11 | 12 | Make sure to add `gozulipbot` to your imports: 13 | 14 | ```go 15 | import ( 16 | gzb "github.com/ifo/gozulipbot" 17 | ) 18 | ``` 19 | 20 | Check out the examples directory for more info. 21 | -------------------------------------------------------------------------------- /examples/eventschan.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | gzb "github.com/ifo/gozulipbot" 8 | ) 9 | 10 | func main() { 11 | bot := gzb.Bot{} 12 | err := bot.GetConfigFromFlags() 13 | if err != nil { 14 | log.Fatalln(err) 15 | } 16 | bot.Init() 17 | 18 | q, err := bot.RegisterAt() 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | msgs, stopFunc := q.EventsChan() 24 | 25 | // stop after a minute 26 | go func() { 27 | time.Sleep(1 * time.Minute) 28 | stopFunc() 29 | }() 30 | 31 | for m := range msgs { 32 | log.Println("message received") 33 | m.Queue.Bot.Respond(m, "hi forever!") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/getandreadmessages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | gzb "github.com/ifo/gozulipbot" 8 | ) 9 | 10 | func main() { 11 | bot := gzb.Bot{} 12 | err := bot.GetConfigFromFlags() 13 | if err != nil { 14 | log.Fatalln(err) 15 | } 16 | bot.Init() 17 | 18 | q, err := bot.RegisterAll() 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | messages, err := q.GetEvents() 24 | if err != nil { 25 | log.Fatal(err) 26 | } 27 | 28 | // print just the display recipients 29 | for _, m := range messages { 30 | fmt.Println(m.DisplayRecipient) 31 | } 32 | 33 | // print all the messages 34 | fmt.Println(messages) 35 | } 36 | -------------------------------------------------------------------------------- /examples/watchevents.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | gzb "github.com/ifo/gozulipbot" 8 | ) 9 | 10 | func main() { 11 | bot := gzb.Bot{} 12 | err := bot.GetConfigFromFlags() 13 | if err != nil { 14 | log.Fatalln(err) 15 | } 16 | bot.Init() 17 | 18 | q, err := bot.RegisterAt() 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | stopFunc := q.EventsCallback(respondToMessage) 24 | 25 | time.Sleep(1 * time.Minute) 26 | stopFunc() 27 | } 28 | 29 | func respondToMessage(em gzb.EventMessage, err error) { 30 | if err != nil { 31 | log.Println(err) 32 | return 33 | } 34 | 35 | log.Println("message received") 36 | 37 | em.Queue.Bot.Respond(em, "hi forever!") 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Steve McCarthy 2 | 3 | Permission to use, copy, modify, and/or distribute this 4 | software for any purpose with or without fee is hereby granted, 5 | provided that the above copyright notice and this permission 6 | notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL 9 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED 10 | WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL 11 | THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR 12 | CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, 14 | NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 15 | CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /examples/getsubscriptions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | 10 | gzb "github.com/ifo/gozulipbot" 11 | ) 12 | 13 | func main() { 14 | bot := gzb.Bot{} 15 | err := bot.GetConfigFromFlags() 16 | if err != nil { 17 | log.Fatalln(err) 18 | } 19 | bot.Init() 20 | 21 | bts := listSubscriptions(&bot) 22 | fmt.Printf(bts.String()) 23 | } 24 | 25 | func listSubscriptions(bot *gzb.Bot) bytes.Buffer { 26 | resp, err := bot.ListSubscriptions() 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | defer resp.Body.Close() 31 | body, err := ioutil.ReadAll(resp.Body) 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | var out bytes.Buffer 37 | err = json.Indent(&out, body, "", " ") 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | return out 43 | } 44 | -------------------------------------------------------------------------------- /examples/privatemessage.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | 10 | gzb "github.com/ifo/gozulipbot" 11 | ) 12 | 13 | func main() { 14 | bot := gzb.Bot{} 15 | err := bot.GetConfigFromFlags() 16 | if err != nil { 17 | log.Fatalln(err) 18 | } 19 | bot.Init() 20 | 21 | m := gzb.Message{ 22 | Emails: {"person@example.com"}, 23 | Content: "this message is private", 24 | } 25 | 26 | resp, err := bot.PrivateMessage(m) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | defer resp.Body.Close() 31 | body, err := ioutil.ReadAll(resp.Body) 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | var toPrint bytes.Buffer 36 | 37 | err = json.Indent(&toPrint, body, "", " ") 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | fmt.Println(toPrint.String()) 43 | } 44 | -------------------------------------------------------------------------------- /examples/publicmessage.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | 10 | gzb "github.com/ifo/gozulipbot" 11 | ) 12 | 13 | func main() { 14 | bot := gzb.Bot{} 15 | err := bot.GetConfigFromFlags() 16 | if err != nil { 17 | log.Fatalln(err) 18 | } 19 | bot.Init() 20 | 21 | m := gzb.Message{ 22 | Stream: "test-bot", 23 | Topic: "test-go-bot", 24 | Content: "this is a stream message", 25 | } 26 | 27 | resp, err := bot.Message(m) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | defer resp.Body.Close() 32 | body, err := ioutil.ReadAll(resp.Body) 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | var toPrint bytes.Buffer 37 | 38 | err = json.Indent(&toPrint, body, "", " ") 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | fmt.Println(toPrint.String()) 44 | } 45 | -------------------------------------------------------------------------------- /examples/subscribe.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | 10 | gzb "github.com/ifo/gozulipbot" 11 | ) 12 | 13 | func main() { 14 | bot := gzb.Bot{} 15 | err := bot.GetConfigFromFlags() 16 | if err != nil { 17 | log.Fatalln(err) 18 | } 19 | bot.Init() 20 | 21 | streams, err := bot.GetStreams() 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | 26 | // print the stream list 27 | for _, s := range streams { 28 | fmt.Println(s) 29 | } 30 | 31 | // subscribe 32 | subResp := subscribeToStreams(bot, streams) 33 | fmt.Println(subResp.String()) 34 | } 35 | 36 | func subscribeToStreams(bot gzb.Bot, streams []string) bytes.Buffer { 37 | resp, err := bot.Subscribe(streams) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | defer resp.Body.Close() 42 | body, err := ioutil.ReadAll(resp.Body) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | 47 | var out bytes.Buffer 48 | err = json.Indent(&out, body, "", " ") 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | 53 | return out 54 | } 55 | -------------------------------------------------------------------------------- /examples/atmessages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | 11 | gzb "github.com/ifo/gozulipbot" 12 | ) 13 | 14 | func main() { 15 | bot := gzb.Bot{} 16 | err := bot.GetConfigFromFlags() 17 | if err != nil { 18 | log.Fatalln(err) 19 | } 20 | bot.Init() 21 | 22 | q, err := bot.RegisterAt() 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | messages, err := q.GetEvents() 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | // Respond with "hi" to all @ messages 33 | for _, m := range messages { 34 | resp, err := bot.Respond(m, "hi") 35 | if err != nil { 36 | log.Println(err) 37 | } else { 38 | defer resp.Body.Close() 39 | printResponse(resp.Body) 40 | } 41 | } 42 | } 43 | 44 | func printResponse(r io.ReadCloser) { 45 | body, err := ioutil.ReadAll(r) 46 | if err != nil { 47 | log.Println(err) 48 | } 49 | var toPrint bytes.Buffer 50 | err = json.Indent(&toPrint, body, "", " ") 51 | if err != nil { 52 | log.Println(err) 53 | } 54 | fmt.Println(toPrint.String()) 55 | } 56 | -------------------------------------------------------------------------------- /flag.go: -------------------------------------------------------------------------------- 1 | package gozulipbot 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "time" 8 | ) 9 | 10 | func (b *Bot) GetConfigFromFlags() error { 11 | var ( 12 | apiKey = flag.String("apikey", "ZULIP_APIKEY", "bot api key or env var") 13 | apiURL = flag.String("apiurl", "ZULIP_APIURL", "url of zulip server or env var") 14 | backoff = flag.Duration("backoff", 1*time.Second, "backoff base duration or env var") 15 | email = flag.String("email", "ZULIP_EMAIL", "bot email address or env var") 16 | env = flag.Bool("env", false, "get string values from environment variables") 17 | ) 18 | flag.Parse() 19 | 20 | b.APIKey = *apiKey 21 | b.APIURL = *apiURL 22 | b.Email = *email 23 | b.Backoff = *backoff 24 | if *env { 25 | b.GetConfigFromEnvironment() 26 | } 27 | return b.checkConfig() 28 | } 29 | 30 | func (b *Bot) GetConfigFromEnvironment() error { 31 | if apiKey, exists := os.LookupEnv(b.APIKey); !exists { 32 | return fmt.Errorf("--env was set but env var %s did not exist", b.APIKey) 33 | } else { 34 | b.APIKey = apiKey 35 | } 36 | if apiURL, exists := os.LookupEnv(b.APIURL); !exists { 37 | return fmt.Errorf("--env was set but env var %s did not exist", b.APIURL) 38 | } else { 39 | b.APIURL = apiURL 40 | } 41 | if email, exists := os.LookupEnv(b.Email); !exists { 42 | return fmt.Errorf("--env was set but env var %s did not exist", b.Email) 43 | } else { 44 | b.Email = email 45 | } 46 | return nil 47 | } 48 | 49 | func (b *Bot) checkConfig() error { 50 | if b.APIKey == "" { 51 | return fmt.Errorf("--apikey is required") 52 | } 53 | if b.APIURL == "" { 54 | return fmt.Errorf("--apiurl is required") 55 | } 56 | if b.Email == "" { 57 | return fmt.Errorf("--email is required") 58 | } 59 | if b.Backoff <= 0 { 60 | return fmt.Errorf("--backoff must be greater than zero") 61 | } 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /message_test.go: -------------------------------------------------------------------------------- 1 | package gozulipbot 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "testing" 7 | ) 8 | 9 | func TestBot_Message(t *testing.T) { 10 | t.Skip() 11 | } 12 | 13 | func TestBot_PrivateMessage(t *testing.T) { 14 | bot := getTestBot() 15 | type C struct { 16 | M Message 17 | Body string 18 | E error 19 | } 20 | cases := map[string]C{ 21 | "1": C{M: Message{Stream: "a", Emails: []string{"a@example.com"}, Content: "hey"}, // normal 22 | Body: "content=hey&to=a%40example.com&type=private", E: nil}, 23 | "2": C{M: Message{Stream: "a", Topic: "a", Emails: []string{"a@example.com"}, Content: "hey"}, // topic is ignored 24 | Body: "content=hey&to=a%40example.com&type=private", E: nil}, 25 | "3": C{M: Message{Stream: "a", Topic: "a", Emails: []string{"a@example.com", "b@example.com"}, Content: "hey"}, // multiple emails are fine 26 | Body: "content=hey&to=a%40example.com%2Cb%40example.com&type=private", E: nil}, 27 | "4": C{M: Message{Stream: "a", Content: "hey"}, // no email set 28 | Body: "", E: fmt.Errorf("there must be at least one recipient")}, 29 | } 30 | 31 | for num, c := range cases { 32 | // ignore response from testClient 33 | _, err := bot.PrivateMessage(c.M) 34 | 35 | // Check if error matches the error specified in the case 36 | switch c.E { 37 | case nil: 38 | if err != nil && c.E == nil { 39 | t.Fatalf("got %q, expected nil, case %q", err, num) 40 | } 41 | 42 | default: 43 | if err == nil { 44 | t.Fatalf("got nil, expected %q, case %q", c.E, num) 45 | } 46 | 47 | if err.Error() != c.E.Error() { 48 | t.Fatalf("got %q, expected %q, case %q", err, c.E, num) 49 | } 50 | 51 | // No request was created so the test has been completed and 52 | // we won't check the request body, as there is none. 53 | return 54 | } 55 | 56 | // Check the request body matches our expectation 57 | body, _ := ioutil.ReadAll(bot.Client.(*testClient).Request.Body) 58 | if string(body) != c.Body { 59 | t.Errorf("got %q, expected %q, case %q", string(body), c.Body, num) 60 | } 61 | } 62 | } 63 | 64 | func TestBot_Respond(t *testing.T) { 65 | t.Skip() 66 | } 67 | 68 | func TestBot_privateResponseList(t *testing.T) { 69 | t.Skip() 70 | } 71 | 72 | func TestBot_constructMessageRequest(t *testing.T) { 73 | t.Skip() 74 | } 75 | -------------------------------------------------------------------------------- /bot_test.go: -------------------------------------------------------------------------------- 1 | package gozulipbot 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func TestBot_Init(t *testing.T) { 10 | bot := Bot{} 11 | bot.Init() 12 | 13 | if bot.Client == nil { 14 | t.Error("expected bot to have client") 15 | } 16 | } 17 | 18 | func TestBot_GetStreamList(t *testing.T) { 19 | bot := getTestBot() 20 | type Case struct { 21 | URL string 22 | Error error 23 | } 24 | 25 | cases := map[string]Case{ 26 | "1": Case{URL: "https://api.example.com/streams", Error: nil}, 27 | } 28 | 29 | for num, c := range cases { 30 | _, err := bot.GetStreamList() 31 | 32 | if err != c.Error { 33 | t.Fatalf("got %q, expected nil, case %q", err, num) 34 | } 35 | 36 | req := bot.Client.(*testClient).Request 37 | if req.URL.String() != c.URL { 38 | t.Errorf("got %q, expected %q, case %q", req.URL.String(), c.URL, num) 39 | } 40 | 41 | body, _ := ioutil.ReadAll(req.Body) 42 | if string(body) != "" { 43 | t.Errorf(`got %q, expected "", case %q`, string(body), num) 44 | } 45 | } 46 | } 47 | 48 | func TestBot_GetStreams(t *testing.T) { 49 | t.Skip() 50 | } 51 | 52 | func TestBot_Subscribe(t *testing.T) { 53 | t.Skip() 54 | } 55 | 56 | func TestBot_Unsubscribe(t *testing.T) { 57 | t.Skip() 58 | } 59 | 60 | func TestBot_ListSubscriptions(t *testing.T) { 61 | t.Skip() 62 | } 63 | 64 | func TestBot_RegisterEvents(t *testing.T) { 65 | t.Skip() 66 | } 67 | 68 | func TestBot_RegisterAll(t *testing.T) { 69 | t.Skip() 70 | } 71 | 72 | func TestBot_RegisterAt(t *testing.T) { 73 | t.Skip() 74 | } 75 | 76 | func TestBot_RegisterPrivate(t *testing.T) { 77 | t.Skip() 78 | } 79 | 80 | func TestBot_RegisterSubscriptions(t *testing.T) { 81 | t.Skip() 82 | } 83 | 84 | func TestBot_RawRegisterEvents(t *testing.T) { 85 | t.Skip() 86 | } 87 | 88 | // ensure constructRequest adds a JSON header and uses basic auth 89 | func TestBot_constructRequest(t *testing.T) { 90 | bot := getTestBot() 91 | type Case struct { 92 | Method string 93 | Endpoint string 94 | Body string 95 | ReqBody string 96 | Error error 97 | } 98 | 99 | JSONHeader := "application/x-www-form-urlencoded" 100 | 101 | cases := map[string]Case{ 102 | "1": Case{"GET", "endpoint", "", "", nil}, 103 | } 104 | 105 | for num, c := range cases { 106 | req, err := bot.constructRequest(c.Method, c.Endpoint, c.Body) 107 | if err != nil { 108 | t.Fatalf("got %q, expected nil, case %q", err, num) 109 | } 110 | 111 | header := req.Header.Get("Content-Type") 112 | if string(header) != JSONHeader { 113 | t.Errorf("got %q, expected %q, case %q", header, JSONHeader, num) 114 | } 115 | 116 | email, key, ok := req.BasicAuth() 117 | if !ok || email != bot.Email || key != bot.APIKey { 118 | t.Errorf("got %t, expected true, case %q", ok, num) 119 | t.Errorf("got %q, expected %q, case %q", email, bot.Email, num) 120 | t.Errorf("got %q, expected %q, case %q", key, bot.APIKey, num) 121 | } 122 | } 123 | } 124 | 125 | func getTestBot() *Bot { 126 | return &Bot{ 127 | Email: "testbot@example.com", 128 | APIKey: "apikey", 129 | APIURL: "https://api.example.com/", 130 | Streams: []string{"stream a", "test bots"}, 131 | Client: &testClient{}, 132 | } 133 | } 134 | 135 | type testClient struct { 136 | Request *http.Request 137 | Response *http.Response 138 | } 139 | 140 | func (t *testClient) Do(r *http.Request) (*http.Response, error) { 141 | t.Request = r 142 | return t.Response, nil 143 | } 144 | 145 | func isIn(needle string, haystack []string) bool { 146 | for _, straw := range haystack { 147 | if needle == straw { 148 | return true 149 | } 150 | } 151 | return false 152 | } 153 | -------------------------------------------------------------------------------- /bot.go: -------------------------------------------------------------------------------- 1 | package gozulipbot 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type Bot struct { 13 | APIKey string 14 | APIURL string 15 | Email string 16 | Queues []*Queue 17 | Streams []string 18 | Client Doer 19 | Backoff time.Duration 20 | Retries int64 21 | } 22 | 23 | type Doer interface { 24 | Do(*http.Request) (*http.Response, error) 25 | } 26 | 27 | // Init adds an http client to an existing bot struct. 28 | func (b *Bot) Init() *Bot { 29 | b.Client = &http.Client{} 30 | return b 31 | } 32 | 33 | // GetStreamList gets the raw http response when requesting all public streams. 34 | func (b *Bot) GetStreamList() (*http.Response, error) { 35 | req, err := b.constructRequest("GET", "streams", "") 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | return b.Client.Do(req) 41 | } 42 | 43 | type StreamJSON struct { 44 | Msg string `json:"msg"` 45 | Streams []struct { 46 | StreamID int `json:"stream_id"` 47 | InviteOnly bool `json:"invite_only"` 48 | Description string `json:"description"` 49 | Name string `json:"name"` 50 | } `json:"streams"` 51 | Result string `json:"result"` 52 | } 53 | 54 | // GetStreams returns a list of all public streams 55 | func (b *Bot) GetStreams() ([]string, error) { 56 | resp, err := b.GetStreamList() 57 | if err != nil { 58 | return nil, err 59 | } 60 | defer resp.Body.Close() 61 | body, err := ioutil.ReadAll(resp.Body) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | var sj StreamJSON 67 | err = json.Unmarshal(body, &sj) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | var streams []string 73 | for _, s := range sj.Streams { 74 | streams = append(streams, s.Name) 75 | } 76 | 77 | return streams, nil 78 | } 79 | 80 | // Subscribe will set the bot to receive messages from the given streams. 81 | // If no streams are given, it will subscribe the bot to the streams in the bot struct. 82 | func (b *Bot) Subscribe(streams []string) (*http.Response, error) { 83 | if streams == nil { 84 | streams = b.Streams 85 | } 86 | 87 | var toSubStreams []map[string]string 88 | for _, name := range streams { 89 | toSubStreams = append(toSubStreams, map[string]string{"name": name}) 90 | } 91 | 92 | bodyBts, err := json.Marshal(toSubStreams) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | body := "subscriptions=" + string(bodyBts) 98 | 99 | req, err := b.constructRequest("POST", "users/me/subscriptions", body) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | return b.Client.Do(req) 105 | } 106 | 107 | // Unsubscribe will remove the bot from the given streams. 108 | // If no streams are given, nothing will happen and the function will error. 109 | func (b *Bot) Unsubscribe(streams []string) (*http.Response, error) { 110 | if len(streams) == 0 { 111 | return nil, fmt.Errorf("No streams were provided") 112 | } 113 | 114 | body := `delete=["` + strings.Join(streams, `","`) + `"]` 115 | 116 | req, err := b.constructRequest("PATCH", "users/me/subscriptions", body) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | return b.Client.Do(req) 122 | } 123 | 124 | func (b *Bot) ListSubscriptions() (*http.Response, error) { 125 | req, err := b.constructRequest("GET", "users/me/subscriptions", "") 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | return b.Client.Do(req) 131 | } 132 | 133 | type EventType string 134 | 135 | const ( 136 | Messages EventType = "messages" 137 | Subscriptions EventType = "subscriptions" 138 | RealmUser EventType = "realm_user" 139 | Pointer EventType = "pointer" 140 | ) 141 | 142 | type Narrow string 143 | 144 | const ( 145 | NarrowPrivate Narrow = `[["is", "private"]]` 146 | NarrowAt Narrow = `[["is", "mentioned"]]` 147 | ) 148 | 149 | // RegisterEvents adds a queue to the bot. It includes the EventTypes and 150 | // Narrow given. If neither is given, it will default to all Messages. 151 | func (b *Bot) RegisterEvents(ets []EventType, n Narrow) (*Queue, error) { 152 | resp, err := b.RawRegisterEvents(ets, n) 153 | if err != nil { 154 | return nil, err 155 | } 156 | defer resp.Body.Close() 157 | 158 | body, err := ioutil.ReadAll(resp.Body) 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | q := &Queue{Bot: b} 164 | err = json.Unmarshal(body, q) 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | b.Queues = append(b.Queues, q) 170 | 171 | return q, nil 172 | } 173 | 174 | func (b *Bot) RegisterAll() (*Queue, error) { 175 | return b.RegisterEvents(nil, "") 176 | } 177 | 178 | func (b *Bot) RegisterAt() (*Queue, error) { 179 | return b.RegisterEvents(nil, NarrowAt) 180 | } 181 | 182 | func (b *Bot) RegisterPrivate() (*Queue, error) { 183 | return b.RegisterEvents(nil, NarrowPrivate) 184 | } 185 | 186 | func (b *Bot) RegisterSubscriptions() (*Queue, error) { 187 | events := []EventType{Subscriptions} 188 | return b.RegisterEvents(events, "") 189 | } 190 | 191 | // RawRegisterEvents tells Zulip to include message events in the bots events queue. 192 | // Passing nil as the slice of EventType will default to receiving Messages 193 | func (b *Bot) RawRegisterEvents(ets []EventType, n Narrow) (*http.Response, error) { 194 | // default to Messages if no EventTypes given 195 | query := `event_types=["message"]` 196 | 197 | if len(ets) != 0 { 198 | query = `event_types=["` 199 | for i, s := range ets { 200 | query += fmt.Sprintf("%s", s) 201 | if i != len(ets)-1 { 202 | query += `", "` 203 | } 204 | } 205 | query += `"]` 206 | } 207 | 208 | if n != "" { 209 | query += fmt.Sprintf("&narrow=%s", n) 210 | } 211 | 212 | req, err := b.constructRequest("POST", "register", query) 213 | if err != nil { 214 | return nil, err 215 | } 216 | 217 | return b.Client.Do(req) 218 | } 219 | 220 | // constructRequest makes a zulip request and ensures the proper headers are set. 221 | func (b *Bot) constructRequest(method, endpoint, body string) (*http.Request, error) { 222 | url := b.APIURL + endpoint 223 | req, err := http.NewRequest(method, url, strings.NewReader(body)) 224 | if err != nil { 225 | return nil, err 226 | } 227 | 228 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 229 | req.SetBasicAuth(b.Email, b.APIKey) 230 | 231 | return req, nil 232 | } 233 | -------------------------------------------------------------------------------- /queue.go: -------------------------------------------------------------------------------- 1 | package gozulipbot 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "math" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | "sync/atomic" 12 | "time" 13 | ) 14 | 15 | var ( 16 | HeartbeatError = fmt.Errorf("EventMessage is a heartbeat") 17 | UnauthorizedError = fmt.Errorf("Request is unauthorized") 18 | BackoffError = fmt.Errorf("Too many requests") 19 | UnknownError = fmt.Errorf("Error was unknown") 20 | ) 21 | 22 | type Queue struct { 23 | ID string `json:"queue_id"` 24 | LastEventID int `json:"last_event_id"` 25 | MaxMessageID int `json:"max_message_id"` 26 | Bot *Bot `json:"-"` 27 | } 28 | 29 | func (q *Queue) EventsChan() (chan EventMessage, func()) { 30 | end := false 31 | endFunc := func() { 32 | end = true 33 | } 34 | 35 | out := make(chan EventMessage) 36 | go func() { 37 | defer close(out) 38 | for { 39 | backoffTime := time.Now().Add(q.Bot.Backoff * time.Duration(math.Pow10(int(atomic.LoadInt64(&q.Bot.Retries))))) 40 | minTime := time.Now().Add(q.Bot.Backoff) 41 | if end { 42 | return 43 | } 44 | ems, err := q.GetEvents() 45 | switch { 46 | case err == HeartbeatError: 47 | time.Sleep(time.Until(minTime)) 48 | continue 49 | case err == BackoffError: 50 | time.Sleep(time.Until(backoffTime)) 51 | atomic.AddInt64(&q.Bot.Retries, 1) 52 | continue 53 | case err == UnauthorizedError: 54 | // TODO? have error channel when ending the continuously running process? 55 | return 56 | default: 57 | atomic.StoreInt64(&q.Bot.Retries, 0) 58 | } 59 | if err != nil { 60 | // TODO: handle unknown error 61 | // For now, handle this like an UnauthorizedError and end the func. 62 | return 63 | } 64 | for _, em := range ems { 65 | out <- em 66 | } 67 | // Always make sure we wait the minimum time before asking again. 68 | time.Sleep(time.Until(minTime)) 69 | } 70 | }() 71 | 72 | return out, endFunc 73 | } 74 | 75 | // EventsCallback will repeatedly call the provided callback function with 76 | // the output of continual queue.GetEvents calls. 77 | // It returns a function which can be called to end the calls. 78 | // 79 | // It will end early if it receives an UnauthorizedError, or an unknown error. 80 | // Note, it will never return a HeartbeatError. 81 | func (q *Queue) EventsCallback(fn func(EventMessage, error)) func() { 82 | end := false 83 | endFunc := func() { 84 | end = true 85 | } 86 | go func() { 87 | for { 88 | backoffTime := time.Now().Add(q.Bot.Backoff * time.Duration(math.Pow10(int(atomic.LoadInt64(&q.Bot.Retries))))) 89 | minTime := time.Now().Add(q.Bot.Backoff) 90 | if end { 91 | return 92 | } 93 | ems, err := q.GetEvents() 94 | switch { 95 | case err == HeartbeatError: 96 | time.Sleep(time.Until(minTime)) 97 | continue 98 | case err == BackoffError: 99 | time.Sleep(time.Until(backoffTime)) 100 | atomic.AddInt64(&q.Bot.Retries, 1) 101 | continue 102 | case err == UnauthorizedError: 103 | // TODO? have error channel when ending the continuously running process? 104 | return 105 | default: 106 | atomic.StoreInt64(&q.Bot.Retries, 0) 107 | } 108 | if err != nil { 109 | // TODO: handle unknown error 110 | // For now, handle this like an UnauthorizedError and end the func. 111 | return 112 | } 113 | for _, em := range ems { 114 | fn(em, err) 115 | } 116 | // Always make sure we wait the minimum time before asking again. 117 | time.Sleep(time.Until(minTime)) 118 | } 119 | }() 120 | 121 | return endFunc 122 | } 123 | 124 | // GetEvents is a blocking call that waits for and parses a list of EventMessages. 125 | // There will usually only be one EventMessage returned. 126 | // When a heartbeat is returned, GetEvents will return a HeartbeatError. 127 | // When an http status code above 400 is returned, one of a BackoffError, 128 | // UnauthorizedError, or UnknownError will be returned. 129 | func (q *Queue) GetEvents() ([]EventMessage, error) { 130 | resp, err := q.RawGetEvents() 131 | if err != nil { 132 | return nil, err 133 | } 134 | defer resp.Body.Close() 135 | 136 | switch { 137 | case resp.StatusCode == 429: 138 | return nil, BackoffError 139 | case resp.StatusCode == 403: 140 | return nil, UnauthorizedError 141 | case resp.StatusCode >= 400: 142 | return nil, UnknownError 143 | } 144 | 145 | body, err := ioutil.ReadAll(resp.Body) 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | msgs, err := q.ParseEventMessages(body) 151 | if err != nil { 152 | return nil, err 153 | } 154 | 155 | return msgs, nil 156 | } 157 | 158 | // RawGetEvents is a blocking call that receives a response containing a list 159 | // of events (a.k.a. received messages) since the last message id in the queue. 160 | func (q *Queue) RawGetEvents() (*http.Response, error) { 161 | values := url.Values{} 162 | values.Set("queue_id", q.ID) 163 | values.Set("last_event_id", strconv.Itoa(q.LastEventID)) 164 | 165 | url := "events?" + values.Encode() 166 | 167 | req, err := q.Bot.constructRequest("GET", url, "") 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | return q.Bot.Client.Do(req) 173 | } 174 | 175 | func (q *Queue) ParseEventMessages(rawEventResponse []byte) ([]EventMessage, error) { 176 | rawResponse := map[string]json.RawMessage{} 177 | err := json.Unmarshal(rawEventResponse, &rawResponse) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | events := []map[string]json.RawMessage{} 183 | err = json.Unmarshal(rawResponse["events"], &events) 184 | if err != nil { 185 | return nil, err 186 | } 187 | 188 | messages := []EventMessage{} 189 | newLastEventID := 0 190 | for _, event := range events { 191 | // Update the lastEventID 192 | var id int 193 | json.Unmarshal(event["id"], &id) 194 | if id > newLastEventID { 195 | newLastEventID = id 196 | } 197 | 198 | // If the event is a heartbeat, there won't be any more events. 199 | // So update the last event id and return a special error. 200 | if string(event["type"]) == `"heartbeat"` { 201 | q.LastEventID = newLastEventID 202 | return nil, HeartbeatError 203 | } 204 | var msg EventMessage 205 | err = json.Unmarshal(event["message"], &msg) 206 | // TODO? should this check be here 207 | if err != nil { 208 | return nil, err 209 | } 210 | msg.Queue = q 211 | messages = append(messages, msg) 212 | } 213 | 214 | q.LastEventID = newLastEventID 215 | 216 | return messages, nil 217 | } 218 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package gozulipbot 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | // A Message is all of the necessary metadata to post on Zulip. 12 | // It can be either a public message, where Topic is set, or a private message, 13 | // where there is at least one element in Emails. 14 | // 15 | // If the length of Emails is not 0, functions will always assume it is a private message. 16 | type Message struct { 17 | Stream string 18 | Topic string 19 | Emails []string 20 | Content string 21 | } 22 | 23 | type EventMessage struct { 24 | AvatarURL string `json:"avatar_url"` 25 | Client string `json:"client"` 26 | Content string `json:"content"` 27 | ContentType string `json:"content_type"` 28 | DisplayRecipient DisplayRecipient `json:"display_recipient"` 29 | GravatarHash string `json:"gravatar_hash"` 30 | ID int `json:"id"` 31 | RecipientID int `json:"recipient_id"` 32 | SenderDomain string `json:"sender_domain"` 33 | SenderEmail string `json:"sender_email"` 34 | SenderFullName string `json:"sender_full_name"` 35 | SenderID int `json:"sender_id"` 36 | SenderShortName string `json:"sender_short_name"` 37 | Subject string `json:"subject"` 38 | SubjectLinks []interface{} `json:"subject_links"` 39 | Timestamp int `json:"timestamp"` 40 | Type string `json:"type"` 41 | Queue *Queue `json:"-"` 42 | } 43 | 44 | type DisplayRecipient struct { 45 | Users []User `json:"users,omitempty"` 46 | Topic string `json:"topic,omitempty"` 47 | } 48 | 49 | type User struct { 50 | Domain string `json:"domain"` 51 | Email string `json:"email"` 52 | FullName string `json:"full_name"` 53 | ID int `json:"id"` 54 | IsMirrorDummy bool `json:"is_mirror_dummy"` 55 | ShortName string `json:"short_name"` 56 | } 57 | 58 | func (d *DisplayRecipient) UnmarshalJSON(b []byte) (err error) { 59 | topic, users := "", make([]User, 1) 60 | if err = json.Unmarshal(b, &topic); err == nil { 61 | d.Topic = topic 62 | return 63 | } 64 | if err = json.Unmarshal(b, &users); err == nil { 65 | d.Users = users 66 | return 67 | } 68 | return 69 | } 70 | 71 | // Message posts a message to Zulip. If any emails have been set on the message, 72 | // the message will be re-routed to the PrivateMessage function. 73 | func (b *Bot) Message(m Message) (*http.Response, error) { 74 | if m.Content == "" { 75 | return nil, fmt.Errorf("content cannot be empty") 76 | } 77 | 78 | // if any emails are set, this is a private message 79 | if len(m.Emails) != 0 { 80 | return b.PrivateMessage(m) 81 | } 82 | 83 | // otherwise it's a stream message 84 | if m.Stream == "" { 85 | return nil, fmt.Errorf("stream cannot be empty") 86 | } 87 | if m.Topic == "" { 88 | return nil, fmt.Errorf("topic cannot be empty") 89 | } 90 | req, err := b.constructMessageRequest(m) 91 | if err != nil { 92 | return nil, err 93 | } 94 | return b.Client.Do(req) 95 | } 96 | 97 | // PrivateMessage sends a message to the users in the message email slice. 98 | func (b *Bot) PrivateMessage(m Message) (*http.Response, error) { 99 | if len(m.Emails) == 0 { 100 | return nil, fmt.Errorf("there must be at least one recipient") 101 | } 102 | req, err := b.constructMessageRequest(m) 103 | if err != nil { 104 | return nil, err 105 | } 106 | return b.Client.Do(req) 107 | } 108 | 109 | // Respond sends a given message as a response to whatever context from which 110 | // an EventMessage was received. 111 | func (b *Bot) Respond(e EventMessage, response string) (*http.Response, error) { 112 | if response == "" { 113 | return nil, fmt.Errorf("Message response cannot be blank") 114 | } 115 | m := Message{ 116 | Stream: e.DisplayRecipient.Topic, 117 | Topic: e.Subject, 118 | Content: response, 119 | } 120 | if m.Topic != "" { 121 | return b.Message(m) 122 | } 123 | // private message 124 | if m.Stream == "" { 125 | emails, err := b.privateResponseList(e) 126 | if err != nil { 127 | return nil, err 128 | } 129 | m.Emails = emails 130 | return b.Message(m) 131 | } 132 | return nil, fmt.Errorf("EventMessage is not understood: %v\n", e) 133 | } 134 | 135 | // privateResponseList gets the list of other users in a private multiple 136 | // message conversation. 137 | func (b *Bot) privateResponseList(e EventMessage) ([]string, error) { 138 | var out []string 139 | for _, u := range e.DisplayRecipient.Users { 140 | if u.Email != b.Email { 141 | out = append(out, u.Email) 142 | } 143 | } 144 | if len(out) == 0 { 145 | return nil, fmt.Errorf("EventMessage had no Users within the DisplayRecipient") 146 | } 147 | return out, nil 148 | } 149 | 150 | // constructMessageRequest is a helper for simplifying sending a message. 151 | func (b *Bot) constructMessageRequest(m Message) (*http.Request, error) { 152 | to := m.Stream 153 | mtype := "stream" 154 | 155 | le := len(m.Emails) 156 | if le != 0 { 157 | mtype = "private" 158 | } 159 | if le == 1 { 160 | to = m.Emails[0] 161 | } 162 | if le > 1 { 163 | to = "" 164 | for i, e := range m.Emails { 165 | to += e 166 | if i != le-1 { 167 | to += "," 168 | } 169 | } 170 | } 171 | 172 | values := url.Values{} 173 | values.Set("type", mtype) 174 | values.Set("to", to) 175 | values.Set("content", m.Content) 176 | if mtype == "stream" { 177 | values.Set("subject", m.Topic) 178 | } 179 | 180 | return b.constructRequest("POST", "messages", values.Encode()) 181 | } 182 | 183 | // React adds an emoji reaction to an EventMessage. 184 | func (b *Bot) React(e EventMessage, emoji string) (*http.Response, error) { 185 | requestURL := fmt.Sprintf("messages/%d/reactions", e.ID) 186 | values := url.Values{} 187 | values.Set("emoji_name", emoji) 188 | req, err := b.constructRequest("POST", requestURL, values.Encode()) 189 | if err != nil { 190 | return nil, err 191 | } 192 | return b.Client.Do(req) 193 | } 194 | 195 | // Unreact removes an emoji reaction from an EventMessage. 196 | func (b *Bot) Unreact(e EventMessage, emoji string) (*http.Response, error) { 197 | requestURL := fmt.Sprintf("messages/%d/reactions", e.ID) 198 | values := url.Values{} 199 | values.Set("emoji_name", emoji) 200 | req, err := b.constructRequest("DELETE", requestURL, values.Encode()) 201 | if err != nil { 202 | return nil, err 203 | } 204 | return b.Client.Do(req) 205 | } 206 | 207 | type Emoji struct { 208 | Author string `json:"author"` 209 | DisplayURL string `json:"display_url"` 210 | SourceURL string `json:"source_url"` 211 | } 212 | 213 | type EmojiResponse struct { 214 | Emoji map[string]*Emoji `json:"emoji"` 215 | Msg string `json:"msg"` 216 | Result string `json:"result"` 217 | } 218 | 219 | // RealmEmoji gets the custom emoji information for the Zulip instance. 220 | func (b *Bot) RealmEmoji() (map[string]*Emoji, error) { 221 | req, err := b.constructRequest("GET", "realm/emoji", "") 222 | if err != nil { 223 | return nil, err 224 | } 225 | resp, err := b.Client.Do(req) 226 | if err != nil { 227 | return nil, err 228 | } 229 | defer resp.Body.Close() 230 | 231 | body, err := ioutil.ReadAll(resp.Body) 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | var emjResp EmojiResponse 237 | err = json.Unmarshal(body, &emjResp) 238 | if err != nil { 239 | return nil, err 240 | } 241 | return emjResp.Emoji, nil 242 | } 243 | 244 | // RealmEmojiSet makes a set of the names of the custom emoji in the Zulip instance. 245 | func (b *Bot) RealmEmojiSet() (map[string]struct{}, error) { 246 | emj, err := b.RealmEmoji() 247 | if err != nil { 248 | return nil, nil 249 | } 250 | out := map[string]struct{}{} 251 | for k, _ := range emj { 252 | out[k] = struct{}{} 253 | } 254 | return out, nil 255 | } 256 | --------------------------------------------------------------------------------