diff --git a/README.md b/README.md index 1b66c4a40..ccb69ba25 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) | [ifo/gozulipbot](https://github.com/ifo/gozulipbot) | :heavy_check_mark: | ## Special Thanks diff --git a/go.mod b/go.mod index 0c45b95d8..615b842ed 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( require github.com/golang-jwt/jwt v3.2.2+incompatible // indirect require ( + github.com/ifo/gozulipbot v0.0.1 github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/vartanbeno/go-reddit/v2 v2.0.1 google.golang.org/api v0.143.0 diff --git a/go.sum b/go.sum index 904ca9e55..d79789689 100644 --- a/go.sum +++ b/go.sum @@ -187,6 +187,8 @@ github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslC github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ifo/gozulipbot v0.0.1 h1:hYcUViKBe1ZIXk+WRNOe2dEFGi6H8G1cr25HsEnN9Xw= +github.com/ifo/gozulipbot v0.0.1/go.mod h1:KBDdzKbjflzh+LBaYauJmuDCwwXfFLI6j3eSENlScE0= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= diff --git a/service/zulip/common.go b/service/zulip/common.go new file mode 100644 index 000000000..31dd5110e --- /dev/null +++ b/service/zulip/common.go @@ -0,0 +1,24 @@ +package zulip + +// Receiver encapsulates a receiver credentials for a direct or stream message. +type Receiver struct { + email string + stream string + topic string +} + +// Direct specifies a Zulip Direct message +func Direct(email string) *Receiver { + return &Receiver{email: email} +} + +// Stream specifies a Zulip Stream message +func Stream(stream, topic string) *Receiver { + return &Receiver{stream: stream, topic: topic} +} + +type ErrorResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Result string `json:"result"` +} diff --git a/service/zulip/mock_zulip_client.go b/service/zulip/mock_zulip_client.go new file mode 100644 index 000000000..812704424 --- /dev/null +++ b/service/zulip/mock_zulip_client.go @@ -0,0 +1,56 @@ +// Code generated by mockery v2.34.2. DO NOT EDIT. + +package zulip + +import ( + http "net/http" + + gozulipbot "github.com/ifo/gozulipbot" + + mock "github.com/stretchr/testify/mock" +) + +// mockZulipClient is an autogenerated mock type for the zulipClient type +type mockZulipClient struct { + mock.Mock +} + +// Message provides a mock function with given fields: _a0 +func (_m *mockZulipClient) Message(_a0 gozulipbot.Message) (*http.Response, error) { + ret := _m.Called(_a0) + + var r0 *http.Response + var r1 error + if rf, ok := ret.Get(0).(func(gozulipbot.Message) (*http.Response, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(gozulipbot.Message) *http.Response); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*http.Response) + } + } + + if rf, ok := ret.Get(1).(func(gozulipbot.Message) error); ok { + r1 = rf(_a0) + } 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/usage.md b/service/zulip/usage.md new file mode 100644 index 000000000..6d1eb487d --- /dev/null +++ b/service/zulip/usage.md @@ -0,0 +1,62 @@ +# Zulip Usage + +Ensure that you have already navigated to your GOPATH and installed the following packages: + +* `go get -u github.com/nikoksr/notify` + +## Steps for creating Zulip Bot + +These are general and very high level instructions + +1. Create a new Zulip bot (https://zulip.com/help/add-a-bot-or-integration) +2. Copy your *Organization URL* from the browser address bar. You need to copy only subdomain `your-org` from the full url `your-org.zulipchat.com` without the hostname `.zulipchat.com`. +3. Copy your *Bot Email* and *API Key* for usage below +4. Copy the *Stream name* of the stream if you want to post a message to stream or just copy an email address of the receiver. +5. Now you should be good to use the code below + +## Sample Code + +```go +package main + +import ( + "context" + "fmt" + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/zulip" +) + +func main() { + + notifier := notify.New() + + // Provide your Zulip Bot credentials + zulipService := zulip.New( + "your-org", + "ZULIP_API_KEY", + "email-bot@your-org.zulipchat.com", + ) + + // Passing a Zulip receivers as a receiver for our messages. + // Where to send our messages. + // It can be direct or stream message + zulipService.AddReceivers(zulip.Direct("some-user@email.com")) + zulipService.AddReceivers(zulip.Stream("alerts", "critical")) + + // Tell our notifier to use the Zulip service. You can repeat the above process + // for as many services as you like and just tell the notifier to use them. + notifier.UseServices(zulipService) + + // Send a message + err := notifier.Send( + context.Background(), + "Hello from notify :wave:\n", + "Message written in Go!", + ) + + if err != nil { + fmt.Println(err) + } + +} +``` diff --git a/service/zulip/zulip.go b/service/zulip/zulip.go new file mode 100644 index 000000000..d8f8fa5c0 --- /dev/null +++ b/service/zulip/zulip.go @@ -0,0 +1,93 @@ +package zulip + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + gzb "github.com/ifo/gozulipbot" + "github.com/pkg/errors" +) + +//go:generate mockery --name=zulipClient --output=. --case=underscore --inpackage +type zulipClient interface { + Message(gzb.Message) (*http.Response, error) +} + +// Compile-time check to ensure that zulip message client implements the zulipClient interface. +var _ zulipClient = new(gzb.Bot) + +// Zulip struct holds necessary data to communicate with the Zulip API. +type Zulip struct { + client zulipClient + receivers []*Receiver +} + +func New(domain, apiKey, botEmail string) *Zulip { + client := &gzb.Bot{ + APIURL: fmt.Sprintf("https://%s.zulipchat.com/api/v1/", domain), + APIKey: apiKey, + Email: botEmail, + } + + client.Init() + + zulip := &Zulip{ + client: client, + receivers: make([]*Receiver, 0), + } + + return zulip +} + +func (z *Zulip) AddReceivers(receivers ...*Receiver) { + z.receivers = append(z.receivers, receivers...) +} + +func (z *Zulip) Send(ctx context.Context, subject, message string) error { + fullMessage := subject + "\n" + message // Treating subject as message title + + for _, receiver := range z.receivers { + select { + case <-ctx.Done(): + return ctx.Err() + default: + emails := make([]string, 0) + if receiver.email != "" { + emails = append(emails, receiver.email) + } + + msg := gzb.Message{ + Content: fullMessage, + Emails: emails, + Stream: receiver.stream, + Topic: receiver.topic, + } + + resp, err := z.client.Message(msg) + if err != nil { + return errors.Wrapf(err, "failed to send message to Zulip receiver") + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + switch resp.StatusCode { + case http.StatusBadRequest: + var errorResp ErrorResponse + _ = json.Unmarshal(body, &errorResp) + + return errors.Errorf("failed to send message to Zulip receiver: %s", errorResp.Message) + + case http.StatusOK: + break + + default: + return errors.Errorf("failed to send message to Zulip receiver: %s", body) + } + } + } + + return nil +} diff --git a/service/zulip/zulip_test.go b/service/zulip/zulip_test.go new file mode 100644 index 000000000..c85f2acec --- /dev/null +++ b/service/zulip/zulip_test.go @@ -0,0 +1,237 @@ +package zulip + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/mock" + + "github.com/stretchr/testify/require" +) + +func TestZulip_New(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + service := New("your-org", "ZULIP_API_KEY", "email-bot@your-org.zulipchat.com") + assert.NotNil(service) +} + +func TestZulip_AddReceivers(t *testing.T) { + t.Parallel() + + assert := require.New(t) + + service := New("your-org", "ZULIP_API_KEY", "email-bot@your-org.zulipchat.com") + assert.NotNil(service) + + service.AddReceivers(Direct("some-user@email.com")) + assert.Len(service.receivers, 1) + + service.AddReceivers(Direct("some-user2@email.com"), Stream("stream-name", "topic-name")) + assert.Len(service.receivers, 3) + + service.AddReceivers(Stream("another-stream-name", "topic-name")) + assert.Len(service.receivers, 4) +} + +func TestZulip_Send(t *testing.T) { + t.Run("error", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assert := require.New(t) + + service := New("your-org", "ZULIP_API_KEY", "email-bot@your-org.zulipchat.com") + assert.NotNil(service) + + mockClient := newMockZulipClient(t) + mockClient. + On("Message", mock.AnythingOfType("gozulipbot.Message")). + Return(nil, errors.New("some error")) + + service.client = mockClient + + service.AddReceivers(Direct("some-user@email.com")) + + err := service.Send(ctx, "subject", "message") + assert.NotNil(err) + mockClient.AssertExpectations(t) + }) + + t.Run("success direct message", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assert := require.New(t) + + service := New("your-org", "ZULIP_API_KEY", "email-bot@your-org.zulipchat.com") + assert.NotNil(service) + + mockResponse := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"id": 42,"msg": "","result": "success"}`)), + } + + mockClient := newMockZulipClient(t) + mockClient. + On("Message", mock.AnythingOfType("gozulipbot.Message")). + Return(mockResponse, nil) + + service.client = mockClient + service.AddReceivers(Direct("some-user@email.com")) + + err := service.Send(ctx, "subject", "message") + assert.Nil(err) + mockClient.AssertExpectations(t) + }) + + t.Run("success stream message", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assert := require.New(t) + + service := New("your-org", "ZULIP_API_KEY", "email-bot@your-org.zulipchat.com") + assert.NotNil(service) + + mockResponse := &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"id": 42,"msg": "","result": "success"}`)), + } + + mockClient := newMockZulipClient(t) + mockClient. + On("Message", mock.AnythingOfType("gozulipbot.Message")). + Return(mockResponse, nil) + + service.client = mockClient + service.AddReceivers(Stream("stream-name", "topic-name")) + + err := service.Send(ctx, "subject", "message") + assert.Nil(err) + mockClient.AssertExpectations(t) + }) + + t.Run("no receivers added", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assert := require.New(t) + + service := New("your-org", "ZULIP_API_KEY", "email-bot@your-org.zulipchat.com") + assert.NotNil(service) + + err := service.Send(ctx, "subject", "message") + assert.Nil(err) + }) + + t.Run("non-exists receiver for a direct message", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assert := require.New(t) + + service := New("your-org", "ZULIP_API_KEY", "email-bot@your-org.zulipchat.com") + assert.NotNil(service) + + mockResponse := &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBufferString(`{"code": "BAD_REQUEST","msg": "Invalid email 'invalid@email.com'","result": "error"}`)), + } + + mockClient := newMockZulipClient(t) + mockClient. + On("Message", mock.AnythingOfType("gozulipbot.Message")). + Return(mockResponse, nil) + + service.client = mockClient + service.AddReceivers(Direct("invalid@email.com")) + + err := service.Send(ctx, "subject", "message") + assert.NotNil(err) + mockClient.AssertExpectations(t) + }) + + t.Run("non-exists stream for a stream message", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assert := require.New(t) + + service := New("your-org", "ZULIP_API_KEY", "email-bot@your-org.zulipchat.com") + assert.NotNil(service) + + mockResponse := &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBufferString(`{"code": "BAD_REQUEST","msg": "Invalid email 'invalid@email.com'","result": "error"}`)), + } + + mockClient := newMockZulipClient(t) + mockClient. + On("Message", mock.AnythingOfType("gozulipbot.Message")). + Return(mockResponse, nil) + + service.client = mockClient + service.AddReceivers(Stream("invalid-stream-name", "topic-name")) + + err := service.Send(ctx, "subject", "message") + assert.NotNil(err) + mockClient.AssertExpectations(t) + }) + + t.Run("invalid response from API server", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assert := require.New(t) + + service := New("your-org", "ZULIP_API_KEY", "email-bot@your-org.zulipchat.com") + assert.NotNil(service) + + mockResponse := &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewBufferString(`{"code": "INTERNAL_SERVER_ERROR","msg": "Something went wrong'","result": "error"}`)), + } + + mockClient := newMockZulipClient(t) + mockClient. + On("Message", mock.AnythingOfType("gozulipbot.Message")). + Return(mockResponse, nil) + + service.client = mockClient + service.AddReceivers(Direct("some-user@email.com")) + + err := service.Send(ctx, "subject", "message") + assert.NotNil(err) + mockClient.AssertExpectations(t) + }) + + t.Run("deadline exceeded", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assert := require.New(t) + + service := New("your-org", "ZULIP_API_KEY", "email-bot@your-org.zulipchat.com") + assert.NotNil(service) + + deadline := time.Now().Add(-5 * time.Second) + ctx, cancelCtx := context.WithDeadline(ctx, deadline) + defer cancelCtx() + + mockClient := newMockZulipClient(t) + + service.client = mockClient + service.AddReceivers(Direct("some-user@email.com")) + + err := service.Send(ctx, "subject", "message") + assert.NotNil(err) + }) +}