diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 618f3b9..e145e04 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -24,7 +24,7 @@ jobs:
go-version: 1.15
-
name: Cache Go modules
- uses: actions/cache@v2.1.4
+ uses: actions/cache@v2.1.5
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
diff --git a/Makefile b/Makefile
index 73a46a4..4a878f6 100644
--- a/Makefile
+++ b/Makefile
@@ -27,7 +27,7 @@ test:
# gofumports and gci all go files
fmt:
- find . -name '*.go' -not -wholename './vendor/*' | while read -r file; do gofumports -w "$$file"; done
+ find . -name '*.go' -not -wholename './vendor/*' | while read -r file; do gofumpt -w "$$file"; done
gci -w -local github.com/nikoksr/notify .
.PHONY: fmt
diff --git a/README.md b/README.md
index eef9694..c11e25b 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,7 @@ _ = notifier.Send(
- *Telegram*
- *Twitter*
- *WhatsApp*
+- *WeChat*
## Credits
@@ -95,6 +96,7 @@ _ = notifier.Send(
- Slack support: [slack-go/slack](https://github.com/slack-go/slack)
- Telegram support: [go-telegram-bot-api/telegram-bot-api](https://github.com/go-telegram-bot-api/telegram-bot-api)
- Twitter: [dghubble/go-twitter](https://github.com/dghubble/go-twitter)
+- WeChat: [silenceper/wechat](https://github.com/silenceper/wechat)
- WhatsApp: [Rhymen/go-whatsapp](https://github.com/Rhymen/go-whatsapp)
## Author
diff --git a/assets/gopher-letter.svg b/assets/gopher-letter.svg
index 2e57514..a3fbf40 100644
--- a/assets/gopher-letter.svg
+++ b/assets/gopher-letter.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 7139180..ac464b2 100644
--- a/go.mod
+++ b/go.mod
@@ -29,7 +29,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/plivo/plivo-go v5.5.1+incompatible
github.com/sendgrid/rest v2.6.3+incompatible // indirect
- github.com/sendgrid/sendgrid-go v3.8.0+incompatible
+ github.com/sendgrid/sendgrid-go v3.9.0+incompatible
github.com/silenceper/wechat/v2 v2.0.5
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
diff --git a/go.sum b/go.sum
index edaf54e..fdf8ced 100644
--- a/go.sum
+++ b/go.sum
@@ -24,14 +24,17 @@ github.com/aws/aws-sdk-go-v2/credentials v1.1.6 h1:efaeh6FsO/jzyJ+U4ZxduKC6rRJDr
github.com/aws/aws-sdk-go-v2/credentials v1.1.6/go.mod h1:q1wQ5jHdFNhc4wnNcOEpnovs4keJA5Ds+qESCnfEsgU=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.0.6 h1:zoOz5V56jO/rGixsCDnrQtAzYRYM2hGA/43U6jVMFbo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.0.6/go.mod h1:0+fWMitrmIpENiY8/1DyhdYPUCAPvd9UNz9mtCsEoLQ=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.0.3/go.mod h1:C50Z41fJaJ7WgaeeCulOGAU3q4+4se4B3uOPFdhBi2I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.0.6 h1:ldYIsOP4WyjdzW8t6RC/aSieajrlx+3UN3UCZy1KM5Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.0.6/go.mod h1:L0KWr0ASo83PRZu9NaZaDsw3koS6PspKv137DMDZjHo=
github.com/aws/aws-sdk-go-v2/service/ses v1.2.2 h1:NVHy0deH7YK3yJc+xB1gA/wZeNXWVtL4zOTSdj5spfI=
github.com/aws/aws-sdk-go-v2/service/ses v1.2.2/go.mod h1:fdj/PsFS59GndzkUKAuWw7cLOjgLHn+V8V6otywinUk=
github.com/aws/aws-sdk-go-v2/service/sso v1.1.5 h1:B7ec5wE4+3Ldkurmq0C4gfQFtElGTG+/iTpi/YPMzi4=
github.com/aws/aws-sdk-go-v2/service/sso v1.1.5/go.mod h1:bpGz0tidC4y39sZkQSkpO/J0tzWCMXHbw6FZ0j1GkWM=
+github.com/aws/aws-sdk-go-v2/service/sts v1.1.2/go.mod h1:zu7rotIY9P4Aoc6ytqLP9jeYrECDHUODB5Gbp+BSHl8=
github.com/aws/aws-sdk-go-v2/service/sts v1.3.0 h1:4o69U9waE25xhRbsnXa4jjQac03BFJcNfcZkSedk3e4=
github.com/aws/aws-sdk-go-v2/service/sts v1.3.0/go.mod h1:ssRzzJ2RZOVuKj2Vx1YE7ypfil/BIlgmQnCSW4DistU=
+github.com/aws/smithy-go v1.2.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/aws/smithy-go v1.3.1 h1:xJFO4pK0y9J8fCl34uGsSJX5KNnGbdARDlA5BPhXnwE=
github.com/aws/smithy-go v1.3.1/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0=
@@ -165,8 +168,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/sendgrid/rest v2.6.3+incompatible h1:h/uruXAzKxVyDDIQX/MkQI73p/gsdpEnb5q2wxSvTsA=
github.com/sendgrid/rest v2.6.3+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
-github.com/sendgrid/sendgrid-go v3.8.0+incompatible h1:7yoUFMwT+jDI2ArBpC6zvtuQj1RUyYfCDl7zZea3XV4=
-github.com/sendgrid/sendgrid-go v3.8.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
+github.com/sendgrid/sendgrid-go v3.9.0+incompatible h1:rs5T2u6XVNDN51Z8uynH+vgKLgCem+xfuL1OMcxsmBE=
+github.com/sendgrid/sendgrid-go v3.9.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/silenceper/wechat/v2 v2.0.5 h1:yBOeTMiMAzCAvmhR14ADMlrRrR3oOqmoz7I/bus7bTw=
github.com/silenceper/wechat/v2 v2.0.5/go.mod h1:hksYXWXGl7/E6TQojFNgxv8ouTF9CPPjfvWWJouJJGs=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
diff --git a/service/wechat/README.md b/service/wechat/README.md
new file mode 100644
index 0000000..0c285b5
--- /dev/null
+++ b/service/wechat/README.md
@@ -0,0 +1,84 @@
+# WeChat
+
+## Prerequisites
+
+To use the service you need to apply for a [WeChat Official Account](https://mp.weixin.qq.com).
+Application approval is a manual process and might take a while. In the meantime, you
+can test your code using the [sandbox](https://developers.weixin.qq.com/sandbox).
+
+You need the following configuration information to sent out text messages with an Official Account:
+
+1. AppID
+2. AppSecret
+3. Token
+4. EncodingAESKey
+5. Verification URL
+
+The `AppID` and `AppSecret` are provided to you by WeChat. The `Token`, `EncodingAESKey` and
+the Verifications URL, you set yourself and are needed by the authentication method. More on
+this [here](https://developers.weixin.qq.com/doc/offiaccount/en/Basic_Information/Access_Overview.html).
+
+## Getting Started
+
+Until your application is approved, sign in to the sandbox to get an `AppID`, an `AppSecret` and
+set the `Token` and the Verification URL. Typically, you need a service like [ngrok](https://ngrok.com/)
+for the latter. You don't need to/cannot set the `EncodingAESKey`, because it's not required in the
+sandbox environment:
+
+![wechat_sandbox_1.png](img/wechat_sandbox_1.png)
+
+You also need a user subscribed to your Official Account. You can use your own:
+
+![wechat_sandbox_2.png](img/wechat_sandbox_2.png)
+
+## Usage
+
+```go
+package main
+
+import (
+ "github.com/silenceper/wechat/v2/cache"
+ "log"
+ "context"
+ "fmt"
+ "net/http"
+ "github.com/nikoksr/notify"
+ "github.com/nikoksr/notify/service/wechat"
+)
+
+func main() {
+
+ wechatSvc := wechat.New(&wechat.Config{
+ AppID: "abcdefghi",
+ AppSecret: "jklmnopqr",
+ Token: "mytoken",
+ EncodingAESKey: "IGNORED-IN-SANDBOX",
+ Cache: cache.NewMemory(),
+ })
+
+ // do this only once, or when settings are updated
+ devMode := true
+ wechatSvc.WaitForOneOffVerification(":7999", devMode, func(r *http.Request, verified bool) {
+ if !verified {
+ fmt.Println("unknown or failed verification call")
+ } else {
+ fmt.Println("verification call done")
+ }
+ })
+
+ wechatSvc.AddReceivers("some-user-openid")
+
+ notifier := notify.New()
+ notifier.UseServices(wechatSvc)
+
+ err := notifier.Send(context.Background(), "subject", "message")
+ if err != nil {
+ log.Fatalf("notifier.Send() failed: %s", err.Error())
+ }
+
+ log.Println("notification sent")
+}
+```
+
+![wechat_usage.png](img/wechat_usage.png)
+
diff --git a/service/wechat/img/wechat_sandbox_1.png b/service/wechat/img/wechat_sandbox_1.png
new file mode 100644
index 0000000..b2b11fd
Binary files /dev/null and b/service/wechat/img/wechat_sandbox_1.png differ
diff --git a/service/wechat/img/wechat_sandbox_2.png b/service/wechat/img/wechat_sandbox_2.png
new file mode 100644
index 0000000..37206b7
Binary files /dev/null and b/service/wechat/img/wechat_sandbox_2.png differ
diff --git a/service/wechat/img/wechat_usage.png b/service/wechat/img/wechat_usage.png
new file mode 100644
index 0000000..20a96d8
Binary files /dev/null and b/service/wechat/img/wechat_usage.png differ
diff --git a/service/wechat/mock_wechatMessageManager.go b/service/wechat/mock_wechatMessageManager.go
new file mode 100644
index 0000000..975964d
--- /dev/null
+++ b/service/wechat/mock_wechatMessageManager.go
@@ -0,0 +1,27 @@
+// Code generated by mockery 2.7.4. DO NOT EDIT.
+
+package wechat
+
+import (
+ message "github.com/silenceper/wechat/v2/officialaccount/message"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// mockWechatMessageManager is an autogenerated mock type for the wechatMessageManager type
+type mockWechatMessageManager struct {
+ mock.Mock
+}
+
+// Send provides a mock function with given fields: msg
+func (_m *mockWechatMessageManager) Send(msg *message.CustomerMessage) error {
+ ret := _m.Called(msg)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(*message.CustomerMessage) error); ok {
+ r0 = rf(msg)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
diff --git a/service/wechat/wechat.go b/service/wechat/wechat.go
new file mode 100644
index 0000000..8009b91
--- /dev/null
+++ b/service/wechat/wechat.go
@@ -0,0 +1,145 @@
+package wechat
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "sync"
+
+ "github.com/pkg/errors"
+ "github.com/silenceper/wechat/v2"
+ "github.com/silenceper/wechat/v2/cache"
+ "github.com/silenceper/wechat/v2/officialaccount/config"
+ "github.com/silenceper/wechat/v2/officialaccount/message"
+ "github.com/silenceper/wechat/v2/util"
+)
+
+type verificationCallbackFunc func(r *http.Request, verified bool)
+
+// Config is the Service configuration.
+type Config struct {
+ AppID string
+ AppSecret string
+ Token string
+ EncodingAESKey string
+ Cache cache.Cache
+}
+
+// wechatMessageManager abstracts go-wechat's message.Manager for writing unit tests
+type wechatMessageManager interface {
+ Send(msg *message.CustomerMessage) error
+}
+
+// Service encapsulates the WeChat client along with internal state for storing users.
+type Service struct {
+ config *Config
+ messageManager wechatMessageManager
+ userIDs []string
+}
+
+// New returns a new instance of a WeChat notification service.
+func New(cfg *Config) *Service {
+ wc := wechat.NewWechat()
+ wcCfg := &config.Config{
+ AppID: cfg.AppID,
+ AppSecret: cfg.AppSecret,
+ Token: cfg.Token,
+ EncodingAESKey: cfg.EncodingAESKey,
+ Cache: cfg.Cache,
+ }
+
+ oa := wc.GetOfficialAccount(wcCfg)
+
+ return &Service{
+ config: cfg,
+ messageManager: oa.GetCustomerMessageManager(),
+ }
+}
+
+// WaitForOneOffVerification waits for the verification call from the WeChat backend.
+//
+// Should be running when (re-)applying settings in wechat configuration.
+//
+// Set devMode to true when using the sandbox.
+//
+// See https://developers.weixin.qq.com/doc/offiaccount/en/Basic_Information/Access_Overview.html
+func (s *Service) WaitForOneOffVerification(serverURL string, devMode bool, callback verificationCallbackFunc) error {
+ srv := &http.Server{Addr: serverURL}
+ verificationDone := &sync.WaitGroup{}
+ verificationDone.Add(1)
+
+ http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query()
+
+ echoStr := query.Get("echostr")
+ if devMode {
+ if callback != nil {
+ callback(r, true)
+ }
+ _, _ = w.Write([]byte(echoStr))
+ // verification done; dev mode
+ verificationDone.Done()
+ return
+ }
+
+ // perform signature check
+ timestamp := query.Get("timestamp")
+ nonce := query.Get("nonce")
+ suppliedSignature := query.Get("signature")
+ computedSignature := util.Signature(s.config.Token, timestamp, nonce)
+ if suppliedSignature == computedSignature {
+ if callback != nil {
+ callback(r, true)
+ }
+ _, _ = w.Write([]byte(echoStr))
+ // verification done; prod mode
+ verificationDone.Done()
+ return
+ }
+
+ // verification not done (keep waiting)
+ if callback != nil {
+ callback(r, false)
+ }
+ })
+
+ var err error
+ go func() {
+ if innerErr := srv.ListenAndServe(); innerErr != http.ErrServerClosed {
+ err = errors.Wrapf(innerErr, "failed to start verification listener '%s'", serverURL)
+ }
+ }()
+
+ // wait until verification is done and shutdown the server
+ verificationDone.Wait()
+
+ if innerErr := srv.Shutdown(context.TODO()); innerErr != nil {
+ err = errors.Wrap(innerErr, "failed to shutdown verification listerer")
+ }
+
+ return err
+}
+
+// AddReceivers takes user ids and adds them to the internal users list. The Send method will send
+// a given message to all those users.
+func (s *Service) AddReceivers(userIDs ...string) {
+ s.userIDs = append(s.userIDs, userIDs...)
+}
+
+// Send takes a message subject and a message content and sends them to all previously set users.
+func (s *Service) Send(ctx context.Context, subject, content string) error {
+ for _, userID := range s.userIDs {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ text := fmt.Sprintf("%s\n%s", subject, content)
+ err := s.messageManager.Send(message.NewCustomerTextMessage(userID, text))
+ if err != nil {
+ return errors.Wrapf(err, "failed to send message to WeChat user '%s'", userID)
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/service/wechat/wechat_test.go b/service/wechat/wechat_test.go
new file mode 100644
index 0000000..324153b
--- /dev/null
+++ b/service/wechat/wechat_test.go
@@ -0,0 +1,53 @@
+package wechat
+
+import (
+ "context"
+ "testing"
+
+ "github.com/pkg/errors"
+ "github.com/silenceper/wechat/v2/officialaccount/message"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAddReceivers(t *testing.T) {
+ assert := require.New(t)
+
+ svc := &Service{
+ userIDs: []string{},
+ }
+ userIDs := []string{"User1ID", "User2ID", "User3ID"}
+ svc.AddReceivers(userIDs...)
+
+ assert.Equal(svc.userIDs, userIDs)
+}
+
+func TestSend(t *testing.T) {
+ assert := require.New(t)
+
+ svc := &Service{
+ userIDs: []string{},
+ }
+
+ // test wechat message manager returning error
+ mockMsgManager := new(mockWechatMessageManager)
+ mockMsgManager.On("Send", message.NewCustomerTextMessage("UserID1", "subject\nmessage")).
+ Return(errors.New("some error"))
+ svc.messageManager = mockMsgManager
+ svc.AddReceivers("UserID1")
+ ctx := context.Background()
+ err := svc.Send(ctx, "subject", "message")
+ assert.NotNil(err)
+ mockMsgManager.AssertExpectations(t)
+
+ // test success and multiple receivers
+ mockMsgManager = new(mockWechatMessageManager)
+ mockMsgManager.On("Send", message.NewCustomerTextMessage("UserID1", "subject\nmessage")).
+ Return(nil)
+ mockMsgManager.On("Send", message.NewCustomerTextMessage("UserID2", "subject\nmessage")).
+ Return(nil)
+ svc.messageManager = mockMsgManager
+ svc.AddReceivers("UserID1", "UserID2")
+ err = svc.Send(ctx, "subject", "message")
+ assert.Nil(err)
+ mockMsgManager.AssertExpectations(t)
+}