Skip to content

Commit

Permalink
add thinq handler
Browse files Browse the repository at this point in the history
  • Loading branch information
nicpottier committed Oct 7, 2019
1 parent 707405b commit 2aec0d6
Show file tree
Hide file tree
Showing 4 changed files with 305 additions and 0 deletions.
1 change: 1 addition & 0 deletions cmd/courier/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import (
_ "github.com/nyaruka/courier/handlers/shaqodoon"
_ "github.com/nyaruka/courier/handlers/smscentral"
_ "github.com/nyaruka/courier/handlers/start"
_ "github.com/nyaruka/courier/handlers/thinq"
_ "github.com/nyaruka/courier/handlers/telegram"
_ "github.com/nyaruka/courier/handlers/twiml"
_ "github.com/nyaruka/courier/handlers/twitter"
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ require (
gopkg.in/h2non/filetype.v1 v1.0.5
gopkg.in/ini.v1 v1.41.0 // indirect
)

go 1.13
215 changes: 215 additions & 0 deletions handlers/thinq/thinq.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package thinq

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"

"github.com/buger/jsonparser"
"github.com/nyaruka/courier"
"github.com/nyaruka/courier/handlers"
"github.com/nyaruka/courier/utils"
)

const configAccountID = "account_id"
const configAPITokenUser = "api_token_user"
const configAPIToken = "api_token"
const maxMsgLength = 1600

var sendURL = "https://api.thinq.com/account/%s/product/origination/sms/send"
var sendMMSURL = "https://api.thinq.com/account/%s/product/origination/mms/send"

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

type handler struct {
handlers.BaseHandler
}

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

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

// from: Source DID
// to: Destination DID
// type: sms|mms
// message: Content of the message
type moForm struct {
From string `validate:"required" name:"from"`
To string `validate:"required" name:"to"`
Type string `validate:"required" name:"type"`
Message string `name:"message"`
}

// 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) {
// get our params
form := &moForm{}
err := handlers.DecodeAndValidateForm(form, r)
if err != nil {
return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err)
}

// create our URN
urn, err := handlers.StrictTelForCountry(form.From, channel.Country())
if err != nil {
return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err)
}

var msg courier.Msg
if form.Type == "sms" {
msg = h.Backend().NewIncomingMsg(channel, urn, form.Message)
} else if form.Type == "mms" {
msg = h.Backend().NewIncomingMsg(channel, urn, "").WithAttachment(form.Message)
} else {
return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("unknown message type: %s", form.Type))
}
return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r)
}

// guid: thinQ guid returned when an outbound message is sent via our API
// account_id: Your thinQ account ID
// source_did: Source DID
// destination_did: Destination DID
// timestamp: Time the delivery notification was received
// send_status: User friendly version of the status (i.e.: delivered)
// status: System version of the status (i.e.: DELIVRD)
// error: Error code if any (i.e.: 000)
type statusForm struct {
GUID string `validate:"required" name:"guid"`
Status string `validate:"required" name:"status"`
}

var statusMapping = map[string]courier.MsgStatusValue{
"DELIVRD": courier.MsgDelivered,
"EXPIRED": courier.MsgErrored,
"DELETED": courier.MsgFailed,
"UNDELIV": courier.MsgFailed,
"UNKNOWN": courier.MsgFailed,
"REJECTD": courier.MsgFailed,
}

// receiveStatus is our HTTP handler function for status updates
func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request) ([]courier.Event, error) {
// get our params
form := &statusForm{}
err := handlers.DecodeAndValidateForm(form, r)
if err != nil {
return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err)
}

msgStatus, found := statusMapping[form.Status]
if !found {
return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r,
fmt.Errorf("unknown status: '%s'", form.Status))
}

// write our status
status := h.Backend().NewMsgStatusForExternalID(channel, form.GUID, msgStatus)
return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r)
}

type mtMessage struct {
FromDID string `json:"from_did"`
ToDID string `json:"to_did"`
Message string `json:"message"`
}

// SendMsg sends the passed in message, returning any error
func (h *handler) SendMsg(_ context.Context, msg courier.Msg) (courier.MsgStatus, error) {
accountID := msg.Channel().StringConfigForKey(configAccountID, "")
if accountID == "" {
return nil, fmt.Errorf("no account id set for TQ channel")
}

tokenUser := msg.Channel().StringConfigForKey(configAPITokenUser, "")
if tokenUser == "" {
return nil, fmt.Errorf("no token user set for TQ channel")
}

token := msg.Channel().StringConfigForKey(configAPIToken, "")
if token == "" {
return nil, fmt.Errorf("no token set for TQ channel")
}

status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored)

// we send attachments first so that text appears below
for _, a := range msg.Attachments() {
_, u := handlers.SplitAttachment(a)

form := url.Values{
"from_did": []string{strings.TrimLeft(msg.Channel().Address(), "+")[1:]},
"to_did": []string{strings.TrimLeft(msg.URN().Path(), "+")[1:]},
"media_url": []string{u},
}

req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf(sendMMSURL, accountID), strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "multipart/form-data")
req.Header.Set("Accept", "application/json")
req.SetBasicAuth(tokenUser, token)
rr, err := utils.MakeHTTPRequest(req)

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

// try to get our external id
_, err = jsonparser.GetString([]byte(rr.Body), "guid")
if err != nil {
log.WithError("Unable to read external ID", err)
return status, nil
}
}

// now send our text
parts := handlers.SplitMsg(msg.Text(), maxMsgLength)
for _, part := range parts {
body := mtMessage{
FromDID: strings.TrimLeft(msg.Channel().Address(), "+")[1:],
ToDID: strings.TrimLeft(msg.URN().Path(), "+")[1:],
Message: part,
}
bodyJSON, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf(sendURL, accountID), bytes.NewBuffer(bodyJSON))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.SetBasicAuth(tokenUser, token)
rr, err := utils.MakeHTTPRequest(req)

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

// get our external id
externalID, err := jsonparser.GetString([]byte(rr.Body), "guid")
if err != nil {
log.WithError("Unable to read external ID from guid field", err)
return status, nil
}

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

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

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

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

var testChannels = []courier.Channel{
courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TQ", "+12065551212", "US", nil),
}

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

receiveValid = "message=hello+world&from=2065551234&type=sms&to=2065551212"
receiveMedia = "message=http://foo.bar/foo.png&hello+world&from=2065551234&type=mms&to=2065551212"

statusURL = "/c/tq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status/"
statusValid = "guid=1234&status=DELIVRD"
statusInvalid = "guid=1234&status=UN"
missingGUID = "status=DELIVRD"
)

var testCases = []ChannelHandleTestCase{
{Label: "Receive Valid", URL: receiveURL, Data: receiveValid, Status: 200, Response: "Accepted",
Text: Sp("hello world"), URN: Sp("tel:+12065551234")},
{Label: "Receive No Params", URL: receiveURL, Data: " ", Status: 400, Response: `'From' failed on the 'required'`},
{Label: "Receive Media", URL: receiveURL, Data: receiveMedia, Status: 200, Response: "Accepted",
URN: Sp("tel:+12065551234"), Attachments: []string{"http://foo.bar/foo.png"}},

{Label: "Status Valid", URL: statusURL, Data: statusValid, Status: 200,
ExternalID: Sp("1234"), Response: `"status":"D"`},
{Label: "Status Invalid", URL: statusURL, Data: statusInvalid, Status: 400,
ExternalID: Sp("1234"), Response: `"unknown status: 'UN'"`},
{Label: "Status Missing GUID", URL: statusURL, Data: missingGUID, Status: 400,
ExternalID: Sp("1234"), Response: `'GUID' failed on the 'required' tag`},
}

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

func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) {
sendURL = s.URL + "?account_id=%s"
sendMMSURL = s.URL + "?account_id=%s"
}

var sendTestCases = []ChannelSendTestCase{
{Label: "Plain Send",
Text: "Simple Message ☺", URN: "tel:+12067791234",
Status: "W", ExternalID: "1002",
ResponseBody: `{ "guid": "1002" }`, ResponseStatus: 200,
Headers: map[string]string{"Authorization": "Basic dXNlcjE6c2VzYW1l"},
RequestBody: `{"from_did":"2065551212","to_did":"2067791234","message":"Simple Message ☺"}`,
SendPrep: setSendURL},
{Label: "Send Attachment",
Text: "My pic!", URN: "tel:+12067791234", Attachments: []string{"image/jpeg:https://foo.bar/image.jpg"},
Status: "W", ExternalID: "1002",
ResponseBody: `{ "guid": "1002" }`, ResponseStatus: 200,
RequestBody: `{"from_did":"2065551212","to_did":"2067791234","message":"My pic!"}`,
SendPrep: setSendURL},
{Label: "No External ID",
Text: "No External ID", URN: "tel:+12067791234",
Status: "E",
ResponseBody: `{}`, ResponseStatus: 200,
RequestBody: `{"from_did":"2065551212","to_did":"2067791234","message":"No External ID"}`,
SendPrep: setSendURL},
{Label: "Error Sending",
Text: "Error Message", URN: "tel:+12067791234",
Status: "E",
ResponseBody: `{ "error": "failed" }`, ResponseStatus: 401,
RequestBody: `{"from_did":"2065551212","to_did":"2067791234","message":"Error Message"}`,
SendPrep: setSendURL},
}

func TestSending(t *testing.T) {
var channel = courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TQ", "+12065551212", "US",
map[string]interface{}{
configAccountID: "1234",
configAPITokenUser: "user1",
configAPIToken: "sesame",
})
RunChannelSendTestCases(t, channel, newHandler(), sendTestCases, nil)
}

0 comments on commit 2aec0d6

Please sign in to comment.