From d3c3677fc663defd1e1796e8ac45edc7aa6525d0 Mon Sep 17 00:00:00 2001 From: Nic Pottier Date: Tue, 20 Jun 2017 17:28:01 -0500 Subject: [PATCH] Add gsm7 library to test gsm7 of string and replace variances, implement twilio, kannel, AT and BM channels for sending --- .travis.yml | 1 - backends/rapidpro/channel.go | 10 ++ channel.go | 15 +- gsm7/gsm7.go | 215 +++++++++++++++++++++++++++++ handlers/africastalking/handler.go | 52 ++++++- handlers/blackmyna/handler.go | 46 +++++- handlers/kannel/handler.go | 109 ++++++++++++++- handlers/telegram/handler.go | 23 ++- handlers/twilio/handler.go | 83 +++++++++-- msg.go | 10 ++ test.go | 9 ++ utils/http.go | 38 +++++ 12 files changed, 580 insertions(+), 31 deletions(-) create mode 100644 gsm7/gsm7.go diff --git a/.travis.yml b/.travis.yml index 031b2e1bd..50ed2b9fd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,6 @@ services: - redis-server before_script: -- redis-cli info - psql -U postgres -c "CREATE USER courier WITH PASSWORD 'courier';" - psql -U postgres -c "ALTER ROLE courier WITH SUPERUSER;" - psql -U postgres -c "CREATE DATABASE courier_test;" diff --git a/backends/rapidpro/channel.go b/backends/rapidpro/channel.go index db0d542da..9ff56ea2e 100644 --- a/backends/rapidpro/channel.go +++ b/backends/rapidpro/channel.go @@ -174,3 +174,13 @@ func (c *DBChannel) ConfigForKey(key string, defaultValue interface{}) interface } return value } + +// StringConfigForKey returns the config value for the passed in key, or defaultValue if it isn't found +func (c *DBChannel) StringConfigForKey(key string, defaultValue string) string { + val := c.ConfigForKey(key, defaultValue) + str, isStr := val.(string) + if !isStr { + return defaultValue + } + return str +} diff --git a/channel.go b/channel.go index dc30ec4fd..b48ab8fdc 100644 --- a/channel.go +++ b/channel.go @@ -8,8 +8,20 @@ import ( ) const ( - // ConfigAuthToken is our constant key used in channel configs for auth tokens + // ConfigAuthToken is a constant key for channel configs ConfigAuthToken = "auth_token" + + // ConfigUsername is a constant key for channel configs + ConfigUsername = "username" + + // ConfigPassword is a constant key for channel configs + ConfigPassword = "password" + + // ConfigAPIKey is a constant key for channel configs + ConfigAPIKey = "api_key" + + // ConfigSendURL is a constant key for channel configs + ConfigSendURL = "send_url" ) // ChannelType is our typing of the two char channel types @@ -55,4 +67,5 @@ type Channel interface { Country() string Address() string ConfigForKey(key string, defaultValue interface{}) interface{} + StringConfigForKey(key string, defaultValue string) string } diff --git a/gsm7/gsm7.go b/gsm7/gsm7.go new file mode 100644 index 000000000..b6d282b6f --- /dev/null +++ b/gsm7/gsm7.go @@ -0,0 +1,215 @@ +package gsm7 + +import "bytes" + +var validGSM7 = map[rune]byte{ + '@': 0x00, + '£': 0x01, + '$': 0x02, + '¥': 0x03, + 'è': 0x04, + 'é': 0x05, + 'ù': 0x06, + 'ì': 0x07, + 'ò': 0x08, + 'Ç': 0x09, + '\n': 0x0A, + 'Ø': 0x0B, + 'ø': 0x0C, + '\r': 0x0D, + 'Å': 0x0E, + 'å': 0x0F, + 'Δ': 0x10, + '_': 0x11, + 'Φ': 0x12, + 'Γ': 0x13, + 'Λ': 0x14, + 'Ω': 0x15, + 'Π': 0x16, + 'Ψ': 0x17, + 'Σ': 0x18, + 'Θ': 0x19, + 'Ξ': 0x1A, + // 'ESC': 0x1B, // Escape control + 'Æ': 0x1C, + 'æ': 0x1D, + 'ß': 0x1E, + 'É': 0x1F, + ' ': 0x20, + '!': 0x21, + '"': 0x22, + '#': 0x23, + '¤': 0x24, + '%': 0x25, + '&': 0x26, + '\'': 0x27, + '(': 0x28, + ')': 0x29, + '*': 0x2A, + '+': 0x2B, + ',': 0x2C, + '-': 0x2D, + '.': 0x2E, + '/': 0x2F, + '0': 0x30, + '1': 0x31, + '2': 0x32, + '3': 0x33, + '4': 0x34, + '5': 0x35, + '6': 0x36, + '7': 0x37, + '8': 0x38, + '9': 0x39, + ':': 0x3A, + ';': 0x3B, + '<': 0x3C, + '=': 0x3D, + '>': 0x3E, + '?': 0x3F, + '¡': 0x40, + 'A': 0x41, + 'B': 0x42, + 'C': 0x43, + 'D': 0x44, + 'E': 0x45, + 'F': 0x46, + 'G': 0x47, + 'H': 0x48, + 'I': 0x49, + 'J': 0x4A, + 'K': 0x4B, + 'L': 0x4C, + 'M': 0x4D, + 'N': 0x4E, + 'O': 0x4F, + 'P': 0x50, + 'Q': 0x51, + 'R': 0x52, + 'S': 0x53, + 'T': 0x54, + 'U': 0x55, + 'V': 0x56, + 'W': 0x57, + 'X': 0x58, + 'Y': 0x59, + 'Z': 0x5A, + 'Ä': 0x5B, + 'Ö': 0x5C, + 'Ñ': 0x5D, + 'Ü': 0x5E, + '§': 0x5F, + '¿': 0x60, + 'a': 0x61, + 'b': 0x62, + 'c': 0x63, + 'd': 0x64, + 'e': 0x65, + 'f': 0x66, + 'g': 0x67, + 'h': 0x68, + 'i': 0x69, + 'j': 0x7A, + 'k': 0x7B, + 'l': 0x7C, + 'm': 0x7D, + 'n': 0x7E, + 'o': 0x7F, + 'p': 0x80, + 'q': 0x81, + 'r': 0x82, + 's': 0x83, + 't': 0x84, + 'u': 0x85, + 'v': 0x86, + 'w': 0x87, + 'x': 0x88, + 'y': 0x89, + 'z': 0x8A, + 'ä': 0x8B, + 'ö': 0x8C, + 'ñ': 0x8D, + 'ü': 0x8E, + 'à': 0x8F, + + // extended char set + // 'FF': 0x0A // Page break + // 'CR2': 0x0D // Control char + // 'SS2': 0x1B // Single shift escape + '^': 0x14, + '{': 0x28, + '}': 0x29, + '\\': 0x2F, + '[': 0x3C, + '~': 0x3D, + ']': 0x3E, + '|': 0x40, + '€': 0x65, +} + +// Characters we replace in GSM7 with versions that can actually be encoded +var gsm7Replacements = map[rune]rune{ + 'á': 'a', + 'ê': 'e', + 'ã': 'a', + 'â': 'a', + 'ç': 'c', + 'í': 'i', + 'î': 'i', + 'ú': 'u', + 'û': 'u', + 'õ': 'o', + 'ô': 'o', + 'ó': 'o', + + 'Á': 'A', + 'Â': 'A', + 'Ã': 'A', + 'À': 'A', + 'Ç': 'C', + 'È': 'E', + 'Ê': 'E', + 'Í': 'I', + 'Î': 'I', + 'Ì': 'I', + 'Ó': 'O', + 'Ô': 'O', + 'Ò': 'O', + 'Õ': 'O', + 'Ú': 'U', + 'Ù': 'U', + 'Û': 'U', + + // shit Word likes replacing automatically + '’': '\'', + '‘': '\'', + '“': '"', + '”': '"', + '–': '-', + '\xa0': ' ', +} + +// IsGSM7 returns whether the passed in string is made up of entirely GSM7 characters +func IsGSM7(text string) bool { + for _, r := range text { + _, present := validGSM7[r] + if !present { + return false + } + } + return true +} + +// ReplaceNonGSM7Chars replaces all the non-gsm7 characters it can in the passed in string +func ReplaceNonGSM7Chars(text string) string { + output := bytes.Buffer{} + for _, r := range text { + replacement, present := gsm7Replacements[r] + if present { + output.WriteRune(replacement) + } else { + output.WriteRune(r) + } + } + return output.String() +} diff --git a/handlers/africastalking/handler.go b/handlers/africastalking/handler.go index a05e6b50b..2f8158204 100644 --- a/handlers/africastalking/handler.go +++ b/handlers/africastalking/handler.go @@ -3,12 +3,19 @@ package africastalking import ( "fmt" "net/http" + "net/url" + "strings" "time" + "github.com/buger/jsonparser" "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/utils" + "github.com/pkg/errors" ) +const configIsShared = "is_shared" + func init() { courier.RegisterHandler(NewHandler()) } @@ -111,5 +118,48 @@ func (h *handler) StatusMessage(channel courier.Channel, w http.ResponseWriter, // SendMsg sends the passed in message, returning any error func (h *handler) SendMsg(msg *courier.Msg) (*courier.MsgStatusUpdate, error) { - return nil, fmt.Errorf("sending not implemented channel type: %s", msg.Channel.ChannelType()) + isSharedStr := msg.Channel.ConfigForKey(configIsShared, false) + isShared, _ := isSharedStr.(bool) + + username := msg.Channel.StringConfigForKey(courier.ConfigUsername, "") + if username == "" { + return nil, fmt.Errorf("no username set for AT channel") + } + + apiKey := msg.Channel.StringConfigForKey(courier.ConfigAPIKey, "") + if apiKey == "" { + return nil, fmt.Errorf("no API key set for AT channel") + } + + // build our request + form := url.Values{ + "username": []string{username}, + "to": []string{msg.URN.Path()}, + "message": []string{msg.Text}, + } + + // if this isn't shared, include our from + if !isShared { + form["from"] = []string{msg.Channel.Address()} + } + + req, err := http.NewRequest("POST", "https://api.africastalking.com/version1/messaging", strings.NewReader(form.Encode())) + rr, err := utils.MakeHTTPRequest(req) + + // record our status and log + status := courier.NewStatusUpdateForID(msg.Channel, msg.ID, courier.MsgErrored) + status.AddLog(courier.NewChannelLogFromRR(msg.Channel, msg.ID, rr)) + + // was this request successful? + msgStatus, _ := jsonparser.GetString([]byte(rr.Body), "SMSMessageData", "Recipients", "[0]", "status") + if err != nil || msgStatus != "Success" { + return status, errors.Errorf("received error sending message: %s", msgStatus) + } + + // grab the external id if we can + externalID, _ := jsonparser.GetString([]byte(rr.Body), "SMSMessageData", "Recipients", "[0]", "messageId") + status.Status = courier.MsgWired + status.ExternalID = externalID + + return status, nil } diff --git a/handlers/blackmyna/handler.go b/handlers/blackmyna/handler.go index 63516e316..7336cc260 100644 --- a/handlers/blackmyna/handler.go +++ b/handlers/blackmyna/handler.go @@ -3,9 +3,14 @@ package blackmyna import ( "fmt" "net/http" + "net/url" + "strings" + "github.com/buger/jsonparser" "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/utils" + "github.com/pkg/errors" ) type bmHandler struct { @@ -96,7 +101,46 @@ func (h *bmHandler) StatusMessage(channel courier.Channel, w http.ResponseWriter // SendMsg sends the passed in message, returning any error func (h *bmHandler) SendMsg(msg *courier.Msg) (*courier.MsgStatusUpdate, error) { - return nil, fmt.Errorf("sending not implemented channel type: %s", msg.Channel.ChannelType()) + username := msg.Channel.StringConfigForKey(courier.ConfigUsername, "") + if username == "" { + return nil, fmt.Errorf("no username set for BM channel") + } + + password := msg.Channel.StringConfigForKey(courier.ConfigPassword, "") + if password == "" { + return nil, fmt.Errorf("no password set for BM channel") + } + + apiKey := msg.Channel.StringConfigForKey(courier.ConfigAPIKey, "") + if apiKey == "" { + return nil, fmt.Errorf("no API key set for AT channel") + } + + // build our request + form := url.Values{ + "address": []string{msg.URN.Path()}, + "senderaddress": []string{msg.Channel.Address()}, + "message": []string{msg.Text}, + } + + req, err := http.NewRequest("POST", "http://api.blackmyna.com/2/smsmessaging/outbound", strings.NewReader(form.Encode())) + req.SetBasicAuth(username, password) + rr, err := utils.MakeHTTPRequest(req) + + // record our status and log + status := courier.NewStatusUpdateForID(msg.Channel, msg.ID, courier.MsgErrored) + status.AddLog(courier.NewChannelLogFromRR(msg.Channel, msg.ID, rr)) + + // get our external id + externalID, _ := jsonparser.GetString([]byte(rr.Body), "[0]", "id") + if err != nil || externalID == "" { + return status, errors.Errorf("received error sending message") + } + + status.Status = courier.MsgWired + status.ExternalID = externalID + + return status, nil } type bmStatus struct { diff --git a/handlers/kannel/handler.go b/handlers/kannel/handler.go index c75fb198d..699913411 100644 --- a/handlers/kannel/handler.go +++ b/handlers/kannel/handler.go @@ -3,12 +3,27 @@ package kannel import ( "fmt" "net/http" + "net/url" "time" + "strings" + "github.com/nyaruka/courier" + "github.com/nyaruka/courier/gsm7" "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/utils" + "github.com/nyaruka/phonenumbers" + "github.com/pkg/errors" ) +const configUseNational = "use_national" +const configEncoding = "encoding" +const configVerifySSL = "verify_ssl" + +const encodingDefault = "D" +const encodingUnicode = "U" +const encodingSmart = "S" + func init() { courier.RegisterHandler(NewHandler()) } @@ -101,7 +116,99 @@ func (h *kannelHandler) StatusMessage(channel courier.Channel, w http.ResponseWr // SendMsg sends the passed in message, returning any error func (h *kannelHandler) SendMsg(msg *courier.Msg) (*courier.MsgStatusUpdate, error) { - return nil, fmt.Errorf("sending not implemented channel type: %s", msg.Channel.ChannelType()) + username := msg.Channel.StringConfigForKey(courier.ConfigUsername, "") + if username == "" { + return nil, fmt.Errorf("no username set for KN channel") + } + + password := msg.Channel.StringConfigForKey(courier.ConfigPassword, "") + if password == "" { + return nil, fmt.Errorf("no password set for KN channel") + } + + sendURL := msg.Channel.StringConfigForKey(courier.ConfigSendURL, "") + if sendURL == "" { + return nil, fmt.Errorf("no send url set for KN channel") + } + + dlrURL := fmt.Sprintf("%s%s%s/?id=%d&status=%%d", h.Server().Config().BaseURL, "/c/kn/", msg.Channel.UUID(), msg.ID.Int64) + + // build our request + form := url.Values{ + "username": []string{username}, + "password": []string{password}, + "from": []string{msg.Channel.Address()}, + "text": []string{msg.Text}, + "to": []string{msg.URN.Path()}, + "dlr-url": []string{dlrURL}, + "dlr-mask": []string{"31"}, + } + + // TODO: higher priority for responses + //if msg.ResponseTo != 0 { + // form["priority"] = []string{"1"} + //} + + useNationalStr := msg.Channel.ConfigForKey(configUseNational, false) + useNational, _ := useNationalStr.(bool) + + // if we are meant to use national formatting (no country code) pull that out + if useNational { + parsed, err := phonenumbers.Parse(msg.URN.Path(), encodingDefault) + if err == nil { + form["to"] = []string{fmt.Sprintf("%d", *parsed.NationalNumber)} + } + } + + // figure out what encoding to tell kannel to send as + encoding := msg.Channel.StringConfigForKey(configEncoding, "") + + // if we are smart, first try to convert to GSM7 chars + if encoding == encodingSmart { + replaced := gsm7.ReplaceNonGSM7Chars(msg.Text) + if gsm7.IsGSM7(replaced) { + form["text"] = []string{replaced} + } else { + encoding = encodingUnicode + } + } + + // if we are UTF8, set our coding appropriately + if encoding == encodingUnicode { + form["coding"] = []string{"2"} + form["charset"] = []string{"utf8"} + } + + // our send URL may have form parameters in it already, append our own afterwards + encodedForm := form.Encode() + if strings.Contains(sendURL, "?") { + sendURL = fmt.Sprintf("%s&%s", sendURL, encodedForm) + } else { + sendURL = fmt.Sprintf("%s?%s", sendURL, encodedForm) + } + + // ignore SSL warnings if they ask + verifySSLStr := msg.Channel.ConfigForKey(configVerifySSL, true) + verifySSL, _ := verifySSLStr.(bool) + + req, err := http.NewRequest("GET", sendURL, nil) + var rr *utils.RequestResponse + + if verifySSL { + rr, err = utils.MakeHTTPRequest(req) + } else { + rr, err = utils.MakeInsecureHTTPRequest(req) + } + + // record our status and log + status := courier.NewStatusUpdateForID(msg.Channel, msg.ID, courier.MsgErrored) + status.AddLog(courier.NewChannelLogFromRR(msg.Channel, msg.ID, rr)) + if err != nil { + return status, errors.Errorf("received error sending message") + } + + status.Status = courier.MsgWired + return status, nil } type kannelStatus struct { diff --git a/handlers/telegram/handler.go b/handlers/telegram/handler.go index 5945d7e8f..9e5db4f78 100644 --- a/handlers/telegram/handler.go +++ b/handlers/telegram/handler.go @@ -155,7 +155,7 @@ func (h *telegramHandler) SendMsg(msg *courier.Msg) (*courier.MsgStatusUpdate, e } // the status that will be written for this message - status := courier.NewStatusUpdateForID(msg.Channel, msg.ID, courier.NilMsgStatus) + status := courier.NewStatusUpdateForID(msg.Channel, msg.ID, courier.MsgErrored) // whether we encountered any errors sending any parts hasError := true @@ -174,16 +174,11 @@ func (h *telegramHandler) SendMsg(msg *courier.Msg) (*courier.MsgStatusUpdate, e // send each attachment for _, attachment := range msg.Attachments { - mediaParts := strings.SplitN(attachment, "/", 2) - if len(mediaParts) < 2 { - status.AddLog(courier.NewChannelLog(msg.Channel, msg.ID, "", courier.NilStatusCode, - fmt.Errorf("invalid attachment: %s", attachment), "", "", time.Duration(0), time.Now())) - continue - } - switch mediaParts[0] { + mediaType, mediaURL := courier.SplitAttachment(attachment) + switch mediaType { case "image": form := url.Values{ - "photo": []string{mediaParts[1]}, + "photo": []string{mediaURL}, "caption": []string{caption}, } externalID, log, err := h.sendMsgPart(msg, authToken, "sendPhoto", form) @@ -193,7 +188,7 @@ func (h *telegramHandler) SendMsg(msg *courier.Msg) (*courier.MsgStatusUpdate, e case "video": form := url.Values{ - "video": []string{mediaParts[1]}, + "video": []string{mediaURL}, "caption": []string{caption}, } externalID, log, err := h.sendMsgPart(msg, authToken, "sendVideo", form) @@ -203,7 +198,7 @@ func (h *telegramHandler) SendMsg(msg *courier.Msg) (*courier.MsgStatusUpdate, e case "audio": form := url.Values{ - "audio": []string{mediaParts[1]}, + "audio": []string{mediaURL}, "caption": []string{caption}, } externalID, log, err := h.sendMsgPart(msg, authToken, "sendAudio", form) @@ -213,14 +208,12 @@ func (h *telegramHandler) SendMsg(msg *courier.Msg) (*courier.MsgStatusUpdate, e default: status.AddLog(courier.NewChannelLog(msg.Channel, msg.ID, "", courier.NilStatusCode, - fmt.Errorf("unknown media type: %s", mediaParts[0]), "", "", time.Duration(0), time.Now())) + fmt.Errorf("unknown media type: %s", mediaType), "", "", time.Duration(0), time.Now())) hasError = true } } - if hasError { - status.Status = courier.MsgErrored - } else { + if !hasError { status.Status = courier.MsgWired } diff --git a/handlers/twilio/handler.go b/handlers/twilio/handler.go index 0dda5c562..1a95e8e90 100644 --- a/handlers/twilio/handler.go +++ b/handlers/twilio/handler.go @@ -1,16 +1,8 @@ package twilio /* -Handler for Twilio channels, see https://www.twilio.com/docs/api - -Examples: - -POST /c/tw/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/ -ToCountry=US&ToState=IN&SmsMessageSid=SMa741ddeb574e5dda5516c73417fcd28a&NumMedia=0&ToCity=&FromZip=46204&SmsSid=SMa741ddeb574e5dda5516c73417fcd28a&FromState=IN&SmsStatus=received&FromCity=INDIANAPOLIS&Body=Hi+there+from+Twilio&FromCountry=US&To=%2B13177933221&ToZip=&NumSegments=1&MessageSid=SMa741ddeb574e5dda5516c73417fcd28a&AccountSid=AC7ef44158dbb01b972d64d7e5c851c8d7&From=%2B13177592786&ApiVersion=2010-04-01 - -POST /c/tw/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status/ -MessageStatus=sent&ApiVersion=2010-04-01&SmsSid=SM7ac25b8b7f04410093ff54e1fd2b4256&SmsStatus=sent&To=%2B13177933221&From=%2B13177592786&MessageSid=SM7ac25b8b7f04410093ff54e1fd2b4256&AccountSid=AC7ef44158dbb01b972d64d7e5c851c8d7 -*/ + * Handler for Twilio channels, see https://www.twilio.com/docs/api + */ import ( "bytes" @@ -21,11 +13,21 @@ import ( "net/http" "net/url" "sort" + "strings" + + "github.com/buger/jsonparser" "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/utils" + "github.com/pkg/errors" ) +// TODO: agree on case! +const configAccountSID = "ACCOUNT_SID" +const configMessagingServiceSID = "messaging_service_sid" +const configSendURL = "send_url" + const twSignatureHeader = "X-Twilio-Signature" type twHandler struct { @@ -149,7 +151,66 @@ func (h *twHandler) StatusMessage(channel courier.Channel, w http.ResponseWriter // SendMsg sends the passed in message, returning any error func (h *twHandler) SendMsg(msg *courier.Msg) (*courier.MsgStatusUpdate, error) { - return nil, fmt.Errorf("sending not implemented channel type: %s", msg.Channel.ChannelType()) + // build our callback URL + callbackURL := fmt.Sprintf("%s/c/kn/%s/status/", h.Server().Config().BaseURL, msg.Channel.UUID()) + + accountSID := msg.Channel.StringConfigForKey(configAccountSID, "") + if accountSID == "" { + return nil, fmt.Errorf("missing account sid for twilio channel") + } + + accountToken := msg.Channel.StringConfigForKey(courier.ConfigAuthToken, "") + if accountToken == "" { + return nil, fmt.Errorf("missing account auth token for twilio channel") + } + + // build our request + form := url.Values{ + "To": []string{msg.URN.Path()}, + "Body": []string{msg.Text}, + "StatusCallback": []string{callbackURL}, + } + + // add any media URL + if len(msg.Attachments) > 0 { + _, mediaURL := courier.SplitAttachment(msg.Attachments[0]) + form["MediaURL"] = []string{mediaURL} + } + + // set our from, either as a messaging service or from our address + serviceSID := msg.Channel.StringConfigForKey(configMessagingServiceSID, "") + if serviceSID != "" { + form["MessagingServiceSID"] = []string{serviceSID} + } else { + form["From"] = []string{msg.Channel.Address()} + } + + baseSendURL := msg.Channel.StringConfigForKey(configSendURL, "https://api.twilio.com/2010-04-01/Accounts/") + sendURL := fmt.Sprintf("%s%s/Messages.json", baseSendURL, accountSID) + req, err := http.NewRequest("POST", sendURL, strings.NewReader(form.Encode())) + rr, err := utils.MakeHTTPRequest(req) + + // record our status and log + status := courier.NewStatusUpdateForID(msg.Channel, msg.ID, courier.MsgErrored) + status.AddLog(courier.NewChannelLogFromRR(msg.Channel, msg.ID, rr)) + + // was this request successful? + errorCode, _ := jsonparser.GetInt([]byte(rr.Body), "error_code") + if err != nil || errorCode != 0 { + // TODO: Notify RapidPro of blocked contacts (code 21610) + return status, errors.Errorf("received error from twilio") + } + + // grab the external id + externalID, err := jsonparser.GetString([]byte(rr.Body), "sid") + if err != nil { + return status, errors.Errorf("unable to get sid from body") + } + + status.Status = courier.MsgWired + status.ExternalID = externalID + + return status, nil } // Twilio expects Twiml from a message receive request diff --git a/msg.go b/msg.go index 49f283016..d462ae411 100644 --- a/msg.go +++ b/msg.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strconv" + "strings" "time" "github.com/nyaruka/courier/queue" @@ -119,3 +120,12 @@ func (m *Msg) WithExternalID(id string) *Msg { m.ExternalID = id; return m } // AddAttachment can be used to append to the media urls for a message func (m *Msg) AddAttachment(url string) *Msg { m.Attachments = append(m.Attachments, url); return m } + +// SplitAttachment takes an attachment string and returns the media type and URL for the attachment +func SplitAttachment(attachment string) (string, string) { + parts := strings.SplitN(attachment, ":", 2) + if len(parts) < 2 { + return "", parts[0] + } + return parts[0], parts[1] +} diff --git a/test.go b/test.go index 33afba6ff..9a40c5d37 100644 --- a/test.go +++ b/test.go @@ -132,6 +132,15 @@ func (c *mockChannel) ConfigForKey(key string, defaultValue interface{}) interfa return value } +func (c *mockChannel) StringConfigForKey(key string, defaultValue string) string { + val := c.ConfigForKey(key, defaultValue) + str, isStr := val.(string) + if !isStr { + return defaultValue + } + return str +} + // NewMockChannel creates a new mock channel for the passed in type, address, country and config func NewMockChannel(uuid string, channelType string, address string, country string, config map[string]interface{}) Channel { cUUID, _ := NewChannelUUID(uuid) diff --git a/utils/http.go b/utils/http.go index f9effe932..e7972ff35 100644 --- a/utils/http.go +++ b/utils/http.go @@ -1,6 +1,7 @@ package utils import ( + "crypto/tls" "fmt" "io/ioutil" "net/http" @@ -35,6 +36,28 @@ const ( RRStatusFailure RequestResponseStatus = "E" ) +// MakeInsecureHTTPRequest fires the passed in http request against a transport that does not validate +// SSL certificates. +func MakeInsecureHTTPRequest(req *http.Request) (*RequestResponse, error) { + start := time.Now() + requestTrace, err := httputil.DumpRequestOut(req, true) + if err != nil { + rr, _ := newRRFromRequestAndError(req, string(requestTrace), err) + return rr, err + } + + resp, err := GetInsecureHTTPClient().Do(req) + if err != nil { + rr, _ := newRRFromRequestAndError(req, string(requestTrace), err) + return rr, err + } + defer resp.Body.Close() + + rr, err := newRRFromResponse(string(requestTrace), resp) + rr.Elapsed = time.Now().Sub(start) + return rr, err +} + // MakeHTTPRequest fires the passed in http request, returning any errors encountered. RequestResponse is always set // regardless of any errors being set func MakeHTTPRequest(req *http.Request) (*RequestResponse, error) { @@ -139,3 +162,18 @@ func GetHTTPClient() *http.Client { return client } + +// GetInsecureHTTPClient returns the shared HTTP client used by all Courier threads +func GetInsecureHTTPClient() *http.Client { + once.Do(func() { + timeout := time.Duration(30 * time.Second) + transport = &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client = &http.Client{Transport: transport, Timeout: timeout} + }) + + return client +}