Skip to content

Commit

Permalink
Merge pull request rapidpro#292 from nyaruka/add-telesom
Browse files Browse the repository at this point in the history
Add TS channel type support
  • Loading branch information
nicpottier authored Apr 27, 2020
2 parents b7c2f2d + 5dac1d5 commit 6225cc6
Show file tree
Hide file tree
Showing 5 changed files with 312 additions and 1 deletion.
3 changes: 2 additions & 1 deletion cmd/courier/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ 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/telesom"
_ "github.com/nyaruka/courier/handlers/thinq"
_ "github.com/nyaruka/courier/handlers/twiml"
_ "github.com/nyaruka/courier/handlers/twitter"
_ "github.com/nyaruka/courier/handlers/viber"
Expand Down
128 changes: 128 additions & 0 deletions handlers/telesom/telesom.go
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

}
95 changes: 95 additions & 0 deletions handlers/telesom/telesom_test.go
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)
}
61 changes: 61 additions & 0 deletions utils/dates/now.go
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}
}
26 changes: 26 additions & 0 deletions utils/dates/now_test.go
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())
}

0 comments on commit 6225cc6

Please sign in to comment.