Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(service): Add Meta Workplace #413

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions service/metaworkplace/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
## Meta Workplace Service

### Usage
* You must have a Meta Workplace access token to use this.
* You must be part of an existing thread/chat with a user to use this.
* If you are not in a thread/chat with a user already you must use AddUsers().
* Unless AddUsers() is called again subsequent calls to SendMessage() will send to the same thread/chat.

```go
package main

import (
"context"
"github.com/nikoksr/notify"
"github.com/nikoksr/notify/service/metaworkplace"
"log"
)

func main() {
// Create a new notifier
notifier := notify.New()

accesstoken := "your_access_token"

// Create a new Workplace service.
wps := metaworkplace.New(accesstoken)

wps.AddUsers("some_user_id")
wps.AddThreads("t_somethreadid", "t_somethreadid")

notifier.UseServices(wps)

err := notifier.Send(context.Background(), "", "our first message")
if err != nil {
log.Fatal(err)
}

err = notifier.Send(context.Background(), "", "our second message")
if err != nil {
log.Fatal(err)
}

wps.AddUsers("some_other_user_id")

err = notifier.Send(context.Background(), "", "our third message")
if err != nil {
log.Fatal(err)
}
}
```


#### Please consider the following:
* Meta Workplace service does not allow sending messages to group chats
* Meta Workplace service does not allow sending messages to group posts
* Meta Workplace service does not allow sending messages to users that are not already in a thread/chat with you
* Testing still needs to be added; the scenarios below will all fail. Those without a user/thread id should fail. Those
Comment on lines +53 to +57
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mrhillsman,

first of all thank you for your efforts and the contribution. Really stoked to get this service in.

On first glance everything seems very well made and basically ready to merge. However, on second check I do have some smaller things and questions.

  1. Could you clarify why the service does not support the things you've listed here? This would help with setting future goals and todos for this service.

  2. Are you planning to implement the missing tests? As you've probably noticed (since you've added the go:generate directive) you can use make mock to generate a mock of this service that should help with testing. We're trying to achieve a high standard in quality with Notify and tests are defenitely a foundation for that. It would be very much appreciated!

Also, please check the linter errors and warnings. I'd ask you to fix those too. You can use make lint and make fmt to get them out of the way.

Thanks again for your efforts! Please let me know if anything needs clarification.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @nikoksr,

The items listed as not being supported are limitations on the Meta Workplace API not that they couldn't be implemented. Unfortunately I was not able to find a way to get group chat/posts and you can send a message to a user that is not already in a chat/thread; I probably just typed that in error.

I hope I can get time to work on the tests. Work got a bit busy right after I committed to at least getting the service implementation done but I hope to write the tests as well.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mrhillsman,

I'm so sorry for coming back this late to you. I did not get notified about your reply and just saw it now by accident!

Thank you for clarifying that. That sounds acceptable to me then. The package design you came up with seems to allow for future expansion and addition of the currently missing features, so I'm okay with leaving them out for now.

Let me know if there's anything I can do to help you out with! Appreciate your efforts a lot.

with a valid user/thread id should succeed and return an error for the invalid user/thread id(s). Here are the scenarios:
* wps.AddUsers("", "123456789012345")
wps.AddUsers("", "123456789012345", "")
wps.AddUsers("")
* wps.AddThreads("", "123456789012345")
wps.AddThreads("", "123456789012345", "")
wps.AddThreads("")

## Contributors
- [Melvin Hillsman](github.com/mrhillsman)
- [Ainsley Clark](github.com/ainsleyclark)
171 changes: 171 additions & 0 deletions service/metaworkplace/metaworkplace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package metaworkplace

import (
"bytes"
"context"
"encoding/json"
"github.com/pkg/errors"
"io"
"log"
"net/http"
"time"
)

const (
// ENDPOINT is the base URL of the Workplace API to send messages.
ENDPOINT = "https://graph.facebook.com/me/messages"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ENDPOINT = "https://graph.facebook.com/me/messages"
endpoint = "https://graph.facebook.com/me/messages"

Let's follow the common naming conventions for constants in Go here.

)

// metaWorkplaceService is the internal implementation of the Meta Workplace notification service.
//
//go:generate mockery --name=metaWorkplaceService --output=. --case=underscore --inpackage
type metaWorkplaceService interface {
send(payload interface{}) *MetaWorkplaceResponse
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
send(payload interface{}) *MetaWorkplaceResponse
send(payload any) *MetaWorkplaceResponse

}

// ValidateConfig checks if the required configuration data is present.
func (sc *MetaWorkplaceServiceConfig) ValidateConfig() error {
if sc.AccessToken == "" {
return errors.New("a valid Meta Workplace access token is required")
}
return nil
}

// New returns a new instance of a Meta Workplace notification service.
func New(token string) *MetaWorkplaceService {
serviceConfig := MetaWorkplaceServiceConfig{
AccessToken: token,
Endpoint: ENDPOINT,
}

err := serviceConfig.ValidateConfig()
if err != nil {
log.Fatalf("failed to validate Meta Workplace service configuration: %v", err)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
log.Fatalf("failed to validate Meta Workplace service configuration: %v", err)
return nil, errors.Wrap(err, "validate config")

Following common practice of the Notify codebase here.

}

return &MetaWorkplaceService{
MetaWorkplaceServiceConfig: serviceConfig,
userIDs: []string{},
threadIDs: []string{},
client: &http.Client{
Timeout: 10 * time.Second,
},
Comment on lines +50 to +52
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a requirement but it would be nice tfor the HTTP client to be configurable by the user. A sane default like you chose plus a MetaWorkplaceService.WithHTTPClient(client *http.Client) method would be preferable. I do understand however, that your time is limited, so I don't see this as a hard requirement.

}
}

// AddThreads takes Workplace thread IDs and adds them to the internal thread ID list. The Send method will send
// a given message to all those threads.
func (mw *MetaWorkplaceService) AddThreads(threadIDs ...string) {
mw.threadIDs = append(mw.threadIDs, threadIDs...)
}

// AddUsers takes user IDs and adds them to the internal user ID list. The Send method will send
// a given message to all those users.
func (mw *MetaWorkplaceService) AddUsers(userIDs ...string) {
mw.userIDs = append(mw.userIDs, userIDs...)
}

// send takes a payload, sends it to the Workplace API, and returns the response.
func (mw *MetaWorkplaceService) send(payload interface{}) *MetaWorkplaceResponse {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func (mw *MetaWorkplaceService) send(payload interface{}) *MetaWorkplaceResponse {
func (mw *MetaWorkplaceService) send(payload any) *MetaWorkplaceResponse {

data, err := json.Marshal(payload)
if err != nil {
response := MetaWorkplaceResponse{
MessageID: "",
ThreadKey: "",
Error: &MetaWorkplaceErrorResponse{
Message: "failed to marshal payload",
},
}
return &response
Comment on lines +72 to +79
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
response := MetaWorkplaceResponse{
MessageID: "",
ThreadKey: "",
Error: &MetaWorkplaceErrorResponse{
Message: "failed to marshal payload",
},
}
return &response
return nil, errors.New("failed to marshal payload")

Not necessary to use the full MetaWorkplaceErrorResponse object here, a Go error is sufficient. This obviously requires the methods return values to be expanded to (*MetaWorkplaceResponse, error) too.

}

buff := bytes.NewBuffer(data)

req, err := http.NewRequest(http.MethodPost, mw.Endpoint, buff)
if err != nil {
log.Println("failed to create new HTTP request")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
log.Println("failed to create new HTTP request")
return nil, errors.Wrapf(err, "create POST request to %s", mw.Endpoint)

}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+mw.AccessToken)

res, err := mw.client.Do(req)
if err != nil {
log.Printf("failed to send HTTP request: %v", err)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
log.Printf("failed to send HTTP request: %v", err)
return nil, errors.Wrapf(err, "send POST request to %s", mw.Endpoint)

}

defer func(Body io.ReadCloser) {
err = Body.Close()
if err != nil {
log.Printf("failed to close response body: %v", err)
}
}(res.Body)
Comment on lines +97 to +102
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
defer func(Body io.ReadCloser) {
err = Body.Close()
if err != nil {
log.Printf("failed to close response body: %v", err)
}
}(res.Body)
defer func() {
cerr := res.Body.Close()
if cerr != nil {
if err != nil {
err = errors.Wrap(err, cerr.Error())
} else {
err = cerr
}
}
}()


data, err = io.ReadAll(res.Body)
if err != nil {
log.Printf("failed to read response body: %v", err)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
log.Printf("failed to read response body: %v", err)
return nil, errors.Wrap(err, "read response body")

}

var response MetaWorkplaceResponse

err = json.Unmarshal(data, &response)
if err != nil {
log.Printf("failed to unmarshal response body: %v", err)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
log.Printf("failed to unmarshal response body: %v", err)
return nil, errors.Wrap(err, "unmarshal response body")

}

return &response
}

// Send takes a message and sends it to the provided user and/or thread IDs.
func (mw *MetaWorkplaceService) Send(ctx context.Context, subject string, message string) error {
if len(mw.userIDs) == 0 && len(mw.threadIDs) == 0 {
return errors.New("no user or thread IDs provided")
}

if len(mw.threadIDs) != 0 {
for _, threadID := range mw.threadIDs {
select {
case <-ctx.Done():
return ctx.Err()
default:
payload := metaWorkplaceThreadPayload{
Message: metaWorkplaceMessage{Text: message},
Recipient: metaWorkplaceThread{ThreadID: threadID},
}
err := mw.send(payload)
if err.Error != nil {
log.Printf("%+v\n", err.Error)
return errors.New("failed to send message to Workplace thread: " + threadID)
}
}
}
}
Comment on lines +125 to +142
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if len(mw.threadIDs) != 0 {
for _, threadID := range mw.threadIDs {
select {
case <-ctx.Done():
return ctx.Err()
default:
payload := metaWorkplaceThreadPayload{
Message: metaWorkplaceMessage{Text: message},
Recipient: metaWorkplaceThread{ThreadID: threadID},
}
err := mw.send(payload)
if err.Error != nil {
log.Printf("%+v\n", err.Error)
return errors.New("failed to send message to Workplace thread: " + threadID)
}
}
}
}
for _, threadID := range mw.threadIDs {
select {
case <-ctx.Done():
return ctx.Err()
default:
payload := metaWorkplaceThreadPayload{
Message: metaWorkplaceMessage{Text: message},
Recipient: metaWorkplaceThread{ThreadID: threadID},
}
resp, err := mw.send(payload)
if err != nil {
return nil, errors.Wrapf(err, "send message to thread %q: %+v", threadID, resp)
}
if resp.Error != nil {
return nil, errors.Errorf("send message to thread %q: %+v", threadID, resp)
}
}
}

The length check here is obsolete here since an empty slice would result in a skipped loop execution body. Also, applying the error handling changes that would come implicitly with the expanded send method.


if len(mw.userIDs) != 0 {
for _, userID := range mw.userIDs {
select {
case <-ctx.Done():
return ctx.Err()
default:
payload := metaWorkplaceUserPayload{
Message: metaWorkplaceMessage{Text: message},
Recipient: metaWorkplaceUsers{UserIDs: []string{userID}},
}
err := mw.send(payload)
if err.Error != nil {
log.Printf("%+v\n", err.Error)
return errors.New("failed to send message to Workplace user: " + userID)
}

// Capture the thread ID for the user and add it to the thread ID list. Subsequent
// messages will be sent to the thread instead of creating a new thread for the user.
mw.threadIDs = append(mw.threadIDs, err.ThreadKey)
}
}
Comment on lines +144 to +164
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if len(mw.userIDs) != 0 {
for _, userID := range mw.userIDs {
select {
case <-ctx.Done():
return ctx.Err()
default:
payload := metaWorkplaceUserPayload{
Message: metaWorkplaceMessage{Text: message},
Recipient: metaWorkplaceUsers{UserIDs: []string{userID}},
}
err := mw.send(payload)
if err.Error != nil {
log.Printf("%+v\n", err.Error)
return errors.New("failed to send message to Workplace user: " + userID)
}
// Capture the thread ID for the user and add it to the thread ID list. Subsequent
// messages will be sent to the thread instead of creating a new thread for the user.
mw.threadIDs = append(mw.threadIDs, err.ThreadKey)
}
}
for _, userID := range mw.userIDs {
select {
case <-ctx.Done():
return ctx.Err()
default:
payload := metaWorkplaceUserPayload{
Message: metaWorkplaceMessage{Text: message},
Recipient: metaWorkplaceUsers{UserIDs: []string{userID}},
}
resp, err := mw.send(payload)
if err != nil {
return nil, errors.Wrapf(err, "send message to user %q: %+v", userID, resp)
}
if resp == nil {
continue // All following actions require a non-nil response
}
if resp.Error != nil {
return nil, errors.Errorf("send message to user %q: %+v", userID, resp)
}
// Capture the thread ID for the user and add it to the thread ID list. Subsequent
// messages will be sent to the thread instead of creating a new thread for the user.
mw.threadIDs = append(mw.threadIDs, resp.ThreadKey)
}
}


// Clear the user ID list. Subsequent messages will be sent to the thread instead of creating a new thread for the user.
mw.userIDs = []string{}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mw.userIDs = []string{}
mw.userIDs = make([]string, 0)

Let's also free up the allocated memory here.

}

return nil
}
1 change: 1 addition & 0 deletions service/metaworkplace/metaworkplace_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package metaworkplace
69 changes: 69 additions & 0 deletions service/metaworkplace/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package metaworkplace

import (
"net/http"
)

type (
// MetaWorkplaceErrorResponse is a custom error type for the Meta Workplace service. This struct should be filled when
// a status code other than 200 is received from the Meta Workplace API.
MetaWorkplaceErrorResponse struct {
Message string `json:"message"`
Type string `json:"type"`
Code int `json:"code"`
ErrorSubcode int `json:"error_subcode"`
FbtraceID string `json:"fbtrace_id"`
}

// MetaWorkplaceResponse is a custom response type for the Meta Workplace service. Only the MessageID and
// ThreadKey fields should be filled when a 200 response is received from the Meta Workplace API.
MetaWorkplaceResponse struct {
MessageID string `json:"message_id"`
ThreadKey string `json:"thread_key"`
Error *MetaWorkplaceErrorResponse `json:"error"`
}

// MetaWorkplaceService struct holds necessary data to communicate with Meta Workplace users and/or threads.
MetaWorkplaceService struct {
MetaWorkplaceServiceConfig
userIDs []string
threadIDs []string
client *http.Client
}

// MetaWorkplaceServiceConfig holds the required configuration data when creating a connection to the Meta
// Workplace API.
MetaWorkplaceServiceConfig struct {
AccessToken string
Endpoint string
}

// metaWorkplaceUserPayload is a custom payload type for the Meta Workplace service. This struct should be filled
// when sending a message to a user.
metaWorkplaceUserPayload struct {
Message metaWorkplaceMessage `json:"message"`
Recipient metaWorkplaceUsers `json:"recipient"`
}

// metaWorkplaceUserPayload is a custom payload type for the Meta Workplace service. This struct should be filled
// when sending a message to a thread.
metaWorkplaceThreadPayload struct {
Message metaWorkplaceMessage `json:"message"`
Recipient metaWorkplaceThread `json:"recipient"`
}

// metaWorkplaceUsers holds the user IDs to send a message to.
metaWorkplaceUsers struct {
UserIDs []string `json:"ids"`
}

// metaWorkplaceThread holds the thread ID to send a message to.
metaWorkplaceThread struct {
ThreadID string `json:"thread_key"`
}

// metaWorkplaceMessage holds the message to send.
metaWorkplaceMessage struct {
Text string `json:"text"`
}
)