Skip to content

Commit

Permalink
feat(services): add zulip chat service
Browse files Browse the repository at this point in the history
  • Loading branch information
arnested committed Jun 1, 2020
1 parent 865e6df commit 7c2be31
Show file tree
Hide file tree
Showing 8 changed files with 403 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/services/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ Click on the service for a more thorough explanation.
| [IFTTT](/shoutrrr/services/not-documented) | *ifttt://__`key`__/?events=__`event1`__[,__`event2`__,...]&value1=__`value1`__&value2=__`value2`__&value3=__`value3`__* |
| [Mattermost](/shoutrrr/services/not-documented) | *mattermost://__`mattermost-host`__/__`token`__[/__`username`__/__`channel`__]* |
| [Hangouts Chat](/shoutrrr/services/hangouts) | *hangouts://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz* |
| [Zulip Chat](/shoutrrr/services/zulip) | *zulip://__`bot-mail`__:__`bot-key`__@__`zulip-domain`__/?stream=__`name-or-id`__&topic=__`name`__* |
23 changes: 23 additions & 0 deletions docs/services/zulip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Zulip Chat

## URL format

The shoutrrr service URL should look like this:
> zulip://__`bot-mail`__:__`bot-key`__@__`zulip-domain`__/?stream=__`name-or-id`__&topic=__`name`__
Stream and topic are both optional and can be given as parameters to the Send method:

```go
sender, __ := shoutrrr.CreateSender(url)

params := make(types.Params)
params["stream"] = "mystream"
params["topic"] = "This is my topic"

sender.Send(message, &params)
```

Since __`bot-mail`__ is a mail address you need to URL escape the `@` in it to `%40`.

An example service URL would look like:
> zulip://my-bot%40zulipchat.com:correcthorsebatterystable@example.zulipchat.com?stream=foo&topic=bar
2 changes: 2 additions & 0 deletions pkg/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/containrrr/shoutrrr/pkg/services/smtp"
"github.com/containrrr/shoutrrr/pkg/services/teams"
"github.com/containrrr/shoutrrr/pkg/services/telegram"
"github.com/containrrr/shoutrrr/pkg/services/zulip"
t "github.com/containrrr/shoutrrr/pkg/types"
"github.com/containrrr/shoutrrr/pkg/xmpp"
)
Expand Down Expand Up @@ -140,6 +141,7 @@ var serviceMap = map[string]func() t.Service{
"pushbullet": func() t.Service { return &pushbullet.Service{} },
"mattermost": func() t.Service { return &mattermost.Service{} },
"hangouts": func() t.Service { return &hangouts.Service{} },
"zulip": func() t.Service { return &zulip.Service{} },
}

func (router *ServiceRouter) initService(rawURL string) (t.Service, error) {
Expand Down
90 changes: 90 additions & 0 deletions pkg/services/zulip/zulip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package zulip

import (
"fmt"
"log"
"net/http"
"net/url"
"strings"

"github.com/containrrr/shoutrrr/pkg/services/standard"
"github.com/containrrr/shoutrrr/pkg/types"
)

// Service sends notifications to a pre-configured channel or user
type Service struct {
standard.Standard
config *Config
}

const (
contentMaxSize = 10000 // bytes
topicMaxLength = 60 // characters
)

// Send a notification message to Zulip
func (service *Service) Send(message string, params *types.Params) error {
// Clone the config because we might modify stream and/or
// topic with values from the parameters and they should only
// change this Send().
config := service.config.Clone()

if params != nil {
if stream, found := (*params)["stream"]; found {
config.Stream = stream
}

if topic, found := (*params)["topic"]; found {
config.Topic = topic
}
}

topicLength := len([]rune(config.Topic))

if topicLength > topicMaxLength {
return fmt.Errorf(string(TopicTooLong), topicMaxLength, topicLength)
}

messageSize := len(message)

if messageSize > contentMaxSize {
return fmt.Errorf("message exceeds max size (%d bytes): was %d bytes", contentMaxSize, messageSize)
}

return service.doSend(config, message)
}

// Initialize loads ServiceConfig from configURL and sets logger for this Service
func (service *Service) Initialize(configURL *url.URL, logger *log.Logger) error {
service.Logger.SetLogger(logger)
service.config = &Config{}

if err := service.config.SetURL(configURL); err != nil {
return err
}

return nil
}

func (service *Service) doSend(config *Config, message string) error {
apiURL := service.getURL(config)
payload := CreatePayload(config, message)
res, err := http.Post(apiURL, "application/x-www-form-urlencoded", strings.NewReader(payload.Encode()))

if res.StatusCode != http.StatusOK {
return fmt.Errorf("failed to send notification to service, response status code %s", res.Status)
}

return err
}

func (service *Service) getURL(config *Config) string {
url := url.URL{
User: url.UserPassword(config.BotMail, config.BotKey),
Host: config.Host,
Path: config.Path,
Scheme: "https",
}

return url.String()
}
91 changes: 91 additions & 0 deletions pkg/services/zulip/zulip_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package zulip

import (
"errors"
"net/url"
)

// Config for the zulip service
type Config struct {
BotMail string
BotKey string
Host string
Path string
Stream string
Topic string
}

// GetURL returns a URL representation of it's current field values
func (config *Config) GetURL() *url.URL {
query := &url.Values{}

if config.Stream != "" {
query.Set("stream", config.Stream)
}

if config.Topic != "" {
query.Set("topic", config.Topic)
}

return &url.URL{
User: url.UserPassword(config.BotMail, config.BotKey),
Host: config.Host,
Path: config.Path,
RawQuery: query.Encode(),
Scheme: Scheme,
}
}

// SetURL updates a ServiceConfig from a URL representation of it's field values
func (config *Config) SetURL(serviceURL *url.URL) error {
var ok bool

config.BotMail = serviceURL.User.Username()

if config.BotMail == "" {
return errors.New(string(MissingBotMail))
}

config.BotKey, ok = serviceURL.User.Password()

if !ok {
return errors.New(string(MissingAPIKey))
}

config.Host = serviceURL.Hostname()

if config.Host == "" {
return errors.New(string(MissingHost))
}

config.Path = "api/v1/messages"
config.Stream = serviceURL.Query().Get("stream")
config.Topic = serviceURL.Query().Get("topic")

return nil
}

// Clone the config to a new Config struct
func (config *Config) Clone() *Config {
return &Config{
BotMail: config.BotMail,
BotKey: config.BotKey,
Host: config.Host,
Path: config.Path,
Stream: config.Stream,
Topic: config.Topic,
}
}

const (
// Scheme is the identifying part of this service's configuration URL
Scheme = "zulip"
)

// CreateConfigFromURL to use within the zulip service
func CreateConfigFromURL(serviceURL *url.URL) (*Config, error) {
config := Config{}
err := config.SetURL(serviceURL)

return &config, err
}
15 changes: 15 additions & 0 deletions pkg/services/zulip/zulip_errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package zulip

// ErrorMessage for error events within the zulip service
type ErrorMessage string

const (
// MissingAPIKey from the service URL
MissingAPIKey ErrorMessage = "missing API key"
// MissingHost from the service URL
MissingHost ErrorMessage = "missing Zulip host"
// MissingBotMail from the service URL
MissingBotMail ErrorMessage = "missing Bot mail address"
// TopicTooLong if topic is more than 60 characters
TopicTooLong ErrorMessage = "topic exceeds max length (%d characters): was %d characters"
)
19 changes: 19 additions & 0 deletions pkg/services/zulip/zulip_payload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package zulip

import (
"net/url"
)

// CreatePayload compatible with the zulip api
func CreatePayload(config *Config, message string) url.Values {
form := url.Values{}
form.Set("type", "stream")
form.Set("to", config.Stream)
form.Set("content", message)

if config.Topic != "" {
form.Set("topic", config.Topic)
}

return form
}
Loading

0 comments on commit 7c2be31

Please sign in to comment.