diff --git a/README.md b/README.md index 8b77bf4..4858da2 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,8 @@ Yes, please! Contributions of all kinds are very welcome! Feel free to check our | [DingTalk](https://www.dingtalk.com) | [service/dinding](service/dingding) | [blinkbean/dingtalk](https://github.com/blinkbean/dingtalk) | | [Discord](https://discord.com) | [service/discord](service/discord) | [bwmarrin/discordgo](https://github.com/bwmarrin/discordgo) | | [Email](https://wikipedia.org/wiki/Email) | [service/mail](service/mail) | [jordan-wright/email](https://github.com/jordan-wright/email) | -| [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) | [service/fcm](service/fcm) | [appleboy/go-fcm](https://github.com/appleboy/go-fcm) | +| [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) | [service/fcm](service/fcm) | [appleboy/go-fcm](https://github.com/appleboy/go-fcm) | +| [Lark](https://www.larksuite.com/) | [service/lark](service/lark) | [go-lark/lark](https://github.com/go-lark/lark) | | [Line](https://line.me) | [service/line](service/line) | [line/line-bot-sdk-go](https://github.com/line/line-bot-sdk-go) | | [Line Notify](https://notify-bot.line.me) | [service/line](service/line) | [utahta/go-linenotify](https://github.com/utahta/go-linenotify) | | [Mailgun](https://www.mailgun.com) | [service/mailgun](service/mailgun) | [mailgun/mailgun-go](https://github.com/mailgun/mailgun-go) | diff --git a/go.mod b/go.mod index fee9fd5..83a5879 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( require ( github.com/appleboy/go-fcm v0.1.5 + github.com/go-lark/lark v1.7.2 github.com/google/go-cmp v0.5.8 github.com/kevinburke/twilio-go v0.0.0-20220615032439-b0fe9b151b0e ) diff --git a/go.sum b/go.sum index 8a05d56..8cb5936 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-lark/lark v1.7.2 h1:F2LlwbRrZVuHPs8lz1D7owDevUndPy88Hbw6ZXaho/A= +github.com/go-lark/lark v1.7.2/go.mod h1:6ltbSztPZRT6IaO9ZIQyVaY5pVp/KeMizDYtfZkU+vM= github.com/go-redis/redis/v8 v8.11.6-0.20220405070650-99c79f7041fc h1:jZY+lpZB92nvBo2f31oPC/ivGll6NcsnEOORm8Fkr4M= github.com/go-redis/redis/v8 v8.11.6-0.20220405070650-99c79f7041fc/go.mod h1:25mL1NKxbJhB63ihiK8MnNeTRd+xAizd6bOdydrTLUQ= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= @@ -136,6 +138,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y 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= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -334,5 +338,6 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/service/lark/README.md b/service/lark/README.md new file mode 100644 index 0000000..7687c58 --- /dev/null +++ b/service/lark/README.md @@ -0,0 +1,99 @@ +# Lark + +## Prerequisites + +Depending on your requirements, you'll need either a custom app or a Lark group +chat webhook. The latter is easier to set up, but can only send messages to the +group it is in. You may refer to the doc +[here](https://open.larksuite.com/document/uAjLw4CM/ukTMukTMuhttps://open.larksuite.com/document/home/develop-a-bot-in-5-minutes/create-an-appkTM/bot-v3/use-custom-bots-in-a-group) +to set up a webhook bot, and the doc +[here](https://open.larksuite.com/document/home/develop-a-bot-in-5-minutes/create-an-app) +to set up a custom app. + +## Usage + +### Webhook + +For webhook bots, we only need the webhook URL, which might look something like +`https://open.feishu.cn/open-apis/bot/v2/hook/xxx`. Note that there is no +method to configure receivers, because the webhook bot can only send messages +to the group in which it was created. + +```go +package main + +import ( + "context" + "log" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/lark" +) + +// Replace this with your own webhook URL. +const webHookURL = "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" + +func main() { + larkWebhookSvc := lark.NewWebhookService(webHookURL) + + notifier := notify.New() + notifier.UseServices(larkWebhookSvc) + + if err := notifier.Send(context.Background(), "subject", "message"); err != nil { + log.Fatalf("notifier.Send() failed: %s", err.Error()) + } + + log.Println("notification sent") +} +``` + +### Custom App + +For custom apps, we need to pass in the App ID and App Secret when creating a +new notification service. When adding receivers, the type of the receiver ID +must be specified, as shown in the example below. You may refer to the section +entitled "Query parameters" in the doc +[here](https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/create) +for more information about the different ID types. + +```go +package main + +import ( + "context" + "log" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/lark" +) + +// Replace these with the credentials from your custom app. +const ( + appId = "xxx" + appSecret = "xxx" +) + +func main() { + larkCustomAppService := lark.NewCustomAppService(appId, appSecret) + + // Lark implements five types of receiver IDs. You'll need to specify the + // type using the respective helper functions when adding them as receivers. + larkCustomAppService.AddReceivers( + lark.OpenID("xxx"), + lark.UserID("xxx"), + lark.UnionID("xxx"), + lark.Email("xyz@example.com"), + lark.ChatID("xxx"), + ) + + notifier := notify.New() + notifier.UseServices(larkCustomAppService) + + if err := notifier.Send(context.Background(), "subject", "message"); err != nil { + log.Fatalf("notifier.Send() failed: %s", err.Error()) + } + + log.Println("notification sent") +} +``` + diff --git a/service/lark/common.go b/service/lark/common.go new file mode 100644 index 0000000..9d6186e --- /dev/null +++ b/service/lark/common.go @@ -0,0 +1,58 @@ +package lark + +// sender is an interface for sending a message to an already defined receiver. +type sender interface { + Send(subject, message string) error +} + +// sender is an interface for sending a message to a specific receiver ID. +type sendToer interface { + SendTo(subject, message, id, idType string) error +} + +// receiverID encapsulates a receiver ID and its type in Lark. +type receiverID struct { + id string + typ receiverIDType +} + +// OpenID specifies an ID as a Lark Open ID. +func OpenID(s string) *receiverID { + return &receiverID{s, openID} +} + +// UserID specifies an ID as a Lark User ID. +func UserID(s string) *receiverID { + return &receiverID{s, userID} +} + +// UnionID specifies an ID as a Lark Union ID. +func UnionID(s string) *receiverID { + return &receiverID{s, unionID} +} + +// Email specifies a receiver ID as an email. +func Email(s string) *receiverID { + return &receiverID{s, email} +} + +// ChatID specifies an ID as a Lark Chat ID. +func ChatID(s string) *receiverID { + return &receiverID{s, chatID} +} + +// receiverIDType represents the different ID types implemented by Lark. This +// information is required when sending a message. More information about the +// different ID types can be found in the "Query parameters" section of +// the https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/create, +// or on +// https://open.larksuite.com/document/home/user-identity-introduction/introduction. +type receiverIDType string + +const ( + openID receiverIDType = "open_id" + userID receiverIDType = "user_id" + unionID receiverIDType = "union_id" + email receiverIDType = "email" + chatID receiverIDType = "chat_id" +) diff --git a/service/lark/custom_app.go b/service/lark/custom_app.go new file mode 100644 index 0000000..2c6d74b --- /dev/null +++ b/service/lark/custom_app.go @@ -0,0 +1,111 @@ +package lark + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/go-lark/lark" + + "github.com/nikoksr/notify" +) + +type customAppService struct { + receiveIDs []*receiverID + cli sendToer +} + +// Compile time check that larkCustomAppService implements notify.Notifer. +var _ notify.Notifier = &customAppService{} + +// NewCustomAppService returns a new instance of a Lark notify service using a +// Lark custom app. +func NewCustomAppService(appID, appSecret string) *customAppService { + bot := lark.NewChatBot(appID, appSecret) + + // We need to set the bot to use Lark's open.larksuite.com domain instead of + // the default open.feishu.cn domain. + bot.SetDomain(lark.DomainLark) + + // Let the bot use a HTTP client with a longer timeout than the default 5 + // seconds. + bot.SetClient(&http.Client{ + Timeout: 8 * time.Second, + }) + + _ = bot.StartHeartbeat() + + return &customAppService{ + receiveIDs: make([]*receiverID, 0), + cli: &larkClientGoLarkChatBot{ + bot: bot, + }, + } +} + +// AddReceivers adds recipients to future notifications. There are five different +// types of receiver IDs available in Lark and they must be specified here. For +// example: +// +// larkService.AddReceivers( +// lark.OpenID("ou_c99c5f35d542efc7ee492afe11af19ef"), +// lark.UserID("8335aga2"), +// lark.UnionID("on_cad4860e7af114fb4ff6c5d496d1dd76"), +// lark.Email("xyz@example.com"), +// lark.ChatID("oc_a0553eda9014c201e6969b478895c230"), +// ) +func (c *customAppService) AddReceivers(ids ...*receiverID) { + c.receiveIDs = append(c.receiveIDs, ids...) +} + +// Send takes a message subject and a message body and sends them to all +// previously registered recipient IDs. +func (c *customAppService) Send(ctx context.Context, subject, message string) error { + for _, id := range c.receiveIDs { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if err := c.cli.SendTo(subject, message, id.id, string(id.typ)); err != nil { + return err + } + } + } + return nil +} + +// larkClientGoLarkChatBot is a wrapper around go-lark/lark's Bot, to be used +// for sending messages with custom apps. +type larkClientGoLarkChatBot struct { + bot *lark.Bot +} + +// SendTo implements the sendToer interface using a go-lark/lark chat bot. +func (l *larkClientGoLarkChatBot) SendTo(subject, message, receiverID, idType string) error { + content := lark.NewPostBuilder(). + Title(subject). + TextTag(message, 1, false). + Render() + msg := lark.NewMsgBuffer(lark.MsgPost).Post(content) + switch receiverIDType(idType) { + case openID: + msg.BindOpenID(receiverID) + case userID: + msg.BindUserID(receiverID) + case unionID: + msg.BindUnionID(receiverID) + case email: + msg.BindEmail(receiverID) + case chatID: + msg.BindChatID(receiverID) + } + res, err := l.bot.PostMessage(msg.Build()) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + if res.Code != 0 { + return fmt.Errorf("send failed with error code %d, please see https://open.larksuite.com/document/ukTMukTMukTM/ugjM14COyUjL4ITN for details", res.Code) + } + return nil +} diff --git a/service/lark/custom_app_test.go b/service/lark/custom_app_test.go new file mode 100644 index 0000000..78f8bb7 --- /dev/null +++ b/service/lark/custom_app_test.go @@ -0,0 +1,80 @@ +package lark + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddReceivers(t *testing.T) { + t.Parallel() + + xs := []*receiverID{ + OpenID("ou_c99c5f35d542efc7ee492afe11af19ef"), + UserID("8335aga2"), + } + svc := NewCustomAppService("", "") + svc.AddReceivers(xs...) + + assert.ElementsMatch(t, svc.receiveIDs, xs) + + // Test if adding more receivers afterwards works. + ys := []*receiverID{ + UnionID("on_cad4860e7af114fb4ff6c5d496d1dd76"), + Email("xyz@example.com"), + ChatID("oc_a0553eda9014c201e6969b478895c230"), + } + svc.AddReceivers(ys...) + + assert.ElementsMatch(t, svc.receiveIDs, append(xs, ys...)) +} + +func TestSendCustomApp(t *testing.T) { + t.Parallel() + ctx := context.Background() + assert := assert.New(t) + + tests := []*receiverID{ + OpenID("ou_c99c5f35d542efc7ee492afe11af19ef"), + UserID("8335aga2"), + UnionID("on_cad4860e7af114fb4ff6c5d496d1dd76"), + Email("xyz@example.com"), + ChatID("oc_a0553eda9014c201e6969b478895c230"), + } + + // First, test for when the sender returns an error. + for _, tt := range tests { + mockSendToer := NewSendToer(t) + mockSendToer. + On("SendTo", "subject", "message", tt.id, string(tt.typ)). + Return(errors.New("")) + + svc := NewCustomAppService("", "") + svc.cli = mockSendToer + + svc.AddReceivers(tt) + err := svc.Send(ctx, "subject", "message") + assert.NotNil(err) + + mockSendToer.AssertExpectations(t) + } + + // Then test for when the sender does not return an error. + for _, tt := range tests { + mockSendToer := NewSendToer(t) + mockSendToer. + On("SendTo", "subject", "message", tt.id, string(tt.typ)). + Return(nil) + + svc := NewCustomAppService("", "") + svc.cli = mockSendToer + + svc.AddReceivers(tt) + err := svc.Send(ctx, "subject", "message") + assert.Nil(err) + + mockSendToer.AssertExpectations(t) + } +} diff --git a/service/lark/doc.go b/service/lark/doc.go new file mode 100644 index 0000000..13bfaf7 --- /dev/null +++ b/service/lark/doc.go @@ -0,0 +1,53 @@ +/* +Package lark provides message notification integration for Lark. Two kinds of +bots on Lark are supported -- webhooks and custom apps. For information on +webhook bots, see +https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/bot-v3/use-custom-bots-in-a-group, +and for info on custom apps, see +https://open.larksuite.com/document/home/develop-a-bot-in-5-minutes/create-an-app. + +Usage: + + package main + + import ( + "context" + "log" + + "github.com/nikoksr/notify" + "github.com/nikoksr/notify/service/lark" + ) + + const ( + webhookURL = "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" + appId = "xxx" + appSecret = "xxx" + ) + + func main() { + // Two types of services are available depending on your requirements. + larkWebhookService := lark.NewWebhookService(webhookURL) + larkCustomAppService := lark.NewCustomAppService(appId, appSecret) + + // Lark implements five types of receiver IDs. You'll need to specify the + // type using the respective helper functions when adding them as receivers + // for the custom app service. + larkCustomAppService.AddReceivers( + lark.OpenID("xxx"), + lark.UserID("xxx"), + lark.UnionID("xxx"), + lark.Email("xyz@example.com"), + lark.ChatID("xxx"), + ) + + notifier := notify.New() + notifier.UseServices(larkWebhookService, larkCustomAppService) + + if err := notifier.Send(context.Background(), "subject", "message"); err != nil { + log.Fatalf("notifier.Send() failed: %s", err.Error()) + } + + log.Println("notification sent") + } +*/ +package lark diff --git a/service/lark/mock_sendToer.go b/service/lark/mock_sendToer.go new file mode 100644 index 0000000..7edf606 --- /dev/null +++ b/service/lark/mock_sendToer.go @@ -0,0 +1,39 @@ +// Code generated by mockery v2.12.3. DO NOT EDIT. + +package lark + +import "github.com/stretchr/testify/mock" + +// SendToer is an autogenerated mock type for the sendToer type +type SendToer struct { + mock.Mock +} + +// SendTo provides a mock function with given fields: subject, message, id, idType +func (_m *SendToer) SendTo(subject string, message string, id string, idType string) error { + ret := _m.Called(subject, message, id, idType) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok { + r0 = rf(subject, message, id, idType) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type NewSendToerT interface { + mock.TestingT + Cleanup(func()) +} + +// NewSendToer creates a new instance of SendToer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewSendToer(t NewSendToerT) *SendToer { + mock := &SendToer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/service/lark/mock_sender.go b/service/lark/mock_sender.go new file mode 100644 index 0000000..b371ab9 --- /dev/null +++ b/service/lark/mock_sender.go @@ -0,0 +1,39 @@ +// Code generated by mockery v2.12.3. DO NOT EDIT. + +package lark + +import "github.com/stretchr/testify/mock" + +// Sender is an autogenerated mock type for the sender type +type Sender struct { + mock.Mock +} + +// Send provides a mock function with given fields: subject, message +func (_m *Sender) Send(subject string, message string) error { + ret := _m.Called(subject, message) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(subject, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type NewSenderT interface { + mock.TestingT + Cleanup(func()) +} + +// NewSender creates a new instance of Sender. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewSender(t NewSenderT) *Sender { + mock := &Sender{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/service/lark/webhook.go b/service/lark/webhook.go new file mode 100644 index 0000000..43ceb8f --- /dev/null +++ b/service/lark/webhook.go @@ -0,0 +1,58 @@ +package lark + +import ( + "context" + "fmt" + + "github.com/go-lark/lark" + + "github.com/nikoksr/notify" +) + +type webhookService struct { + cli sender +} + +// Compile time check that larkCustomAppService implements notify.Notifer. +var _ notify.Notifier = &webhookService{} + +// NewWebhookService returns a new instance of a Lark notify service using a +// Lark group chat webhook. Note that this service does not take any +// notification receivers because it can only push messages to the group chat +// it belongs to. +func NewWebhookService(webhookURL string) *webhookService { + bot := lark.NewNotificationBot(webhookURL) + return &webhookService{ + cli: &larkClientGoLarkNotificationBot{ + bot: bot, + }, + } +} + +// Send sends the message subject and body to the group chat. +func (w *webhookService) Send(ctx context.Context, subject, message string) error { + return w.cli.Send(subject, message) +} + +// larkClientGoLarkNotificationBot is a wrapper around go-lark/lark's Bot, to +// be used for notifications via webhooks only. +type larkClientGoLarkNotificationBot struct { + bot *lark.Bot +} + +// Send implements the sender interface using a go-lark/lark notification bot. +func (w *larkClientGoLarkNotificationBot) Send(subject, message string) error { + content := lark.NewPostBuilder(). + Title(subject). + TextTag(message, 1, false). + Render() + msg := lark.NewMsgBuffer(lark.MsgPost).Post(content) + res, err := w.bot.PostNotificationV2(msg.Build()) + if err != nil { + return fmt.Errorf("failed to post webhook message: %w", err) + } + if res.Code != 0 { + return fmt.Errorf("send failed with error code %d, please see https://open.larksuite.com/document/ukTMukTMukTM/ugjM14COyUjL4ITN for details", res.Code) + } + return nil +} diff --git a/service/lark/webhook_test.go b/service/lark/webhook_test.go new file mode 100644 index 0000000..a6534e4 --- /dev/null +++ b/service/lark/webhook_test.go @@ -0,0 +1,44 @@ +package lark + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSendWebhook(t *testing.T) { + t.Parallel() + + assert := assert.New(t) + ctx := context.Background() + + // First, test for when the sender returns an error. + { + mockSender := NewSender(t) + mockSender. + On("Send", "subject", "message"). + Return(errors.New("")) + + svc := NewWebhookService("") + svc.cli = mockSender + err := svc.Send(ctx, "subject", "message") + assert.NotNil(err) + mockSender.AssertExpectations(t) + } + + // Then test for when the sender does not return an error. + { + mockSender := NewSender(t) + mockSender. + On("Send", "subject", "message"). + Return(nil) + + svc := NewWebhookService("") + svc.cli = mockSender + err := svc.Send(ctx, "subject", "message") + assert.Nil(err) + mockSender.AssertExpectations(t) + } +}