diff --git a/README.md b/README.md
index 1b66c4a4..1c61d21d 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,7 @@ Yes, please! Contributions of all kinds are very welcome! Feel free to check our
| [WeChat](https://www.wechat.com) | [service/wechat](service/wechat) | [silenceper/wechat](https://github.com/silenceper/wechat) | :heavy_check_mark: |
| [Webpush Notification](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) | [service/webpush](service/webpush) | [SherClockHolmes/webpush-go](https://github.com/SherClockHolmes/webpush-go/) | :heavy_check_mark: |
| [WhatsApp](https://www.whatsapp.com) | [service/whatsapp](service/whatsapp) | [Rhymen/go-whatsapp](https://github.com/Rhymen/go-whatsapp) | :x: |
+| [Zulip](https://zulip.com/) | [service/zulip](service/zulip) | - | :heavy_check_mark: |
## Special Thanks
diff --git a/service/zulip/README.md b/service/zulip/README.md
new file mode 100644
index 00000000..328a6ae2
--- /dev/null
+++ b/service/zulip/README.md
@@ -0,0 +1,43 @@
+# Zulip
+
+## Steps for creating a Zulip Bot
+
+Follow the below instructions to create a bot email and api key required for the service:
+
+1. Create a Zulip Organization
+2. Go to settings and create a new bot. Copy the bot email and api key.
+3. Copy your Oranization URL. Copy the entire url `https://your-domain.zulipchat.com`
+4. Copy the stream name of the stream and its topic if you want to post a message to stream or just copy an email address of the receiver.
+
+## Sample Code
+
+```go
+ package main
+
+ import (
+ "context"
+ "log"
+
+ "github.com/nikoksr/notify"
+ "github.com/nikoksr/notify/service/zulip"
+ )
+
+ func main() {
+ zulipSvc, err := zulip.New("server-base-url", "bot-email", "api-key")
+ if err != nil {
+ log.Fatalf("zulip.New() failed: %s", err.Error())
+ }
+
+ zulipSvc.AddReceivers(Direct("test2@gmail.com"), Stream("stream", "topic"))
+
+ notifier := notify.New()
+ notifier.UseServices(zulipSvc)
+
+ err = notifier.Send(context.Background(), "subject", "message")
+ if err != nil {
+ log.Fatalf("notifier.Send() failed: %s", err.Error())
+ }
+
+ log.Println("notification sent")
+ }
+```
diff --git a/service/zulip/client/client.go b/service/zulip/client/client.go
new file mode 100644
index 00000000..5edc36cd
--- /dev/null
+++ b/service/zulip/client/client.go
@@ -0,0 +1,135 @@
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const (
+ // DefaultBaseURL contains example base URL of zulip service.
+ DefaultBaseURL = "https://yourZulipDomain.zulipchat.com"
+
+ // DefaultTimeout duration in second
+ DefaultTimeout time.Duration = 30 * time.Second
+)
+
+// Client abstracts the interaction between the application server and the
+// Zulip server via HTTP protocol. The developer must obtain an API key from the
+// Zulip's personal settings page and pass it to the `Client` so that it can
+// perform authorized requests on the application server's behalf.
+// To send a message to one or more devices use the Client's Send.
+//
+// If the `HTTP` field is nil, a zeroed http.Client will be allocated and used
+// to send messages.
+type Client struct {
+ email string
+ apiKey string
+ baseURL string
+ client *http.Client
+ timeout time.Duration
+}
+
+type Option func(*Client) error
+
+// NewClient creates new Zulip Client based on opts passed and
+// with default endpoint and http client.
+func NewClient(opts ...Option) (*Client, error) {
+ c := &Client{
+ baseURL: DefaultBaseURL,
+ client: &http.Client{},
+ timeout: DefaultTimeout,
+ }
+
+ for _, opt := range opts {
+ if err := opt(c); err != nil {
+ return nil, err
+ }
+ }
+
+ if c.apiKey == "" || c.email == "" {
+ return nil, ErrInvalidCreds
+ }
+
+ return c, nil
+}
+
+type Response struct {
+ ID int `json:"id"`
+ Msg string `json:"msg"`
+ Result string `json:"result"`
+ Code string `json:"code"`
+}
+
+// SendWithContext sends a message to the Zulip server without retrying in case of service
+// unavailability. A non-nil error is returned if a non-recoverable error
+// occurs (i.e. if the response status is not "200 OK").
+// Behaves just like regular send, but uses external context.
+func (c *Client) SendWithContext(ctx context.Context, msg *Message) (*Response, error) {
+ // validate
+ if err := msg.Validate(); err != nil {
+ return nil, err
+ }
+
+ return c.send(ctx, msg)
+}
+
+// Send sends a message to the Zulip server without retrying in case of service
+// unavailability. A non-nil error is returned if a non-recoverable error
+// occurs (i.e. if the response status is not "200 OK").
+func (c *Client) Send(msg *Message) (*Response, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
+ defer cancel()
+
+ return c.SendWithContext(ctx, msg)
+}
+
+// send sends a request.
+func (c *Client) send(ctx context.Context, msg *Message) (*Response, error) {
+ // set the message data
+ data := url.Values{}
+ data.Set("type", msg.Type)
+ data.Set("to", fmt.Sprintf("%v", msg.To))
+ data.Set("topic", msg.Topic)
+ data.Set("content", msg.Content)
+
+ // create request
+ url, _ := url.JoinPath(c.baseURL, "/api/v1/messages")
+ req, err := http.NewRequest("POST", url, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+
+ req = req.WithContext(ctx)
+
+ // add headers
+ req.SetBasicAuth(c.email, c.apiKey)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ // execute request
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ // check response status
+ if resp.StatusCode != http.StatusOK {
+ if resp.StatusCode >= http.StatusInternalServerError {
+ return nil, fmt.Errorf("%d error: %s", resp.StatusCode, resp.Status)
+ }
+ return nil, fmt.Errorf("%d error: %s", resp.StatusCode, resp.Status)
+ }
+
+ // build return
+ response := new(Response)
+ if err := json.NewDecoder(resp.Body).Decode(response); err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
diff --git a/service/zulip/client/client_test.go b/service/zulip/client/client_test.go
new file mode 100644
index 00000000..dda56361
--- /dev/null
+++ b/service/zulip/client/client_test.go
@@ -0,0 +1,213 @@
+package client
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSend(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ t.Run("send=success", func(t *testing.T) {
+ t.Parallel()
+
+ server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ email, apiKey, _ := req.BasicAuth()
+ assert.Equal(email, "email", "invalid email provided")
+ assert.Equal(apiKey, "apiKey", "invalid apiKey provided")
+
+ rw.WriteHeader(http.StatusOK)
+ rw.Header().Set("Content-Type", "application/json")
+ fmt.Fprint(rw, `{
+ "id":42,
+ "msg": "",
+ "result": "success"
+ }`)
+ }))
+ defer server.Close()
+
+ client, err := NewClient(
+ WithBaseURL(server.URL),
+ WithCreds("email", "apiKey"),
+ WithTimeout(10*time.Second),
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ resp, err := client.Send(&Message{
+ Type: "stream",
+ To: "test",
+ Topic: "general",
+ Content: "some content goes here",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if resp.Result != "success" {
+ t.Fatalf("invalid response: %v", err)
+ }
+ })
+
+ t.Run("send=failure", func(t *testing.T) {
+ t.Parallel()
+
+ server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ email, apiKey, _ := req.BasicAuth()
+ assert.Equal(email, "email", "invalid email provided")
+ assert.Equal(apiKey, "apiKey", "invalid apiKey provided")
+
+ rw.WriteHeader(http.StatusBadRequest)
+ }))
+ defer server.Close()
+
+ client, err := NewClient(
+ WithBaseURL(server.URL),
+ WithCreds("email", "apiKey"),
+ WithTimeout(10*time.Second),
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ resp, err := client.Send(&Message{
+ Type: "stream",
+ To: "test",
+ Topic: "general",
+ Content: "some content goes here",
+ })
+ if err == nil {
+ t.Fatal("expected error but got nil")
+ }
+ if resp != nil {
+ t.Fatalf("expected nil response\ngot: %v response", resp)
+ }
+ })
+
+ t.Run("send=invalid_token", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := NewClient(
+ WithCreds("", ""),
+ WithTimeout(10*time.Second),
+ )
+ if err == nil {
+ t.Fatal("expected error but got nil")
+ }
+ })
+
+ t.Run("send=invalid_message", func(t *testing.T) {
+ t.Parallel()
+
+ client, err := NewClient(
+ WithBaseURL(""),
+ WithCreds("email", "apiKey"),
+ WithTimeout(10*time.Second),
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ _, err = client.Send(&Message{})
+ if err == nil {
+ t.Fatal("expected error but go nil")
+ }
+ })
+}
+
+func TestSendWithContext(t *testing.T) {
+ t.Run("send_context=success", func(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ email, apiKey, _ := req.BasicAuth()
+ assert.Equal(email, "email", "invalid email provided")
+ assert.Equal(apiKey, "apiKey", "invalid apiKey provided")
+
+ rw.WriteHeader(http.StatusOK)
+ rw.Header().Set("Content-Type", "application/json")
+ fmt.Fprint(rw, `{
+ "id":42,
+ "msg": "",
+ "result": "success"
+ }`)
+ }))
+ defer server.Close()
+
+ client, err := NewClient(
+ WithBaseURL(server.URL),
+ WithCreds("email", "apiKey"),
+ WithTimeout(10*time.Second),
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ ctx := context.Background()
+ resp, err := client.SendWithContext(ctx, &Message{
+ Type: "stream",
+ To: "test",
+ Topic: "general",
+ Content: "some content goes here",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.Result != "success" {
+ t.Fatalf("invalid response: %v", err)
+ }
+ })
+
+ t.Run("send_context=timeout", func(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ email, apiKey, _ := req.BasicAuth()
+ assert.Equal(email, "email", "invalid email provided")
+ assert.Equal(apiKey, "apiKey", "invalid apiKey provided")
+
+ time.Sleep(time.Millisecond * 100)
+
+ rw.WriteHeader(http.StatusOK)
+ rw.Header().Set("Content-Type", "application/json")
+ fmt.Fprint(rw, `{
+ "id":42,
+ "msg": "",
+ "result": "success"
+ }`)
+ }))
+ defer server.Close()
+
+ client, err := NewClient(
+ WithBaseURL(server.URL),
+ WithCreds("email", "apiKey"),
+ WithTimeout(100*time.Millisecond),
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
+ defer cancel()
+ _, err = client.SendWithContext(ctx, &Message{
+ Type: "stream",
+ To: "test",
+ Topic: "general",
+ Content: "some content goes here",
+ })
+ if err == nil {
+ t.Fatalf("no context timeout")
+ }
+ })
+}
diff --git a/service/zulip/client/errors.go b/service/zulip/client/errors.go
new file mode 100644
index 00000000..bed2f2ae
--- /dev/null
+++ b/service/zulip/client/errors.go
@@ -0,0 +1,18 @@
+package client
+
+import "errors"
+
+// ErrInvalidCreds occurs if API key or Email is not set.
+var ErrInvalidCreds = errors.New("client credentials are invalid")
+
+// ErrInvalidMessageType occurs if Message Type is not 'direct','stream' or 'private'
+var ErrInvalidMessageType = errors.New("invalid message type. Type should be 'direct','stream' or 'private'")
+
+// ErrInvalidMessageTo occurs if Message To is not string, int, []string, []int
+var ErrInvalidMessageTo = errors.New("invalid message to. To should be string, int, []string or []int")
+
+// ErrInvalidMessageTopic occurs if Message Topic is empty
+var ErrInvalidMessageTopic = errors.New("invalid message topic. Topic should be a non-empty string")
+
+// ErrInvalidMessageContent occurs if Message Content is empty
+var ErrInvalidMessageContent = errors.New("invalid message content. Content should be a non-empty string")
diff --git a/service/zulip/client/hooks.go b/service/zulip/client/hooks.go
new file mode 100644
index 00000000..d11a5f01
--- /dev/null
+++ b/service/zulip/client/hooks.go
@@ -0,0 +1,28 @@
+package client
+
+import "time"
+
+// Hook to add baseURL to the Zulip client
+func WithBaseURL(baseURL string) Option {
+ return func(c *Client) error {
+ c.baseURL = baseURL
+ return nil
+ }
+}
+
+// Hook to add necessary creds to the Zulip client
+func WithCreds(email string, apiKey string) Option {
+ return func(c *Client) error {
+ c.email = email
+ c.apiKey = apiKey
+ return nil
+ }
+}
+
+// Hook to add timeout to the Zulip client
+func WithTimeout(timeout time.Duration) Option {
+ return func(c *Client) error {
+ c.timeout = timeout
+ return nil
+ }
+}
diff --git a/service/zulip/client/hooks_test.go b/service/zulip/client/hooks_test.go
new file mode 100644
index 00000000..7cbd976c
--- /dev/null
+++ b/service/zulip/client/hooks_test.go
@@ -0,0 +1,85 @@
+package client
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestWithBaseURL(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ urls := []string{
+ "https://localhost:5000",
+ "https://zulip.com",
+ "https://test.domain.co",
+ }
+
+ for i, url := range urls {
+ client, _ := NewClient(WithBaseURL(url), WithCreds("", ""))
+ assert.Equal(
+ client.baseURL,
+ url,
+ fmt.Sprintf("TEST %d: WithBaseURL hook failed", i),
+ )
+ }
+}
+
+func TestWithCreds(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ creds := []struct {
+ email string
+ apiKey string
+ }{
+ {email: "test@gmail.com", apiKey: ""},
+ {email: "zulip@gmail.com", apiKey: ""},
+ {email: "notify@gmail.com", apiKey: ""},
+ }
+
+ for i, cred := range creds {
+ client, err := NewClient(WithCreds(cred.email, cred.apiKey))
+ if err != nil {
+ t.Errorf("TEST %d: WithCreds hook errored", i)
+ } else {
+ assert.Equal(
+ client.email,
+ cred.email,
+ fmt.Sprintf("TEST %d: WithCreds hook failed for email", i),
+ )
+ assert.Equal(
+ client.apiKey,
+ cred.apiKey,
+ fmt.Sprintf("TEST %d: WithCreds hook failed for apiKey", i),
+ )
+ }
+ }
+}
+
+func TestWithTimeout(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ timeouts := []time.Duration{
+ 1 * time.Minute,
+ 50 * time.Second,
+ 200 * time.Millisecond,
+ }
+
+ for i, timeout := range timeouts {
+ client, _ := NewClient(WithTimeout(timeout), WithCreds("", ""))
+
+ assert.Equal(
+ client.timeout,
+ timeout,
+ fmt.Sprintf("TEST %d: WithTimeout hook failed", i),
+ )
+ }
+}
diff --git a/service/zulip/client/message.go b/service/zulip/client/message.go
new file mode 100644
index 00000000..23bd672d
--- /dev/null
+++ b/service/zulip/client/message.go
@@ -0,0 +1,46 @@
+package client
+
+import (
+ "reflect"
+)
+
+type Message struct {
+ Type string
+ To any
+ Topic string
+ Content string
+}
+
+// Validate returns an error if the message is not well-formed.
+func (m *Message) Validate() error {
+ // Type must be "direct", "stream" or "private"
+ switch m.Type {
+ case "direct":
+ case "stream":
+ case "private":
+ break
+ default:
+ return ErrInvalidMessageType
+ }
+
+ // To must be int,string, []int, []string
+ switch reflect.TypeOf(m.To) {
+ case reflect.TypeOf(""):
+ case reflect.TypeOf(1):
+ case reflect.SliceOf(reflect.TypeOf("")):
+ case reflect.SliceOf(reflect.TypeOf(1)):
+ break
+ default:
+ return ErrInvalidMessageTo
+ }
+
+ if m.Topic == "" {
+ return ErrInvalidMessageTopic
+ }
+
+ if m.Content == "" {
+ return ErrInvalidMessageContent
+ }
+
+ return nil
+}
diff --git a/service/zulip/client/message_test.go b/service/zulip/client/message_test.go
new file mode 100644
index 00000000..95611032
--- /dev/null
+++ b/service/zulip/client/message_test.go
@@ -0,0 +1,70 @@
+package client
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type MessageValidationTest struct {
+ msg Message
+ err error
+}
+
+func TestMessageValidation(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ msgs := []MessageValidationTest{
+ {
+ msg: Message{Type: "stream", To: "Sai Sumith", Topic: "something", Content: "content"},
+ err: nil,
+ },
+ {
+ msg: Message{Type: "direct", To: "Sai Sumith", Topic: "something", Content: "content"},
+ err: nil,
+ },
+ {
+ msg: Message{Type: "private", To: "Sai Sumith", Topic: "something", Content: "content"},
+ err: nil,
+ },
+ {
+ msg: Message{Type: "stream", To: "Sai Sumith", Topic: "", Content: "content"},
+ err: ErrInvalidMessageTopic,
+ },
+ {
+ msg: Message{Type: "stream", To: "Sai Sumith", Topic: "something", Content: ""},
+ err: ErrInvalidMessageContent,
+ },
+ {
+ msg: Message{Type: "stream", To: 1, Topic: "something", Content: "content"},
+ err: nil,
+ },
+ {
+ msg: Message{Type: "stream", To: []int{1, 2, 3}, Topic: "something", Content: "content"},
+ err: nil,
+ },
+ {
+ msg: Message{Type: "stream", To: []string{"Sai", "Sumith"}, Topic: "something", Content: "content"},
+ err: nil,
+ },
+ {
+ msg: Message{Type: "somethingRandom", To: []string{"Sai", "Sumith"}, Topic: "something", Content: "content"},
+ err: ErrInvalidMessageType,
+ },
+ {
+ msg: Message{Type: "stream", To: true, Topic: "something", Content: "content"},
+ err: ErrInvalidMessageTo,
+ },
+ }
+
+ for i, v := range msgs {
+ assert.Equal(
+ v.msg.Validate(),
+ v.err,
+ fmt.Sprintf("TEST %d: validation failed", i),
+ )
+ }
+}
diff --git a/service/zulip/doc.go b/service/zulip/doc.go
new file mode 100644
index 00000000..893bd113
--- /dev/null
+++ b/service/zulip/doc.go
@@ -0,0 +1,35 @@
+/*
+Package zulip provides message integration for the zulip service.
+
+Usage:
+
+ package main
+
+ import (
+ "context"
+ "log"
+
+ "github.com/nikoksr/notify"
+ "github.com/nikoksr/notify/service/zulip"
+ )
+
+ func main() {
+ zulipSvc, err := zulip.New("server-base-url", "bot-email", "api-key")
+ if err != nil {
+ log.Fatalf("zulip.New() failed: %s", err.Error())
+ }
+
+ zulipSvc.AddReceivers(Direct("test2@gmail.com"), Stream("stream", "topic"))
+
+ notifier := notify.New()
+ notifier.UseServices(zulipSvc)
+
+ err = notifier.Send(context.Background(), "subject", "message")
+ if err != nil {
+ log.Fatalf("notifier.Send() failed: %s", err.Error())
+ }
+
+ log.Println("notification sent")
+ }
+*/
+package zulip
diff --git a/service/zulip/mock_zulip_client.go b/service/zulip/mock_zulip_client.go
new file mode 100644
index 00000000..990063ff
--- /dev/null
+++ b/service/zulip/mock_zulip_client.go
@@ -0,0 +1,54 @@
+// Code generated by mockery v2.35.1. DO NOT EDIT.
+
+package zulip
+
+import (
+ mock "github.com/stretchr/testify/mock"
+
+ client "github.com/nikoksr/notify/service/zulip/client"
+)
+
+// mockZulipClient is an autogenerated mock type for the zulipClient type
+type mockZulipClient struct {
+ mock.Mock
+}
+
+// Send provides a mock function with given fields: msg
+func (_m *mockZulipClient) Send(msg *client.Message) (*client.Response, error) {
+ ret := _m.Called(msg)
+
+ var r0 *client.Response
+ var r1 error
+ if rf, ok := ret.Get(0).(func(*client.Message) (*client.Response, error)); ok {
+ return rf(msg)
+ }
+ if rf, ok := ret.Get(0).(func(*client.Message) *client.Response); ok {
+ r0 = rf(msg)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*client.Response)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(*client.Message) error); ok {
+ r1 = rf(msg)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// newMockZulipClient creates a new instance of mockZulipClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockZulipClient(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *mockZulipClient {
+ mock := &mockZulipClient{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/service/zulip/zulip.go b/service/zulip/zulip.go
new file mode 100644
index 00000000..b8ff626a
--- /dev/null
+++ b/service/zulip/zulip.go
@@ -0,0 +1,86 @@
+package zulip
+
+import (
+ "context"
+
+ z "github.com/nikoksr/notify/service/zulip/client"
+)
+
+//go:generate mockery --name=zulipClient --output=. --case=underscore --inpackage
+type zulipClient interface {
+ Send(msg *z.Message) (*z.Response, error)
+}
+
+// Compile-time check to ensure that zulip client send function implements the zulipClient interface
+var _ zulipClient = new(z.Client)
+
+type Receiver struct {
+ _type string
+ _to string
+ _topic string
+}
+
+type Service struct {
+ client zulipClient
+ recv []*Receiver
+}
+
+func New(baseURL, botEmail, apiKey string) (*Service, error) {
+ client, err := z.NewClient(
+ z.WithBaseURL(baseURL),
+ z.WithCreds(botEmail, apiKey),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ service := &Service{
+ client,
+ []*Receiver{},
+ }
+
+ return service, nil
+}
+
+func Direct(email string) *Receiver {
+ return &Receiver{
+ _type: "direct",
+ _to: email,
+ _topic: "",
+ }
+}
+
+func Stream(stream, topic string) *Receiver {
+ return &Receiver{
+ _type: "stream",
+ _to: stream,
+ _topic: topic,
+ }
+}
+
+func (s *Service) AddReceivers(recvs ...*Receiver) {
+ s.recv = append(s.recv, recvs...)
+}
+
+func (s *Service) Send(ctx context.Context, subject, message string) error {
+ for _, recv := range s.recv {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ msg := z.Message{
+ Type: recv._type,
+ To: recv._to,
+ Topic: recv._topic,
+ Content: subject + " " + message,
+ }
+
+ _, err := s.client.Send(&msg)
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/service/zulip/zulip_test.go b/service/zulip/zulip_test.go
new file mode 100644
index 00000000..0d37a4de
--- /dev/null
+++ b/service/zulip/zulip_test.go
@@ -0,0 +1,125 @@
+package zulip
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ z "github.com/nikoksr/notify/service/zulip/client"
+)
+
+func TestZulip_New(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ service, err := New("server-base-url", "bot-email", "api-key")
+ assert.NotNil(service)
+ assert.Nil(err)
+}
+
+func TestDirectHook(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ receiver := Direct("test@gmail.com")
+
+ assert.Equal(receiver._type, "direct")
+ assert.Equal(receiver._to, "test@gmail.com")
+ assert.Equal(receiver._topic, "")
+
+ assert.NotNil(receiver)
+ assert.IsType(new(Receiver), receiver)
+}
+
+func TestStreamHook(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ receiver := Stream("group", "topic")
+
+ assert.Equal(receiver._type, "stream")
+ assert.Equal(receiver._to, "group")
+ assert.Equal(receiver._topic, "topic")
+
+ assert.NotNil(receiver)
+ assert.IsType(new(Receiver), receiver)
+}
+
+func TestZulip_AddReceivers(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ service, _ := New("server-base-url", "bot-email", "api-key")
+
+ service.AddReceivers(Direct("test@gmail.com"))
+ assert.Len(service.recv, 1)
+
+ service.AddReceivers(Direct("test2@gmail.com"), Stream("stream", "topic"))
+ assert.Len(service.recv, 3)
+
+ service.AddReceivers(Stream("stream2", "topic2"))
+ assert.Len(service.recv, 4)
+}
+
+func TestZulip_Send(t *testing.T) {
+ t.Parallel()
+
+ assert := require.New(t)
+
+ t.Run("send_message=direct", func(t *testing.T) {
+ t.Parallel()
+
+ service := &Service{}
+
+ mockZulipClient := newMockZulipClient(t)
+ mockZulipClient.On("Send", &z.Message{
+ Type: "direct",
+ To: "test@gmail.com",
+ Topic: "",
+ Content: "subject message",
+ }).Return(&z.Response{
+ ID: 0,
+ Msg: "",
+ Result: "",
+ Code: "",
+ }, nil)
+
+ service.client = mockZulipClient
+
+ service.AddReceivers(Direct("test@gmail.com"))
+ err := service.Send(context.Background(), "subject", "message")
+ assert.Nil(err)
+ mockZulipClient.AssertExpectations(t)
+ })
+
+ t.Run("send_message=stream", func(t *testing.T) {
+ t.Parallel()
+
+ service := &Service{}
+
+ mockZulipClient := newMockZulipClient(t)
+ mockZulipClient.On("Send", &z.Message{
+ Type: "stream",
+ To: "group",
+ Topic: "topic",
+ Content: "subject message",
+ }).Return(&z.Response{
+ ID: 0,
+ Msg: "",
+ Result: "",
+ Code: "",
+ }, nil)
+
+ service.client = mockZulipClient
+
+ service.AddReceivers(Stream("group", "topic"))
+ err := service.Send(context.Background(), "subject", "message")
+ assert.Nil(err)
+ mockZulipClient.AssertExpectations(t)
+ })
+}