forked from rapidpro/mailroom
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request rapidpro#292 from nyaruka/add-telesom
Add TS channel type support
- Loading branch information
Showing
5 changed files
with
312 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
package telesom | ||
|
||
import ( | ||
"context" | ||
"crypto/md5" | ||
"encoding/hex" | ||
"fmt" | ||
"net/http" | ||
"net/url" | ||
"strings" | ||
|
||
"github.com/nyaruka/courier" | ||
"github.com/nyaruka/courier/handlers" | ||
"github.com/nyaruka/courier/utils" | ||
"github.com/nyaruka/courier/utils/dates" | ||
) | ||
|
||
var ( | ||
sendURL = "http://telesom.com/sendsms" | ||
maxMsgLength = 160 | ||
) | ||
|
||
func init() { | ||
courier.RegisterHandler(newHandler()) | ||
} | ||
|
||
type handler struct { | ||
handlers.BaseHandler | ||
} | ||
|
||
func newHandler() courier.ChannelHandler { | ||
return &handler{handlers.NewBaseHandler(courier.ChannelType("TS"), "Telesom")} | ||
} | ||
|
||
func (h *handler) Initialize(s courier.Server) error { | ||
h.SetServer(s) | ||
s.AddHandlerRoute(h, http.MethodGet, "receive", h.receiveMessage) | ||
s.AddHandlerRoute(h, http.MethodPost, "receive", h.receiveMessage) | ||
return nil | ||
} | ||
|
||
type moForm struct { | ||
From string `name:"from" validate:"required"` | ||
Message string `name:"msg" validate:"required"` | ||
} | ||
|
||
// 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) { | ||
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) | ||
} | ||
|
||
// build our msg | ||
dbMsg := h.Backend().NewIncomingMsg(channel, urn, form.Message) | ||
|
||
// and finally write our message | ||
return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{dbMsg}, w, r) | ||
|
||
} | ||
|
||
// 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, "") | ||
if username == "" { | ||
return nil, fmt.Errorf("no username set for Telesom channel") | ||
} | ||
|
||
password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "") | ||
if password == "" { | ||
return nil, fmt.Errorf("no password set for Telesom channel") | ||
} | ||
|
||
privateKey := msg.Channel().StringConfigForKey(courier.ConfigSecret, "") | ||
if privateKey == "" { | ||
return nil, fmt.Errorf("no private key set for Telesom channel") | ||
} | ||
|
||
status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored) | ||
|
||
for _, part := range handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) { | ||
from := strings.TrimPrefix(msg.Channel().Address(), "+") | ||
to := strings.TrimPrefix(msg.URN().Path(), "+") | ||
|
||
// build our request | ||
form := url.Values{ | ||
"username": []string{username}, | ||
"password": []string{password}, | ||
"to": []string{to}, | ||
"from": []string{from}, | ||
"msg": []string{part}, | ||
} | ||
|
||
date := dates.Now().UTC().Format("02/01/2006") | ||
|
||
hasher := md5.New() | ||
hasher.Write([]byte(username + "|" + password + "|" + to + "|" + part + "|" + from + "|" + date + "|" + privateKey)) | ||
hash := hex.EncodeToString(hasher.Sum(nil)) | ||
|
||
form["key"] = []string{strings.ToUpper(hash)} | ||
encodedForm := form.Encode() | ||
sendURL = fmt.Sprintf("%s?%s", sendURL, encodedForm) | ||
|
||
req, _ := http.NewRequest(http.MethodGet, sendURL, nil) | ||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||
rr, err := utils.MakeInsecureHTTPRequest(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 | ||
} | ||
|
||
if strings.Contains(string(rr.Body), "Success") { | ||
status.SetStatus(courier.MsgWired) | ||
} else { | ||
log.WithError("Message Send Error", fmt.Errorf("Received invalid response content: %s", string(rr.Body))) | ||
} | ||
} | ||
return status, nil | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
package telesom | ||
|
||
import ( | ||
"net/http/httptest" | ||
"testing" | ||
"time" | ||
|
||
"github.com/nyaruka/courier" | ||
. "github.com/nyaruka/courier/handlers" | ||
"github.com/nyaruka/courier/utils/dates" | ||
) | ||
|
||
var ( | ||
receiveValidMessage = "/c/ts/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?from=%2B2349067554729&msg=Join" | ||
receiveNoParams = "/c/ts/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" | ||
invalidURN = "/c/ts/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?from=MTN&msg=Join" | ||
receiveNoSender = "/c/ts/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?msg=Join" | ||
) | ||
|
||
var testChannels = []courier.Channel{ | ||
courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TS", "2020", "US", nil), | ||
} | ||
|
||
var handleTestCases = []ChannelHandleTestCase{ | ||
{Label: "Receive Valid Message", URL: receiveValidMessage, Data: "", Status: 200, Response: "Accepted", | ||
Text: Sp("Join"), URN: Sp("tel:+2349067554729")}, | ||
{Label: "Invalid URN", URL: invalidURN, Data: "", Status: 400, Response: "phone number supplied is not a number"}, | ||
{Label: "Receive No Params", URL: receiveNoParams, Data: "", Status: 400, Response: "field 'from' required"}, | ||
{Label: "Receive No Sender", URL: receiveNoSender, Data: "", Status: 400, Response: "field 'from' required"}, | ||
|
||
{Label: "Receive Valid Message", URL: receiveNoParams, Data: "from=%2B2349067554729&msg=Join", Status: 200, Response: "Accepted", | ||
Text: Sp("Join"), URN: Sp("tel:+2349067554729")}, | ||
{Label: "Invalid URN", URL: receiveNoParams, Data: "from=MTN&msg=Join", Status: 400, Response: "phone number supplied is not a number"}, | ||
{Label: "Receive No Params", URL: receiveNoParams, Data: "empty", Status: 400, Response: "field 'from' required"}, | ||
{Label: "Receive No Sender", URL: receiveNoParams, Data: "msg=Join", Status: 400, Response: "field 'from' required"}, | ||
} | ||
|
||
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 sendURL to call | ||
func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { | ||
sendURL = s.URL | ||
} | ||
|
||
var defaultSendTestCases = []ChannelSendTestCase{ | ||
{Label: "Plain Send", | ||
Text: "Simple Message", URN: "tel:+250788383383", | ||
Status: "W", | ||
ResponseBody: "<return>Success</return>", ResponseStatus: 200, | ||
URLParams: map[string]string{"msg": "Simple Message", "to": "250788383383", "from": "2020", "username": "Username", "password": "Password", "key": "702FDCC27F2FF04CEB6EF4E1545B8C94"}, | ||
Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, | ||
SendPrep: setSendURL}, | ||
{Label: "Unicode Send", | ||
Text: "☺", URN: "tel:+250788383383", | ||
Status: "W", | ||
ResponseBody: "<return>Success</return>", ResponseStatus: 200, | ||
URLParams: map[string]string{"msg": "☺", "to": "250788383383", "from": "2020", "username": "Username", "password": "Password", "key": "B9C70F93DAE834477E107A128FEA04D4"}, | ||
Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, | ||
SendPrep: setSendURL}, | ||
{Label: "Error Sending", | ||
Text: "Error Message", URN: "tel:+250788383383", | ||
Status: "E", | ||
ResponseBody: "<return>error</return>", ResponseStatus: 401, | ||
URLParams: map[string]string{"msg": `Error Message`, "to": "250788383383", "from": "2020", "username": "Username", "password": "Password", "key": "C9F78FC4CC9A416C57AB0A3F208EDF49"}, | ||
Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, | ||
SendPrep: setSendURL}, | ||
{Label: "Send Attachment", | ||
Text: "My pic!", URN: "tel:+250788383383", Attachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, | ||
Status: "W", | ||
ResponseBody: `<return>Success</return>`, ResponseStatus: 200, | ||
URLParams: map[string]string{"msg": "My pic!\nhttps://foo.bar/image.jpg", "to": "250788383383", "from": "2020", "username": "Username", "password": "Password", "key": "1D7100B3F9D3249D1A92A0841AD8F543"}, | ||
Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, | ||
SendPrep: setSendURL}, | ||
} | ||
|
||
func TestSending(t *testing.T) { | ||
var defaultChannel = courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TS", "2020", "US", | ||
map[string]interface{}{ | ||
"password": "Password", | ||
"username": "Username", | ||
"secret": "secret", | ||
}, | ||
) | ||
|
||
// mock time so we can have predictable MD5 hashes | ||
dates.SetNowSource(dates.NewFixedNowSource(time.Date(2018, 4, 11, 18, 24, 30, 123456000, time.UTC))) | ||
|
||
RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, nil) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
package dates | ||
|
||
import ( | ||
"time" | ||
) | ||
|
||
// Now returns the time now.. according to the current source of now | ||
func Now() time.Time { | ||
return currentNowSource.Now() | ||
} | ||
|
||
// NowSource is something that can provide a now result | ||
type NowSource interface { | ||
Now() time.Time | ||
} | ||
|
||
// defaultNowSource returns now as the current system time | ||
type defaultNowSource struct{} | ||
|
||
func (s defaultNowSource) Now() time.Time { | ||
return time.Now() | ||
} | ||
|
||
// DefaultNowSource is the default time source | ||
var DefaultNowSource NowSource = defaultNowSource{} | ||
var currentNowSource = DefaultNowSource | ||
|
||
// SetNowSource sets the time source used by Now() | ||
func SetNowSource(source NowSource) { | ||
currentNowSource = source | ||
} | ||
|
||
// a source which returns a fixed time | ||
type fixedNowSource struct { | ||
now time.Time | ||
} | ||
|
||
func (s *fixedNowSource) Now() time.Time { | ||
return s.now | ||
} | ||
|
||
// NewFixedNowSource creates a new fixed time now source | ||
func NewFixedNowSource(now time.Time) NowSource { | ||
return &fixedNowSource{now: now} | ||
} | ||
|
||
// a now source which returns a sequence of times 1 second after each other | ||
type sequentialNowSource struct { | ||
current time.Time | ||
} | ||
|
||
func (s *sequentialNowSource) Now() time.Time { | ||
now := s.current | ||
s.current = s.current.Add(time.Second * 1) | ||
return now | ||
} | ||
|
||
// NewSequentialNowSource creates a new sequential time source | ||
func NewSequentialNowSource(start time.Time) NowSource { | ||
return &sequentialNowSource{current: start} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
package dates_test | ||
|
||
import ( | ||
"testing" | ||
"time" | ||
|
||
"github.com/nyaruka/courier/utils/dates" | ||
|
||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func TestTimeSources(t *testing.T) { | ||
defer dates.SetNowSource(dates.DefaultNowSource) | ||
|
||
d1 := time.Date(2018, 7, 5, 16, 29, 30, 123456, time.UTC) | ||
dates.SetNowSource(dates.NewFixedNowSource(d1)) | ||
|
||
assert.Equal(t, time.Date(2018, 7, 5, 16, 29, 30, 123456, time.UTC), dates.Now()) | ||
assert.Equal(t, time.Date(2018, 7, 5, 16, 29, 30, 123456, time.UTC), dates.Now()) | ||
|
||
dates.SetNowSource(dates.NewSequentialNowSource(d1)) | ||
|
||
assert.Equal(t, time.Date(2018, 7, 5, 16, 29, 30, 123456, time.UTC), dates.Now()) | ||
assert.Equal(t, time.Date(2018, 7, 5, 16, 29, 31, 123456, time.UTC), dates.Now()) | ||
assert.Equal(t, time.Date(2018, 7, 5, 16, 29, 32, 123456, time.UTC), dates.Now()) | ||
} |