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

Add support for MB receive handlers and status report handlers #122

Merged
merged 4 commits into from
Feb 22, 2018
Merged
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
174 changes: 173 additions & 1 deletion handlers/mblox/mblox.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,175 @@
package mblox

/* no logs */
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/buger/jsonparser"
"net/http"
"strings"
"time"

"github.com/nyaruka/courier"
"github.com/nyaruka/courier/handlers"
"github.com/nyaruka/courier/utils"
"github.com/nyaruka/gocommon/urns"
)

var (
mbloxAPIURL = "https://api.mblox.com/xms/v1"
maxMsgLength = 459
)

func init() {
courier.RegisterHandler(newHandler())
}

type handler struct {
handlers.BaseHandler
}

func newHandler() courier.ChannelHandler {
return &handler{handlers.NewBaseHandler(courier.ChannelType("MB"), "Mblox")}
}

// Initialize is called by the engine once everything is loaded
func (h *handler) Initialize(s courier.Server) error {
h.SetServer(s)
return s.AddHandlerRoute(h, http.MethodPost, "receive", h.ReceiveMessage)
}

type moPayload struct {
Type string `json:"type"`
BatchID string `json:"batch_id"`
Status string `json:"status"`
ID string `json:"id"`
From string `json:"from"`
To string `json:"to"`
Body string `json:"body"`
ReceivedAt string `json:"received_at"`
}

var statusMapping = map[string]courier.MsgStatusValue{
"Delivered": courier.MsgDelivered,
"Dispatched": courier.MsgSent,
"Aborted": courier.MsgFailed,
"Rejected": courier.MsgFailed,
"Failed": courier.MsgFailed,
"Expired": courier.MsgFailed,
}

// ReceiveMessage is our HTTP handler function for incoming messages
func (h *handler) ReceiveMessage(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request) ([]courier.Event, error) {
payload := &moPayload{}
err := handlers.DecodeAndValidateJSON(payload, r)
if err != nil {
return nil, courier.WriteAndLogRequestError(ctx, w, r, channel, err)
}

if payload.Type == "recipient_delivery_report_sms" {
if payload.BatchID == "" || payload.Status == "" {
return nil, courier.WriteAndLogRequestError(ctx, w, r, channel, fmt.Errorf("missing one of 'batch_id' or 'status' in request body"))
}

msgStatus, found := statusMapping[payload.Status]
if !found {
return nil, fmt.Errorf(`unknown status '%s', must be one of 'Delivered', 'Dispatched', 'Aborted', 'Rejected', 'Failed' or 'Expired'`, payload.Status)
}

// write our status
status := h.Backend().NewMsgStatusForExternalID(channel, payload.BatchID, msgStatus)
err = h.Backend().WriteMsgStatus(ctx, status)
if err == courier.ErrMsgNotFound {
return nil, courier.WriteAndLogStatusMsgNotFound(ctx, w, r, channel)
}

if err != nil {
return nil, err
}

return []courier.Event{status}, courier.WriteStatusSuccess(ctx, w, r, []courier.MsgStatus{status})

} else if payload.Type == "mo_text" {
if payload.ID == "" || payload.From == "" || payload.To == "" || payload.Body == "" || payload.ReceivedAt == "" {
return nil, courier.WriteAndLogRequestError(ctx, w, r, channel, fmt.Errorf("missing one of 'id', 'from', 'to', 'body' or 'received_at' in request body"))
}

date, err := time.Parse("2006-01-02T15:04:05.000Z", payload.ReceivedAt)
if err != nil {
return nil, courier.WriteAndLogRequestError(ctx, w, r, channel, err)
}

// create our URN
urn := urns.NewTelURNForCountry(payload.From, channel.Country())

// build our Message
msg := h.Backend().NewIncomingMsg(channel, urn, payload.Body).WithReceivedOn(date.UTC()).WithExternalID(payload.ID)

// and write it
err = h.Backend().WriteMsg(ctx, msg)
if err != nil {
return nil, err
}
return []courier.Event{msg}, courier.WriteMsgSuccess(ctx, w, r, []courier.Msg{msg})

}

return nil, courier.WriteAndLogRequestError(ctx, w, r, channel, fmt.Errorf("not handled, unknown type: %s", payload.Type))
}

type mtPayload struct {
From string `json:"from"`
To []string `json:"to"`
Body string `json:"body"`
DeliveryReport string `json:"delivery_report"`
}

// SendMsg sends the passed in message, returning any error
func (h *handler) SendMsg(ctx context.Context, msg courier.Msg) (courier.MsgStatus, error) {
username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "")
password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "")
if username == "" || password == "" {
return nil, fmt.Errorf("Missing username or password for MB channel")
}

status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored)
parts := handlers.SplitMsg(handlers.GetTextAndAttachments(msg), maxMsgLength)
for _, part := range parts {
payload := &mtPayload{}
payload.From = strings.TrimPrefix(msg.Channel().Address(), "+")
payload.To = []string{strings.TrimPrefix(msg.URN().Path(), "+")}
payload.Body = part
payload.DeliveryReport = "per_recipient"

requestBody := &bytes.Buffer{}
json.NewEncoder(requestBody).Encode(payload)

// build our request
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/%s/batches", mbloxAPIURL, username), requestBody)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", password))

if err != nil {
return nil, err
}

rr, err := utils.MakeHTTPRequest(req)
log := courier.NewChannelLogFromRR("Message Sent", msg.Channel(), msg.ID(), rr).WithError("Message Send Error", err)
status.AddLog(log)
if err != nil {
return status, nil
}

externalID, err := jsonparser.GetString([]byte(rr.Body), "id")
if err != nil {
return status, fmt.Errorf("unable to parse response body from MBlox")
}

status.SetStatus(courier.MsgWired)
status.SetExternalID(externalID)
}

return status, nil
}
156 changes: 156 additions & 0 deletions handlers/mblox/mblox_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package mblox

import (
"net/http/httptest"
"testing"
"time"

"github.com/nyaruka/courier"
. "github.com/nyaruka/courier/handlers"
)

var testChannels = []courier.Channel{
courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MB", "2020", "BR", map[string]interface{}{"username": "zv-username", "password": "zv-password"}),
}

var (
receiveURL = "/c/mb/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/"

validReceive = `{
"id": "OzQ5UqIOdoY8",
"from": "12067799294",
"to": "18444651185",
"body": "Hello World",
"type": "mo_text",
"received_at": "2016-03-30T19:33:06.643Z"
}`

missingParamsRecieve = `{
"id": "OzQ5UqIOdoY8",
"to": "18444651185",
"body": "Hello World",
"type": "mo_text",
"received_at": "2016-03-30T19:33:06.643Z"
}`

validStatus = `{
"batch_id": "12345",
"status": "Delivered",
"type": "recipient_delivery_report_sms"
}`

unknownStatus = `{
"batch_id": "12345",
"status": "INVALID",
"type": "recipient_delivery_report_sms"
}`

missingBatchID = `{
"status": "Delivered",
"type": "recipient_delivery_report_sms"
}`
)

var testCases = []ChannelHandleTestCase{
{Label: "Receive Valid", URL: receiveURL, Data: validReceive, Status: 200, Response: "Message Accepted",
Text: Sp("Hello World"), URN: Sp("tel:+12067799294"), Date: Tp(time.Date(2016, 3, 30, 19, 33, 06, 643000000, time.UTC)),
ExternalID: Sp("OzQ5UqIOdoY8")},
{Label: "Receive Valid", URL: receiveURL, Data: missingParamsRecieve, Status: 400, Response: "missing one of 'id', 'from', 'to', 'body' or 'received_at' in request body"},

{Label: "Status Valid", URL: receiveURL, Data: validStatus, Status: 200, Response: `"status":"D"`},
{Label: "Status Unknown", URL: receiveURL, Data: unknownStatus, Status: 400, Response: `unknown status 'INVALID'`},
{Label: "Status Missing Batch ID", URL: receiveURL, Data: missingBatchID, Status: 400, Response: "missing one of 'batch_id' or 'status' in request body"},
}

func TestHandler(t *testing.T) {
RunChannelTestCases(t, testChannels, newHandler(), testCases)
}

func BenchmarkHandler(b *testing.B) {
RunChannelBenchmarks(b, testChannels, newHandler(), testCases)
}

// setSendURL takes care of setting the send_url to our test server host
func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) {
mbloxAPIURL = s.URL
}

var defaultSendTestCases = []ChannelSendTestCase{
{Label: "Plain Send",
Text: "Simple Message ☺",
URN: "tel:+250788383383",
Status: "W",
ExternalID: "",
ResponseBody: `{ "id":"OzYDlvf3SQVc" }`,
ResponseStatus: 200,
Headers: map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": "Bearer Password",
},
RequestBody: `{"from":"2020","to":["250788383383"],"body":"Simple Message ☺","delivery_report":"per_recipient"}`,
SendPrep: setSendURL},
{Label: "Long Send",
Text: "This is a longer message than 160 characters and will cause us to split it into two separate parts, isn't that right but it is even longer than before I say, I need to keep adding more things to make it work",
URN: "tel:+250788383383",
Status: "W",
ExternalID: "",
ResponseBody: `{ "id":"OzYDlvf3SQVc" }`,
ResponseStatus: 200,
Headers: map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": "Bearer Password",
},
RequestBody: `{"from":"2020","to":["250788383383"],"body":"I need to keep adding more things to make it work","delivery_report":"per_recipient"}`,
SendPrep: setSendURL},
{Label: "Send Attachment",
Text: "My pic!",
URN: "tel:+250788383383",
Attachments: []string{"image/jpeg:https://foo.bar/image.jpg"},
Status: "W",
ExternalID: "",
ResponseBody: `{ "id":"OzYDlvf3SQVc" }`,
ResponseStatus: 200,
Headers: map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": "Bearer Password",
},
RequestBody: `{"from":"2020","to":["250788383383"],"body":"My pic!\nhttps://foo.bar/image.jpg","delivery_report":"per_recipient"}`,
SendPrep: setSendURL},
{Label: "No External Id",
Text: "No External ID",
URN: "tel:+250788383383",
Status: "E",
ResponseBody: `{ "missing":"OzYDlvf3SQVc" }`,
ResponseStatus: 200,
Error: "unable to parse response body from MBlox",
Headers: map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": "Bearer Password",
},
RequestBody: `{"from":"2020","to":["250788383383"],"body":"No External ID","delivery_report":"per_recipient"}`,
SendPrep: setSendURL},
{Label: "Error Sending",
Text: "Error Message",
URN: "tel:+250788383383",
Status: "E",
ResponseBody: `{ "error": "failed" }`,
ResponseStatus: 401,
RequestBody: `{"from":"2020","to":["250788383383"],"body":"Error Message","delivery_report":"per_recipient"}`,
SendPrep: setSendURL},
}

func TestSending(t *testing.T) {
maxMsgLength = 160
var defaultChannel = courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MB", "2020", "US",
map[string]interface{}{
"password": "Password",
"username": "Username",
},
)

RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, nil)
}