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) +}