diff --git a/handlers/shaqodoon/shaqodoon.go b/handlers/shaqodoon/shaqodoon.go index 892d51bf0..3ae0a3863 100644 --- a/handlers/shaqodoon/shaqodoon.go +++ b/handlers/shaqodoon/shaqodoon.go @@ -1,6 +1,210 @@ -package shaqdoon +package shaqodoon + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/nyaruka/courier" + "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/utils" + "github.com/pkg/errors" +) /* POST /api/v1/shaqodoon/received/uuid/ from=252634101111&text=Msg */ + +func init() { + courier.RegisterHandler(NewHandler()) +} + +type handler struct { + handlers.BaseHandler +} + +// NewHandler returns a new External handler +func NewHandler() courier.ChannelHandler { + return &handler{handlers.NewBaseHandler(courier.ChannelType("SQ"), "External")} +} + +// Initialize is called by the engine once everything is loaded +func (h *handler) Initialize(s courier.Server) error { + h.SetServer(s) + s.AddReceiveMsgRoute(h, "POST", "receive", h.ReceiveMessage) + s.AddReceiveMsgRoute(h, "GET", "receive", h.ReceiveMessage) + + sentHandler := h.buildStatusHandler("sent") + s.AddUpdateStatusRoute(h, "GET", "sent", sentHandler) + s.AddUpdateStatusRoute(h, "POST", "sent", sentHandler) + + deliveredHandler := h.buildStatusHandler("delivered") + s.AddUpdateStatusRoute(h, "GET", "delivered", deliveredHandler) + s.AddUpdateStatusRoute(h, "POST", "delivered", deliveredHandler) + + failedHandler := h.buildStatusHandler("failed") + s.AddUpdateStatusRoute(h, "GET", "failed", failedHandler) + s.AddUpdateStatusRoute(h, "POST", "failed", failedHandler) + + return nil +} + +// ReceiveMessage is our HTTP handler function for incoming messages +func (h *handler) ReceiveMessage(channel courier.Channel, w http.ResponseWriter, r *http.Request) ([]courier.Msg, error) { + shaqodoonMessage := &shaqodoonMessage{} + handlers.DecodeAndValidateQueryParams(shaqodoonMessage, r) + + // if this is a post, also try to parse the form body + if r.Method == http.MethodPost { + handlers.DecodeAndValidateForm(shaqodoonMessage, r) + } + + // validate whether our required fields are present + err := handlers.Validate(shaqodoonMessage) + if err != nil { + return nil, err + } + + // must have one of from or sender set, error if neither + sender := shaqodoonMessage.Sender + if sender == "" { + sender = shaqodoonMessage.From + } + if sender == "" { + return nil, errors.New("must have one of 'sender' or 'from' set") + } + + // if we have a date, parse it + dateString := shaqodoonMessage.Date + if dateString == "" { + dateString = shaqodoonMessage.Time + } + + date := time.Now() + if dateString != "" { + date, err = time.Parse(time.RFC3339Nano, dateString) + if err != nil { + return nil, errors.New("invalid date format, must be RFC 3339") + } + } + + // create our URN + urn, err := courier.NewURNFromParts(channel.Scheme(), sender) + if err != nil { + return nil, err + } + + // build our msg + msg := h.Backend().NewIncomingMsg(channel, urn, shaqodoonMessage.Text).WithReceivedOn(date) + + // and write it + err = h.Backend().WriteMsg(msg) + if err != nil { + return nil, err + } + + return []courier.Msg{msg}, courier.WriteReceiveSuccess(w, r, msg) +} + +type shaqodoonMessage struct { + From string `name:"from"` + Sender string `name:"sender"` + Text string `validate:"required" name:"text"` + Date string `name:"date"` + Time string `name:"time"` +} + +// buildStatusHandler deals with building a handler that takes what status is received in the URL +func (h *handler) buildStatusHandler(status string) courier.ChannelUpdateStatusFunc { + return func(channel courier.Channel, w http.ResponseWriter, r *http.Request) ([]courier.MsgStatus, error) { + return h.StatusMessage(status, channel, w, r) + } +} + +// StatusMessage is our HTTP handler function for status updates +func (h *handler) StatusMessage(statusString string, channel courier.Channel, w http.ResponseWriter, r *http.Request) ([]courier.MsgStatus, error) { + statusForm := &statusForm{} + handlers.DecodeAndValidateQueryParams(statusForm, r) + + // if this is a post, also try to parse the form body + if r.Method == http.MethodPost { + handlers.DecodeAndValidateForm(statusForm, r) + } + + // validate whether our required fields are present + err := handlers.Validate(statusForm) + if err != nil { + return nil, err + } + + // get our id + msgStatus, found := statusMappings[strings.ToLower(statusString)] + if !found { + return nil, fmt.Errorf("unknown status '%s', must be one failed, sent or delivered", statusString) + } + + // write our status + status := h.Backend().NewMsgStatusForID(channel, courier.NewMsgID(statusForm.ID), msgStatus) + err = h.Backend().WriteMsgStatus(status) + if err != nil { + return nil, err + } + + return []courier.MsgStatus{status}, courier.WriteStatusSuccess(w, r, status) +} + +type statusForm struct { + ID int64 `name:"id" validate:"required"` +} + +var statusMappings = map[string]courier.MsgStatusValue{ + "failed": courier.MsgFailed, + "sent": courier.MsgSent, + "delivered": courier.MsgDelivered, +} + +// SendMsg sends the passed in message, returning any error +func (h *handler) SendMsg(msg courier.Msg) (courier.MsgStatus, error) { + sendURL := msg.Channel().StringConfigForKey(courier.ConfigSendURL, "") + if sendURL == "" { + return nil, fmt.Errorf("no send url set for SQ channel") + } + + username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") + if username == "" { + return nil, fmt.Errorf("no username set for SQ channel") + } + + password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "") + if password == "" { + return nil, fmt.Errorf("no password set for SQ channel") + } + + // build our request + form := url.Values{ + "from": []string{strings.TrimPrefix(msg.Channel().Address(), "+")}, + "msg": []string{courier.GetTextAndAttachments(msg)}, + "to": []string{strings.TrimPrefix(msg.URN().Path(), "+")}, + "username": []string{username}, + "password": []string{password}, + } + + encodedForm := form.Encode() + sendURL = fmt.Sprintf("%s?%s", sendURL, encodedForm) + + req, err := http.NewRequest(http.MethodGet, sendURL, nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr, err := utils.MakeHTTPRequest(req) + + status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored) + status.AddLog(courier.NewChannelLogFromRR(msg.Channel(), msg.ID(), rr)) + if err != nil { + return status, err + } + + status.SetStatus(courier.MsgWired) + return status, nil +} diff --git a/handlers/shaqodoon/shaqodoon_test.go b/handlers/shaqodoon/shaqodoon_test.go new file mode 100644 index 000000000..9bdd248c9 --- /dev/null +++ b/handlers/shaqodoon/shaqodoon_test.go @@ -0,0 +1,100 @@ +package shaqodoon + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/nyaruka/courier" + . "github.com/nyaruka/courier/handlers" +) + +var ( + receiveValidMessage = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?sender=%2B2349067554729&text=Join" + receiveValidMessageFrom = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?from=%2B2349067554729&text=Join" + receiveValidMessageWithDate = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?sender=%2B2349067554729&text=Join&date=2017-06-23T12:30:00.500Z" + receiveValidMessageWithTime = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?sender=%2B2349067554729&text=Join&time=2017-06-23T12:30:00Z" + receiveNoParams = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" + receiveNoSender = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?text=Join" + receiveInvalidDate = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?sender=%2B2349067554729&text=Join&time=20170623T123000Z" + failedNoParams = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/failed/" + failedValid = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/failed/?id=12345" + sentValid = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/sent/?id=12345" + invalidStatus = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/wired/" + deliveredValid = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/delivered/?id=12345" + deliveredValidPost = "/c/sq/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/delivered/" +) + +var testChannels = []courier.Channel{ + courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SQ", "2020", "US", nil), +} + +var handleTestCases = []ChannelHandleTestCase{ + {Label: "Receive Valid Message", URL: receiveValidMessage, Data: "empty", Status: 200, Response: "Accepted", + Text: Sp("Join"), URN: Sp("tel:+2349067554729")}, + {Label: "Receive Valid From", URL: receiveValidMessageFrom, Data: "empty", Status: 200, Response: "Accepted", + Text: Sp("Join"), URN: Sp("tel:+2349067554729")}, + {Label: "Receive Valid Message With Date", URL: receiveValidMessageWithDate, Data: "empty", Status: 200, Response: "Accepted", + Text: Sp("Join"), URN: Sp("tel:+2349067554729"), Date: Tp(time.Date(2017, 6, 23, 12, 30, 0, int(500*time.Millisecond), time.UTC))}, + {Label: "Receive Valid Message With Time", URL: receiveValidMessageWithTime, Data: "empty", Status: 200, Response: "Accepted", + Text: Sp("Join"), URN: Sp("tel:+2349067554729"), Date: Tp(time.Date(2017, 6, 23, 12, 30, 0, 0, time.UTC))}, + {Label: "Receive No Params", URL: receiveNoParams, Data: "empty", Status: 400, Response: "field 'text' required"}, + {Label: "Receive No Sender", URL: receiveNoSender, Data: "empty", Status: 400, Response: "must have one of 'sender' or 'from' set"}, + {Label: "Receive Invalid Date", URL: receiveInvalidDate, Data: "empty", Status: 400, Response: "invalid date format, must be RFC 3339"}, + {Label: "Failed No Params", URL: failedNoParams, Status: 400, Response: "field 'id' required"}, + {Label: "Failed Valid", URL: failedValid, Status: 200, Response: `{"status":"F"}`}, + {Label: "Invalid Status", URL: invalidStatus, Status: 404, Response: `page not found`}, + {Label: "Sent Valid", URL: sentValid, Status: 200, Response: `{"status":"S"}`}, + {Label: "Delivered Valid", URL: deliveredValid, Status: 200, Response: `{"status":"D"}`}, + {Label: "Delivered Valid Post", URL: deliveredValidPost, Data: "id=12345", Status: 200, Response: `{"status":"D"}`}, +} + +func TestHandler(t *testing.T) { + RunChannelTestCases(t, testChannels, NewHandler(), handleTestCases) +} + +func BenchmarkHandler(b *testing.B) { + RunChannelBenchmarks(b, testChannels, NewHandler(), handleTestCases) +} + +func setSendURL(server *httptest.Server, channel courier.Channel, msg courier.Msg) { + channel.(*courier.MockChannel).SetConfig(courier.ConfigSendURL, server.URL) +} + +var getSendTestCases = []ChannelSendTestCase{ + {Label: "Plain Send", + Text: "Simple Message", URN: "tel:+250788383383", + Status: "W", + ResponseBody: "0: Accepted for delivery", ResponseStatus: 200, + URLParams: map[string]string{"msg": "Simple Message", "to": "250788383383", "from": "2020"}, + SendPrep: setSendURL}, + {Label: "Unicode Send", + Text: "☺", URN: "tel:+250788383383", + Status: "W", + ResponseBody: "0: Accepted for delivery", ResponseStatus: 200, + URLParams: map[string]string{"msg": "☺", "to": "250788383383", "from": "2020"}, + SendPrep: setSendURL}, + {Label: "Error Sending", + Text: "Error Message", URN: "tel:+250788383383", + Status: "E", + ResponseBody: "1: Unknown channel", ResponseStatus: 401, + Error: "received non 200 status: 401", + URLParams: map[string]string{"msg": `Error Message`, "to": "250788383383"}, + SendPrep: setSendURL}, + {Label: "Send Attachment", + Text: "My pic!", URN: "tel:+250788383383", Attachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + Status: "W", + ResponseBody: `0: Accepted for delivery`, ResponseStatus: 200, + URLParams: map[string]string{"msg": "My pic!\nhttps://foo.bar/image.jpg", "to": "250788383383", "from": "2020"}, + SendPrep: setSendURL}, +} + +func TestSending(t *testing.T) { + var getChannel = courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SQ", "2020", "US", + map[string]interface{}{ + courier.ConfigSendURL: "SendURL", + courier.ConfigPassword: "Password", + courier.ConfigUsername: "Username"}) + + RunChannelSendTestCases(t, getChannel, NewHandler(), getSendTestCases) +}