diff --git a/handlers/external/external.go b/handlers/external/external.go index 81d372552..a18c7308c 100644 --- a/handlers/external/external.go +++ b/handlers/external/external.go @@ -30,7 +30,7 @@ type handler struct { handlers.BaseHandler } -// NewHandler returns a new Externla handler +// NewHandler returns a new External handler func NewHandler() courier.ChannelHandler { return &handler{handlers.NewBaseHandler(courier.ChannelType("EX"), "External")} } diff --git a/handlers/smscentral/smscentral.go b/handlers/smscentral/smscentral.go index f10d64dbb..c4214763f 100644 --- a/handlers/smscentral/smscentral.go +++ b/handlers/smscentral/smscentral.go @@ -1,6 +1,121 @@ package smscentral +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/nyaruka/courier" + "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/utils" + "github.com/pkg/errors" +) + /* POST /handlers/smscentral/receive/uuid/ mobile=9779811781111&message=Msg */ + +var sendURL = "http://smail.smscentral.com.np/bp/ApiSms.php" + +func init() { + courier.RegisterHandler(NewHandler()) +} + +type handler struct { + handlers.BaseHandler +} + +// NewHandler returns a new Yo! handler +func NewHandler() courier.ChannelHandler { + return &handler{handlers.NewBaseHandler(courier.ChannelType("SC"), "SMS Central")} +} + +// Initialize is called by the engine once everything is loaded +func (h *handler) Initialize(s courier.Server) error { + h.SetServer(s) + err := s.AddReceiveMsgRoute(h, "POST", "receive", h.ReceiveMessage) + if err != nil { + return err + } + + return nil +} + +type smsCentralMessage struct { + Message string `validate:"required" name:"message"` + Mobile string `validate:"required" name:"mobile"` +} + +// 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) { + smsCentralMessage := &smsCentralMessage{} + handlers.DecodeAndValidateQueryParams(smsCentralMessage, r) + + // if this is a post, also try to parse the form body + if r.Method == http.MethodPost { + handlers.DecodeAndValidateForm(smsCentralMessage, r) + } + + // validate whether our required fields are present + err := handlers.Validate(smsCentralMessage) + if err != nil { + return nil, err + } + + // create our URN + urn := courier.NewTelURNForChannel(smsCentralMessage.Mobile, channel) + + // build our msg + msg := h.Backend().NewIncomingMsg(channel, urn, smsCentralMessage.Message) + + // and finally queue our message + err = h.Backend().WriteMsg(msg) + if err != nil { + return nil, err + } + + return []courier.Msg{msg}, courier.WriteReceiveSuccess(w, r, msg) + +} + +// SendMsg sends the passed in message, returning any error +func (h *handler) SendMsg(msg courier.Msg) (courier.MsgStatus, error) { + username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") + if username == "" { + return nil, fmt.Errorf("no username set for SC channel") + } + + password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "") + if password == "" { + return nil, fmt.Errorf("no password set for SC channel") + } + + // build our request + form := url.Values{ + "user": []string{username}, + "pass": []string{password}, + "mobile": []string{strings.TrimPrefix(msg.URN().Path(), "+")}, + "content": []string{courier.GetTextAndAttachments(msg)}, + } + + req, err := http.NewRequest(http.MethodPost, sendURL, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr, err := utils.MakeHTTPRequest(req) + + // record our status and log + 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 + } + + if rr.StatusCode/100 != 2 { + return status, errors.Errorf("Got non-200 response [%d] from API") + } + + status.SetStatus(courier.MsgWired) + + return status, nil +} diff --git a/handlers/smscentral/smscentral_test.go b/handlers/smscentral/smscentral_test.go new file mode 100644 index 000000000..8f4948761 --- /dev/null +++ b/handlers/smscentral/smscentral_test.go @@ -0,0 +1,79 @@ +package smscentral + +import ( + "net/http/httptest" + "testing" + + "github.com/nyaruka/courier" + . "github.com/nyaruka/courier/handlers" +) + +var ( + receiveValidMessage = "/c/sc/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?mobile=%2B2349067554729&message=Join" + receiveNoParams = "/c/sc/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" + receiveNoSender = "/c/sc/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?message=Join" + receiveNoMessage = "/c/sc/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?mobile=%2B2349067554729" +) + +var testChannels = []courier.Channel{ + courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SC", "2020", "US", map[string]interface{}{"username": "Username", "password": "Password"}), +} + +var handleTestCases = []ChannelHandleTestCase{ + {Label: "Receive Valid Message", URL: receiveValidMessage, Data: "empty", Status: 200, Response: "Accepted", + Text: Sp("Join"), URN: Sp("tel:+2349067554729")}, + {Label: "Receive No Params", URL: receiveNoParams, Data: "empty", Status: 400, Response: "field 'message' required"}, + {Label: "Receive No Sender", URL: receiveNoSender, Data: "empty", Status: 400, Response: "field 'mobile' required"}, + {Label: "Receive No Message", URL: receiveNoMessage, Data: "empty", Status: 400, Response: "field 'message' required"}, +} + +func TestHandler(t *testing.T) { + RunChannelTestCases(t, testChannels, NewHandler(), handleTestCases) +} + +func BenchmarkHandler(b *testing.B) { + RunChannelBenchmarks(b, testChannels, NewHandler(), handleTestCases) +} + +// setSend takes care of setting the sendURL to call +func setSendURL(server *httptest.Server, channel courier.Channel, msg courier.Msg) { + sendURL = server.URL +} + +var defaultSendTestCases = []ChannelSendTestCase{ + {Label: "Plain Send", + Text: "Simple Message", URN: "tel:+250788383383", + Status: "W", + ResponseBody: `[{"id": "1002"}]`, ResponseStatus: 200, + PostParams: map[string]string{"content": "Simple Message", "mobile": "250788383383", "pass": "Password", "user": "Username"}, + SendPrep: setSendURL}, + {Label: "Unicode Send", + Text: "☺", URN: "tel:+250788383383", + Status: "W", + ResponseBody: `[{"id": "1002"}]`, ResponseStatus: 200, + PostParams: map[string]string{"content": "☺", "mobile": "250788383383", "pass": "Password", "user": "Username"}, + SendPrep: setSendURL}, + {Label: "Send Attachment", + Text: "My pic!", URN: "tel:+250788383383", Attachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + Status: "W", + ResponseBody: `[{ "id": "1002" }]`, ResponseStatus: 200, + PostParams: map[string]string{"content": "My pic!\nhttps://foo.bar/image.jpg", "mobile": "250788383383", "pass": "Password", "user": "Username"}, + SendPrep: setSendURL}, + {Label: "Error Sending", + Text: "Error Message", URN: "tel:+250788383383", + Status: "E", + ResponseBody: `{ "error": "failed" }`, ResponseStatus: 401, + Error: "received non 200 status: 401", + PostParams: map[string]string{"content": `Error Message`, "mobile": "250788383383", "pass": "Password", "user": "Username"}, + SendPrep: setSendURL}, +} + +func TestSending(t *testing.T) { + var defaultChannel = courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SC", "2020", "US", + map[string]interface{}{ + courier.ConfigPassword: "Password", + courier.ConfigUsername: "Username", + }) + + RunChannelSendTestCases(t, defaultChannel, NewHandler(), defaultSendTestCases) +} diff --git a/handlers/yo/yo.go b/handlers/yo/yo.go index 8e57b981a..fbea5193b 100644 --- a/handlers/yo/yo.go +++ b/handlers/yo/yo.go @@ -3,3 +3,194 @@ package yo /* GET /handlers/yo/received/uuid?account=12345&dest=8500&message=Msg&sender=256778021111 */ + +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" +) + +var sendURL1 = "http://smgw1.yo.co.ug:9100/sendsms" +var sendURL2 = "http://41.220.12.201:9100/sendsms" +var sendURL3 = "http://164.40.148.210:9100/sendsms" + +func init() { + courier.RegisterHandler(NewHandler()) +} + +type handler struct { + handlers.BaseHandler +} + +// NewHandler returns a new Yo! handler +func NewHandler() courier.ChannelHandler { + return &handler{handlers.NewBaseHandler(courier.ChannelType("YO"), "YO!")} +} + +// Initialize is called by the engine once everything is loaded +func (h *handler) Initialize(s courier.Server) error { + h.SetServer(s) + err := s.AddReceiveMsgRoute(h, "GET", "receive", h.ReceiveMessage) + if err != nil { + return err + } + + 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) { + yoMessage := &yoMessage{} + handlers.DecodeAndValidateQueryParams(yoMessage, r) + + // if this is a post, also try to parse the form body + if r.Method == http.MethodPost { + handlers.DecodeAndValidateForm(yoMessage, r) + } + + // validate whether our required fields are present + err := handlers.Validate(yoMessage) + if err != nil { + return nil, err + } + + // must have one of from or sender set, error if neither + sender := yoMessage.Sender + if sender == "" { + sender = yoMessage.From + } + if sender == "" { + return nil, errors.New("must have one of 'sender' or 'from' set") + } + + // if we have a date, parse it + dateString := yoMessage.Date + if dateString == "" { + dateString = yoMessage.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, yoMessage.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 yoMessage struct { + From string `name:"from"` + Sender string `name:"sender"` + Text string `validate:"required" name:"text"` + Date string `name:"date"` + Time string `name:"time"` +} + +// SendMsg sends the passed in message, returning any error +func (h *handler) SendMsg(msg courier.Msg) (courier.MsgStatus, error) { + + username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") + if username == "" { + return nil, fmt.Errorf("no username set for YO channel") + } + + password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "") + if password == "" { + return nil, fmt.Errorf("no password set for YO channel") + } + + // build our request + form := url.Values{ + "origin": []string{strings.TrimPrefix(msg.Channel().Address(), "+")}, + "sms_content": []string{courier.GetTextAndAttachments(msg)}, + "destinations": []string{strings.TrimPrefix(msg.URN().Path(), "+")}, + "ybsacctno": []string{username}, + "password": []string{password}, + } + + var status courier.MsgStatus + encodedForm := form.Encode() + sendURLs := []string{sendURL1, sendURL2, sendURL3} + + for _, sendURL := range sendURLs { + failed := false + 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) + + if err != nil { + failed = true + } + // record our status and log + 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 + } + + responseQS, err := url.ParseQuery(string(rr.Body)) + + if err != nil { + failed = true + } + + if !failed && rr.StatusCode != 200 && rr.StatusCode != 201 { + failed = true + } + + ybsAutocreateStatus, ok := responseQS["ybs_autocreate_status"] + if !ok { + ybsAutocreateStatus = []string{""} + } + + if !failed && ybsAutocreateStatus[0] != "OK" { + failed = true + } + + ybsAutocreateMessage, ok := responseQS["ybs_autocreate_message"] + + if !ok { + ybsAutocreateMessage = []string{""} + } + + if failed && strings.Contains(ybsAutocreateMessage[0], "BLACKLISTED") { + status.SetStatus(courier.MsgFailed) + h.Backend().StopMsgContact(msg) + return status, nil + } + + if !failed { + status.SetStatus(courier.MsgWired) + return status, nil + } + + } + + return status, errors.Errorf("Received error from Yo! API") +} diff --git a/handlers/yo/yo_test.go b/handlers/yo/yo_test.go new file mode 100644 index 000000000..015c8bb12 --- /dev/null +++ b/handlers/yo/yo_test.go @@ -0,0 +1,101 @@ +package yo + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/nyaruka/courier" + . "github.com/nyaruka/courier/handlers" +) + +var ( + receiveValidMessage = "/c/yo/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?sender=%2B2349067554729&text=Join" + receiveValidMessageFrom = "/c/yo/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?from=%2B2349067554729&text=Join" + receiveValidMessageWithDate = "/c/yo/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?sender=%2B2349067554729&text=Join&date=2017-06-23T12:30:00.500Z" + receiveValidMessageWithTime = "/c/yo/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?sender=%2B2349067554729&text=Join&time=2017-06-23T12:30:00Z" + receiveNoParams = "/c/yo/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" + receiveNoSender = "/c/yo/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?text=Join" + receiveInvalidDate = "/c/yo/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?sender=%2B2349067554729&text=Join&time=20170623T123000Z" +) + +var testChannels = []courier.Channel{ + courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "YO", "2020", "US", map[string]interface{}{"username": "yo-username", "password": "yo-password"}), +} + +var handleTestCases = []ChannelHandleTestCase{ + {Label: "Receive Valid Message", URL: receiveValidMessage, Data: "", Status: 200, Response: "Accepted", + Text: Sp("Join"), URN: Sp("tel:+2349067554729")}, + {Label: "Receive Valid From", URL: receiveValidMessageFrom, Data: "", Status: 200, Response: "Accepted", + Text: Sp("Join"), URN: Sp("tel:+2349067554729")}, + {Label: "Receive Valid Message With Date", URL: receiveValidMessageWithDate, Data: "", 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: "", 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: "", Status: 400, Response: "field 'text' required"}, + {Label: "Receive No Sender", URL: receiveNoSender, Data: "", Status: 400, Response: "must have one of 'sender' or 'from' set"}, + {Label: "Receive Invalid Date", URL: receiveInvalidDate, Data: "", Status: 400, Response: "invalid date format, must be RFC 3339"}, +} + +func TestHandler(t *testing.T) { + RunChannelTestCases(t, testChannels, NewHandler(), handleTestCases) +} + +func BenchmarkHandler(b *testing.B) { + RunChannelBenchmarks(b, testChannels, NewHandler(), handleTestCases) +} + +// setSendURL takes care of setting the send_url to our test server host +func setSendURL(server *httptest.Server, channel courier.Channel, msg courier.Msg) { + sendURL1 = server.URL + sendURL2 = server.URL + sendURL3 = server.URL +} + +var getSendTestCases = []ChannelSendTestCase{ + {Label: "Plain Send", + Text: "Simple Message", URN: "tel:+250788383383", + Status: "W", + ResponseBody: "ybs_autocreate_status=OK", ResponseStatus: 200, + URLParams: map[string]string{"sms_content": "Simple Message", "destinations": string("250788383383"), "origin": "2020"}, + SendPrep: setSendURL}, + {Label: "Blacklisted", + Text: "Simple Message", URN: "tel:+250788383383", + Status: "F", + ResponseBody: "ybs_autocreate_status=ERROR&ybs_autocreate_message=256794224665%3ABLACKLISTED", ResponseStatus: 200, + URLParams: map[string]string{"sms_content": "Simple Message", "destinations": string("250788383383"), "origin": "2020"}, + SendPrep: setSendURL, + Stopped: true}, + {Label: "Errored wrong authorization", + Text: "Simple Message", URN: "tel:+250788383383", + Status: "E", + Error: "Received error from Yo! API", + ResponseBody: "ybs_autocreate_status=ERROR&ybs_autocreate_message=YBS+AutoCreate+Subsystem%3A+Access+denied+due+to+wrong+authorization+code", ResponseStatus: 200, + URLParams: map[string]string{"sms_content": "Simple Message", "destinations": string("250788383383"), "origin": "2020"}, + SendPrep: setSendURL}, + {Label: "Unicode Send", + Text: "☺", URN: "tel:+250788383383", + Status: "W", + ResponseBody: "ybs_autocreate_status=OK", ResponseStatus: 200, + URLParams: map[string]string{"sms_content": "☺", "destinations": string("250788383383"), "origin": "2020"}, + SendPrep: setSendURL}, + {Label: "Error Sending", + Text: "Error Message", URN: "tel:+250788383383", + Status: "E", + ResponseBody: "Error", ResponseStatus: 401, + Error: "received non 200 status: 401", + URLParams: map[string]string{"sms_content": `Error Message`, "destinations": string("250788383383")}, + SendPrep: setSendURL}, + {Label: "Send Attachment", + Text: "My pic!", URN: "tel:+250788383383", Attachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + Status: "W", + ResponseBody: "ybs_autocreate_status=OK", ResponseStatus: 200, + URLParams: map[string]string{"sms_content": "My pic!\nhttps://foo.bar/image.jpg", "destinations": string("250788383383"), "origin": "2020"}, + SendPrep: setSendURL}, +} + +func TestSending(t *testing.T) { + var getChannel = courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "YO", "2020", "US", map[string]interface{}{"username": "yo-username", "password": "yo-password"}) + + RunChannelSendTestCases(t, getChannel, NewHandler(), getSendTestCases) +}