diff --git a/handlers/facebookapp/facebookapp.go b/handlers/facebookapp/facebookapp.go index b4c879a0b..b81b4aba8 100644 --- a/handlers/facebookapp/facebookapp.go +++ b/handlers/facebookapp/facebookapp.go @@ -10,6 +10,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" "time" @@ -28,6 +29,8 @@ var ( signatureHeader = "X-Hub-Signature" + configCWAPhoneNumberID = "cwa_phone_number_id" + // max for the body maxMsgLength = 1000 @@ -56,6 +59,17 @@ const ( payloadKey = "payload" ) +var waStatusMapping = map[string]courier.MsgStatusValue{ + "sent": courier.MsgSent, + "delivered": courier.MsgDelivered, + "read": courier.MsgDelivered, + "failed": courier.MsgFailed, +} + +var waIgnoreStatuses = map[string]bool{ + "deleted": true, +} + func newHandler(channelType courier.ChannelType, name string, useUUIDRoutes bool) courier.ChannelHandler { return &handler{handlers.NewBaseHandlerWithParams(channelType, name, useUUIDRoutes)} } @@ -63,6 +77,7 @@ func newHandler(channelType courier.ChannelType, name string, useUUIDRoutes bool func init() { courier.RegisterHandler(newHandler("IG", "Instagram", false)) courier.RegisterHandler(newHandler("FBA", "Facebook", false)) + courier.RegisterHandler(newHandler("CWA", "Cloud API WhatsApp", false)) } @@ -104,11 +119,88 @@ type User struct { // }] // }] // } + +type cwaMedia struct { + Caption string `json:"caption"` + Filename string `json:"filename"` + ID string `json:"id"` + Mimetype string `json:"mime_type"` + SHA256 string `json:"sha256"` +} type moPayload struct { Object string `json:"object"` Entry []struct { - ID string `json:"id"` - Time int64 `json:"time"` + ID string `json:"id"` + Time int64 `json:"time"` + Changes []struct { + Field string `json:"field"` + Value struct { + MessagingProduct string `json:"messaging_product"` + Metadata *struct { + DisplayPhoneNumber string `json:"display_phone_number"` + PhoneNumberID string `json:"phone_number_id"` + } `json:"metadata"` + Contacts []struct { + Profile struct { + Name string `json:"name"` + } `json:"profile"` + WaID string `json:"wa_id"` + } `json:"contacts"` + Messages []struct { + ID string `json:"id"` + From string `json:"from"` + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Context *struct { + Forwarded bool `json:"forwarded"` + FrequentlyForwarded bool `json:"frequently_forwarded"` + From string `json:"from"` + ID string `json:"id"` + } `json:"context"` + Text struct { + Body string `json:"body"` + } `json:"text"` + Image *cwaMedia `json:"image"` + Audio *cwaMedia `json:"audio"` + Video *cwaMedia `json:"video"` + Document *cwaMedia `json:"document"` + Voice *cwaMedia `json:"voice"` + Location *struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Name string `json:"name"` + Address string `json:"address"` + } `json:"location"` + Button *struct { + Text string `json:"text"` + Payload string `json:"payload"` + } `json:"button"` + } `json:"messages"` + Statuses []struct { + ID string `json:"id"` + RecipientID string `json:"recipient_id"` + Status string `json:"status"` + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Conversation *struct { + ID string `json:"id"` + Origin *struct { + Type string `json:"type"` + } `json:"origin"` + ExpirationTimestamp int64 `json:"expiration_timestamp"` + } `json:"conversation"` + Pricing *struct { + PricingModel string `json:"pricing_model"` + Billable bool `json:"billable"` + Category string `json:"category"` + } `json:"pricing"` + } `json:"statuses"` + Errors []struct { + Code int `json:"code"` + Title string `json:"title"` + } `json:"errors"` + } `json:"value"` + } `json:"changes"` Messaging []struct { Sender Sender `json:"sender"` Recipient User `json:"recipient"` @@ -177,8 +269,8 @@ func (h *handler) GetChannel(ctx context.Context, r *http.Request) (courier.Chan } // is not a 'page' and 'instagram' object? ignore it - if payload.Object != "page" && payload.Object != "instagram" { - return nil, fmt.Errorf("object expected 'page' or 'instagram', found %s", payload.Object) + if payload.Object != "page" && payload.Object != "instagram" && payload.Object != "whatsapp_business_account" { + return nil, fmt.Errorf("object expected 'page', 'instagram' or 'whatsapp_business_account', found %s", payload.Object) } // no entries? ignore this request @@ -186,13 +278,25 @@ func (h *handler) GetChannel(ctx context.Context, r *http.Request) (courier.Chan return nil, fmt.Errorf("no entries found") } - entryID := payload.Entry[0].ID + var channelAddress string //if object is 'page' returns type FBA, if object is 'instagram' returns type IG if payload.Object == "page" { - return h.Backend().GetChannelByAddress(ctx, courier.ChannelType("FBA"), courier.ChannelAddress(entryID)) + channelAddress = payload.Entry[0].ID + return h.Backend().GetChannelByAddress(ctx, courier.ChannelType("FBA"), courier.ChannelAddress(channelAddress)) + } else if payload.Object == "instagram" { + channelAddress = payload.Entry[0].ID + return h.Backend().GetChannelByAddress(ctx, courier.ChannelType("IG"), courier.ChannelAddress(channelAddress)) } else { - return h.Backend().GetChannelByAddress(ctx, courier.ChannelType("IG"), courier.ChannelAddress(entryID)) + if len(payload.Entry[0].Changes) == 0 { + return nil, fmt.Errorf("no changes found") + } + + channelAddress = payload.Entry[0].Changes[0].Value.Metadata.DisplayPhoneNumber + if channelAddress == "" { + return nil, fmt.Errorf("no channel adress found") + } + return h.Backend().GetChannelByAddress(ctx, courier.ChannelType("CWA"), courier.ChannelAddress(channelAddress)) } } @@ -215,6 +319,30 @@ func (h *handler) receiveVerify(ctx context.Context, channel courier.Channel, w return nil, err } +func resolveMediaURL(channel courier.Channel, mediaID string) (string, error) { + token := channel.StringConfigForKey(courier.ConfigAuthToken, "") + if token == "" { + return "", fmt.Errorf("missing token for WA channel") + } + + base, _ := url.Parse(graphURL) + path, _ := url.Parse(fmt.Sprintf("/%s", mediaID)) + retreiveURL := base.ResolveReference(path) + + // set the access token as the authorization header + req, _ := http.NewRequest(http.MethodGet, retreiveURL.String(), nil) + //req.Header.Set("User-Agent", utils.HTTPUserAgent) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + resp, err := utils.MakeHTTPRequest(req) + if err != nil { + return "", err + } + + mediaURL, err := jsonparser.GetString(resp.Body, "url") + return mediaURL, err +} + // receiveEvent is our HTTP handler function for incoming messages and status updates func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request) ([]courier.Event, error) { err := h.validateSignature(r) @@ -229,7 +357,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h } // is not a 'page' and 'instagram' object? ignore it - if payload.Object != "page" && payload.Object != "instagram" { + if payload.Object != "page" && payload.Object != "instagram" && payload.Object != "whatsapp_business_account" { return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request") } @@ -238,6 +366,150 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request, no entries") } + var events []courier.Event + var data []interface{} + + if channel.ChannelType() == "FBA" || channel.ChannelType() == "IG" { + events, data, err = h.processFacebookInstagramPayload(ctx, channel, payload, w, r) + } else { + events, data, err = h.processCloudWhatsAppPayload(ctx, channel, payload, w, r) + + } + + if err != nil { + return nil, err + } + + return events, courier.WriteDataResponse(ctx, w, http.StatusOK, "Events Handled", data) +} + +func (h *handler) processCloudWhatsAppPayload(ctx context.Context, channel courier.Channel, payload *moPayload, w http.ResponseWriter, r *http.Request) ([]courier.Event, []interface{}, error) { + // the list of events we deal with + events := make([]courier.Event, 0, 2) + + // the list of data we will return in our response + data := make([]interface{}, 0, 2) + + var contactNames = make(map[string]string) + + // for each entry + for _, entry := range payload.Entry { + if len(entry.Changes) == 0 { + continue + } + + for _, change := range entry.Changes { + + for _, contact := range change.Value.Contacts { + contactNames[contact.WaID] = contact.Profile.Name + } + + for _, msg := range change.Value.Messages { + // create our date from the timestamp + ts, err := strconv.ParseInt(msg.Timestamp, 10, 64) + if err != nil { + return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("invalid timestamp: %s", msg.Timestamp)) + } + date := time.Unix(ts, 0).UTC() + + urn, err := urns.NewWhatsAppURN(msg.From) + if err != nil { + return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + } + + text := "" + mediaURL := "" + + if msg.Type == "text" { + text = msg.Text.Body + } else if msg.Type == "audio" && msg.Audio != nil { + text = msg.Audio.Caption + mediaURL, err = resolveMediaURL(channel, msg.Audio.ID) + } else if msg.Type == "voice" && msg.Voice != nil { + text = msg.Voice.Caption + mediaURL, err = resolveMediaURL(channel, msg.Voice.ID) + } else if msg.Type == "button" && msg.Button != nil { + text = msg.Button.Text + } else if msg.Type == "document" && msg.Document != nil { + text = msg.Document.Caption + mediaURL, err = resolveMediaURL(channel, msg.Document.ID) + } else if msg.Type == "image" && msg.Image != nil { + text = msg.Image.Caption + mediaURL, err = resolveMediaURL(channel, msg.Image.ID) + } else if msg.Type == "video" && msg.Video != nil { + text = msg.Video.Caption + mediaURL, err = resolveMediaURL(channel, msg.Video.ID) + } else if msg.Type == "location" && msg.Location != nil { + mediaURL = fmt.Sprintf("geo:%f,%f", msg.Location.Latitude, msg.Location.Longitude) + } else { + // we received a message type we do not support. + courier.LogRequestError(r, channel, fmt.Errorf("unsupported message type %s", msg.Type)) + } + + // create our message + ev := h.Backend().NewIncomingMsg(channel, urn, text).WithReceivedOn(date).WithExternalID(msg.ID).WithContactName(contactNames[msg.From]) + event := h.Backend().CheckExternalIDSeen(ev) + + // we had an error downloading media + if err != nil { + courier.LogRequestError(r, channel, err) + } + + if mediaURL != "" { + event.WithAttachment(mediaURL) + } + + err = h.Backend().WriteMsg(ctx, event) + if err != nil { + return nil, nil, err + } + + h.Backend().WriteExternalIDSeen(event) + + events = append(events, event) + data = append(data, courier.NewMsgReceiveData(event)) + + } + + for _, status := range change.Value.Statuses { + + msgStatus, found := waStatusMapping[status.Status] + if !found { + if waIgnoreStatuses[status.Status] { + data = append(data, courier.NewInfoData(fmt.Sprintf("ignoring status: %s", status.Status))) + } else { + handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("unknown status: %s", status.Status)) + } + continue + } + + event := h.Backend().NewMsgStatusForExternalID(channel, status.ID, msgStatus) + err := h.Backend().WriteMsgStatus(ctx, event) + + // we don't know about this message, just tell them we ignored it + if err == courier.ErrMsgNotFound { + data = append(data, courier.NewInfoData(fmt.Sprintf("message id: %s not found, ignored", status.ID))) + continue + } + + if err != nil { + return nil, nil, err + } + + events = append(events, event) + data = append(data, courier.NewStatusData(event)) + + } + + } + + } + return events, data, nil +} + +func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel courier.Channel, payload *moPayload, w http.ResponseWriter, r *http.Request) ([]courier.Event, []interface{}, error) { + var err error + // the list of events we deal with events := make([]courier.Event, 0, 2) @@ -273,12 +545,12 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h if payload.Object == "instagram" { urn, err = urns.NewInstagramURN(sender) if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) } } else { urn, err = urns.NewFacebookURN(sender) if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) } } @@ -292,7 +564,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h if msg.OptIn.UserRef != "" { urn, err = urns.NewFacebookURN(urns.FacebookRefPrefix + msg.OptIn.UserRef) if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) } } @@ -306,7 +578,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h err := h.Backend().WriteChannelEvent(ctx, event) if err != nil { - return nil, err + return nil, nil, err } events = append(events, event) @@ -341,7 +613,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h err := h.Backend().WriteChannelEvent(ctx, event) if err != nil { - return nil, err + return nil, nil, err } events = append(events, event) @@ -370,7 +642,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h err := h.Backend().WriteChannelEvent(ctx, event) if err != nil { - return nil, err + return nil, nil, err } events = append(events, event) @@ -435,7 +707,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h err := h.Backend().WriteMsg(ctx, event) if err != nil { - return nil, err + return nil, nil, err } h.Backend().WriteExternalIDSeen(event) @@ -456,7 +728,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h } if err != nil { - return nil, err + return nil, nil, err } events = append(events, event) @@ -468,7 +740,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h } } - return events, courier.WriteDataResponse(ctx, w, http.StatusOK, "Events Handled", data) + return events, data, nil } // { @@ -516,6 +788,16 @@ type mtQuickReply struct { } func (h *handler) SendMsg(ctx context.Context, msg courier.Msg) (courier.MsgStatus, error) { + if msg.Channel().ChannelType() == "FBA" || msg.Channel().ChannelType() == "IG" { + return h.sendFacebookInstagramMsg(ctx, msg) + } else if msg.Channel().ChannelType() == "CWA" { + return h.sendCloudAPIWhatsappMsg(ctx, msg) + } + + return nil, fmt.Errorf("unssuported channel type") +} + +func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg) (courier.MsgStatus, error) { // can't do anything without an access token accessToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") if accessToken == "" { @@ -661,8 +943,346 @@ func (h *handler) SendMsg(ctx context.Context, msg courier.Msg) (courier.MsgStat return status, nil } +type cwaMTMedia struct { + ID string `json:"id,omitempty"` + Link string `json:"link,omitempty"` + Caption string `json:"caption,omitempty"` + Filename string `json:"filename,omitempty"` +} + +type cwaMTSection struct { + Title string `json:"title,omitempty"` + Rows []cwaMTSectionRow `json:"rows" validate:"required"` +} + +type cwaMTSectionRow struct { + ID string `json:"id" validate:"required"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` +} + +type cwaMTButton struct { + Type string `json:"type" validate:"required"` + Reply struct { + ID string `json:"id" validate:"required"` + Title string `json:"title" validate:"required"` + } `json:"reply" validate:"required"` +} + +type cwaParam struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type cwaComponent struct { + Type string `json:"type"` + SubType string `json:"sub_type"` + Index string `json:"index"` + Params []*cwaParam `json:"parameters"` +} + +type cwaText struct { + Body string `json:"body"` +} + +type cwaLanguage struct { + Policy string `json:"policy"` + Code string `json:"code"` +} + +type cwaTemplate struct { + Name string `json:"name"` + Language *cwaLanguage `json:"language"` + Components []*cwaComponent `json:"components"` +} + +type cwaInteractive struct { + Type string `json:"type"` + Header *struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Video string `json:"video,omitempty"` + Image string `json:"image,omitempty"` + Document string `json:"document,omitempty"` + } `json:"header,omitempty"` + Body struct { + Text string `json:"text"` + } `json:"body" validate:"required"` + Footer *struct { + Text string `json:"text"` + } `json:"footer,omitempty"` + Action *struct { + Button string `json:"button,omitempty"` + Sections []cwaMTSection `json:"sections,omitempty"` + Buttons []cwaMTButton `json:"buttons,omitempty"` + } `json:"action,omitempty"` +} + +type cwaMTPayload struct { + MessagingProduct string `json:"messaging_product"` + PreviewURL bool `json:"preview_url"` + RecipientType string `json:"recipient_type"` + To string `json:"to"` + Type string `json:"type"` + + Text *cwaText `json:"text,omitempty"` + + Document *cwaMTMedia `json:"document,omitempty"` + Image *cwaMTMedia `json:"image,omitempty"` + Audio *cwaMTMedia `json:"audio,omitempty"` + Video *cwaMTMedia `json:"video,omitempty"` + + Interactive *cwaInteractive `json:"interactive,omitempty"` + + Template *cwaTemplate `json:"template,omitempty"` +} + +type cwaMTResponse struct { + Messages []*struct { + ID string `json:"id"` + } `json:"messages"` +} + +func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg) (courier.MsgStatus, error) { + // can't do anything without an access token + accessToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") + if accessToken == "" { + return nil, fmt.Errorf("missing access token") + } + + phoneNumberId := msg.Channel().StringConfigForKey(configCWAPhoneNumberID, "") + if phoneNumberId == "" { + return nil, fmt.Errorf("missing CWA phone number ID") + } + + base, _ := url.Parse(graphURL) + path, _ := url.Parse(fmt.Sprintf("/%s/messages", phoneNumberId)) + cwaPhoneURL := base.ResolveReference(path) + + status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored) + + msgParts := make([]string, 0) + if msg.Text() != "" { + msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) + } + qrs := msg.QuickReplies() + + for i := 0; i < len(msgParts)+len(msg.Attachments()); i++ { + payload := cwaMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path()} + + if len(msg.Attachments()) == 0 { + // do we have a template? + var templating *MsgTemplating + templating, err := h.getTemplate(msg) + if err != nil { + return nil, errors.Wrapf(err, "unable to decode template: %s for channel: %s", string(msg.Metadata()), msg.Channel().UUID()) + } + if templating != nil { + + payload.Type = "template" + + template := cwaTemplate{Name: templating.Template.Name, Language: &cwaLanguage{Policy: "deterministic", Code: templating.Language}} + payload.Template = &template + + component := &cwaComponent{Type: "body"} + + for _, v := range templating.Variables { + component.Params = append(component.Params, &cwaParam{Type: "text", Text: v}) + } + template.Components = append(payload.Template.Components, component) + + } else { + if i < (len(msgParts) + len(msg.Attachments()) - 1) { + // this is still a msg part + payload.Type = "text" + payload.Text = &cwaText{Body: msgParts[i-len(msg.Attachments())]} + } else { + if len(qrs) > 0 { + payload.Type = "interactive" + // We can use buttons + if len(qrs) <= 3 { + interactive := cwaInteractive{Type: "button", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + btns := make([]cwaMTButton, len(qrs)) + for i, qr := range qrs { + btns[i] = cwaMTButton{ + Type: "reply", + } + btns[i].Reply.ID = fmt.Sprint(i) + btns[i].Reply.Title = qr + } + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []cwaMTSection "json:\"sections,omitempty\"" + Buttons []cwaMTButton "json:\"buttons,omitempty\"" + }{Buttons: btns} + payload.Interactive = &interactive + } else if len(qrs) <= 10 { + interactive := cwaInteractive{Type: "list", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + section := cwaMTSection{ + Rows: make([]cwaMTSectionRow, len(qrs)), + } + for i, qr := range qrs { + section.Rows[i] = cwaMTSectionRow{ + ID: fmt.Sprint(i), + Title: qr, + } + } + + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []cwaMTSection "json:\"sections,omitempty\"" + Buttons []cwaMTButton "json:\"buttons,omitempty\"" + }{Button: "Menu", Sections: []cwaMTSection{ + section, + }} + + payload.Interactive = &interactive + } else { + return nil, fmt.Errorf("too many quick replies CWA supports only up to 10 quick replies") + } + } else { + // this is still a msg part + payload.Type = "text" + payload.Text = &cwaText{Body: msgParts[i-len(msg.Attachments())]} + } + } + } + + } else if i < len(msg.Attachments()) { + attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) + attType = strings.Split(attType, "/")[0] + if attType == "application" { + attType = "document" + } + payload.Type = attType + media := cwaMTMedia{Link: attURL} + + if attType == "image" { + payload.Image = &media + } else if attType == "audio" { + payload.Audio = &media + } else if attType == "video" { + payload.Video = &media + } else if attType == "document" { + payload.Document = &media + } + } else { + if i < (len(msgParts) + len(msg.Attachments()) - 1) { + // this is still a msg part + payload.Type = "text" + payload.Text = &cwaText{Body: msgParts[i-len(msg.Attachments())]} + } else { + if len(qrs) > 0 { + payload.Type = "interactive" + // We can use buttons + if len(qrs) <= 3 { + interactive := cwaInteractive{Type: "button", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + btns := make([]cwaMTButton, len(qrs)) + for i, qr := range qrs { + btns[i] = cwaMTButton{ + Type: "reply", + } + btns[i].Reply.ID = fmt.Sprint(i) + btns[i].Reply.Title = qr + } + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []cwaMTSection "json:\"sections,omitempty\"" + Buttons []cwaMTButton "json:\"buttons,omitempty\"" + }{Buttons: btns} + payload.Interactive = &interactive + + } else if len(qrs) <= 10 { + interactive := cwaInteractive{Type: "list", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + section := cwaMTSection{ + Rows: make([]cwaMTSectionRow, len(qrs)), + } + for i, qr := range qrs { + section.Rows[i] = cwaMTSectionRow{ + ID: fmt.Sprint(i), + Title: qr, + } + } + + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []cwaMTSection "json:\"sections,omitempty\"" + Buttons []cwaMTButton "json:\"buttons,omitempty\"" + }{Button: "Menu", Sections: []cwaMTSection{ + section, + }} + + payload.Interactive = &interactive + } else { + return nil, fmt.Errorf("too many quick replies CWA supports only up to 10 quick replies") + } + } else { + // this is still a msg part + payload.Type = "text" + payload.Text = &cwaText{Body: msgParts[i-len(msg.Attachments())]} + } + } + + } + + jsonBody, err := json.Marshal(payload) + if err != nil { + return status, err + } + + req, err := http.NewRequest(http.MethodPost, cwaPhoneURL.String(), bytes.NewReader(jsonBody)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + rr, err := utils.MakeHTTPRequest(req) + + // record our status and log + log := courier.NewChannelLogFromRR("Message Sent", msg.Channel(), msg.ID(), rr).WithError("Message Send Error", err) + status.AddLog(log) + if err != nil { + return status, nil + } + + respPayload := &cwaMTResponse{} + err = json.Unmarshal(rr.Body, respPayload) + if err != nil { + log.WithError("Message Send Error", errors.Errorf("unable to unmarshal response body")) + return status, nil + } + externalID := respPayload.Messages[0].ID + if i == 0 && externalID != "" { + status.SetExternalID(externalID) + } + // this was wired successfully + status.SetStatus(courier.MsgWired) + + } + return status, nil +} + // DescribeURN looks up URN metadata for new contacts func (h *handler) DescribeURN(ctx context.Context, channel courier.Channel, urn urns.URN) (map[string]string, error) { + if channel.ChannelType() == "CWA" { + return map[string]string{}, nil + + } + // can't do anything with facebook refs, ignore them if urn.IsFacebookRef() { return map[string]string{}, nil @@ -746,3 +1366,130 @@ func fbCalculateSignature(appSecret string, body []byte) (string, error) { return hex.EncodeToString(mac.Sum(nil)), nil } + +func (h *handler) getTemplate(msg courier.Msg) (*MsgTemplating, error) { + mdJSON := msg.Metadata() + if len(mdJSON) == 0 { + return nil, nil + } + metadata := &TemplateMetadata{} + err := json.Unmarshal(mdJSON, metadata) + if err != nil { + return nil, err + } + templating := metadata.Templating + if templating == nil { + return nil, nil + } + + // check our template is valid + err = handlers.Validate(templating) + if err != nil { + return nil, errors.Wrapf(err, "invalid templating definition") + } + // check country + if templating.Country != "" { + templating.Language = fmt.Sprintf("%s_%s", templating.Language, templating.Country) + } + + // map our language from iso639-3_iso3166-2 to the WA country / iso638-2 pair + language, found := languageMap[templating.Language] + if !found { + return nil, fmt.Errorf("unable to find mapping for language: %s", templating.Language) + } + templating.Language = language + + return templating, err +} + +type TemplateMetadata struct { + Templating *MsgTemplating `json:"templating"` +} + +type MsgTemplating struct { + Template struct { + Name string `json:"name" validate:"required"` + UUID string `json:"uuid" validate:"required"` + } `json:"template" validate:"required,dive"` + Language string `json:"language" validate:"required"` + Country string `json:"country"` + Namespace string `json:"namespace"` + Variables []string `json:"variables"` +} + +// mapping from iso639-3_iso3166-2 to WA language code +var languageMap = map[string]string{ + "afr": "af", // Afrikaans + "sqi": "sq", // Albanian + "ara": "ar", // Arabic + "aze": "az", // Azerbaijani + "ben": "bn", // Bengali + "bul": "bg", // Bulgarian + "cat": "ca", // Catalan + "zho": "zh_CN", // Chinese + "zho_CN": "zh_CN", // Chinese (CHN) + "zho_HK": "zh_HK", // Chinese (HKG) + "zho_TW": "zh_TW", // Chinese (TAI) + "hrv": "hr", // Croatian + "ces": "cs", // Czech + "dah": "da", // Danish + "nld": "nl", // Dutch + "eng": "en", // English + "eng_GB": "en_GB", // English (UK) + "eng_US": "en_US", // English (US) + "est": "et", // Estonian + "fil": "fil", // Filipino + "fin": "fi", // Finnish + "fra": "fr", // French + "kat": "ka", // Georgian + "deu": "de", // German + "ell": "el", // Greek + "guj": "gu", // Gujarati + "hau": "ha", // Hausa + "enb": "he", // Hebrew + "hin": "hi", // Hindi + "hun": "hu", // Hungarian + "ind": "id", // Indonesian + "gle": "ga", // Irish + "ita": "it", // Italian + "jpn": "ja", // Japanese + "kan": "kn", // Kannada + "kaz": "kk", // Kazakh + "kin": "rw_RW", // Kinyarwanda + "kor": "ko", // Korean + "kir": "ky_KG", // Kyrgyzstan + "lao": "lo", // Lao + "lav": "lv", // Latvian + "lit": "lt", // Lithuanian + "mal": "ml", // Malayalam + "mkd": "mk", // Macedonian + "msa": "ms", // Malay + "mar": "mr", // Marathi + "nob": "nb", // Norwegian + "fas": "fa", // Persian + "pol": "pl", // Polish + "por": "pt_PT", // Portuguese + "por_BR": "pt_BR", // Portuguese (BR) + "por_PT": "pt_PT", // Portuguese (POR) + "pan": "pa", // Punjabi + "ron": "ro", // Romanian + "rus": "ru", // Russian + "srp": "sr", // Serbian + "slk": "sk", // Slovak + "slv": "sl", // Slovenian + "spa": "es", // Spanish + "spa_AR": "es_AR", // Spanish (ARG) + "spa_ES": "es_ES", // Spanish (SPA) + "spa_MX": "es_MX", // Spanish (MEX) + "swa": "sw", // Swahili + "swe": "sv", // Swedish + "tam": "ta", // Tamil + "tel": "te", // Telugu + "tha": "th", // Thai + "tur": "tr", // Turkish + "ukr": "uk", // Ukrainian + "urd": "ur", // Urdu + "uzb": "uz", // Uzbek + "vie": "vi", // Vietnamese + "zul": "zu", // Zulu +} diff --git a/handlers/facebookapp/facebookapp_test.go b/handlers/facebookapp/facebookapp_test.go index 9ec8576c0..c5a526ebe 100644 --- a/handlers/facebookapp/facebookapp_test.go +++ b/handlers/facebookapp/facebookapp_test.go @@ -2,6 +2,7 @@ package facebookapp import ( "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -24,6 +25,10 @@ var testChannelsIG = []courier.Channel{ courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "IG", "12345", "", map[string]interface{}{courier.ConfigAuthToken: "a123"}), } +var testChannelsCWA = []courier.Channel{ + courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "CWA", "12345", "", map[string]interface{}{courier.ConfigAuthToken: "a123"}), +} + var testCasesFBA = []ChannelHandleTestCase{ {Label: "Receive Message FBA", URL: "/c/fba/receive", Data: string(courier.ReadFile("./testdata/fba/helloMsgFBA.json")), Status: 200, Response: "Handled", NoQueueErrorCheck: true, NoInvalidChannelCheck: true, Text: Sp("Hello World"), URN: Sp("facebook:5678"), ExternalID: Sp("external_id"), Date: Tp(time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC)), @@ -77,7 +82,7 @@ var testCasesFBA = []ChannelHandleTestCase{ {Label: "Different Page", URL: "/c/fba/receive", Data: string(courier.ReadFile("./testdata/fba/differentPageFBA.json")), Status: 200, Response: `"data":[]`, PrepRequest: addValidSignature}, {Label: "Echo", URL: "/c/fba/receive", Data: string(courier.ReadFile("./testdata/fba/echoFBA.json")), Status: 200, Response: `ignoring echo`, PrepRequest: addValidSignature}, - {Label: "Not Page", URL: "/c/fba/receive", Data: string(courier.ReadFile("./testdata/fba/notPage.json")), Status: 400, Response: "object expected 'page' or 'instagram', found notpage", PrepRequest: addValidSignature}, + {Label: "Not Page", URL: "/c/fba/receive", Data: string(courier.ReadFile("./testdata/fba/notPage.json")), Status: 400, Response: "object expected 'page', 'instagram' or 'whatsapp_business_account', found notpage", PrepRequest: addValidSignature}, {Label: "No Entries", URL: "/c/fba/receive", Data: string(courier.ReadFile("./testdata/fba/noEntriesFBA.json")), Status: 400, Response: "no entries found", PrepRequest: addValidSignature}, {Label: "No Messaging Entries", URL: "/c/fba/receive", Data: string(courier.ReadFile("./testdata/fba/noMessagingEntriesFBA.json")), Status: 200, Response: "Handled", PrepRequest: addValidSignature}, {Label: "Unknown Messaging Entry", URL: "/c/fba/receive", Data: string(courier.ReadFile("./testdata/fba/unknownMessagingEntryFBA.json")), Status: 200, Response: "Handled", PrepRequest: addValidSignature}, @@ -110,7 +115,7 @@ var testCasesIG = []ChannelHandleTestCase{ {Label: "Different Page", URL: "/c/ig/receive", Data: string(courier.ReadFile("./testdata/ig/differentPageIG.json")), Status: 200, Response: `"data":[]`, PrepRequest: addValidSignature}, {Label: "Echo", URL: "/c/ig/receive", Data: string(courier.ReadFile("./testdata/ig/echoIG.json")), Status: 200, Response: `ignoring echo`, PrepRequest: addValidSignature}, {Label: "No Entries", URL: "/c/ig/receive", Data: string(courier.ReadFile("./testdata/ig/noEntriesIG.json")), Status: 400, Response: "no entries found", PrepRequest: addValidSignature}, - {Label: "Not Instagram", URL: "/c/ig/receive", Data: string(courier.ReadFile("./testdata/ig/notInstagram.json")), Status: 400, Response: "object expected 'page' or 'instagram', found notinstagram", PrepRequest: addValidSignature}, + {Label: "Not Instagram", URL: "/c/ig/receive", Data: string(courier.ReadFile("./testdata/ig/notInstagram.json")), Status: 400, Response: "object expected 'page', 'instagram' or 'whatsapp_business_account', found notinstagram", PrepRequest: addValidSignature}, {Label: "No Messaging Entries", URL: "/c/ig/receive", Data: string(courier.ReadFile("./testdata/ig/noMessagingEntriesIG.json")), Status: 200, Response: "Handled", PrepRequest: addValidSignature}, {Label: "Unknown Messaging Entry", URL: "/c/ig/receive", Data: string(courier.ReadFile("./testdata/ig/unknownMessagingEntryIG.json")), Status: 200, Response: "Handled", PrepRequest: addValidSignature}, {Label: "Not JSON", URL: "/c/ig/receive", Data: "not JSON", Status: 400, Response: "Error", PrepRequest: addValidSignature}, @@ -213,10 +218,111 @@ func TestDescribeIG(t *testing.T) { } } +func TestDescribeCWA(t *testing.T) { + handler := newHandler("CWA", "Cloud API WhatsApp", false).(courier.URNDescriber) + + tcs := []struct { + urn urns.URN + metadata map[string]string + }{{"whatsapp:1337", map[string]string{}}, + {"whatsapp:4567", map[string]string{}}} + + for _, tc := range tcs { + metadata, _ := handler.DescribeURN(context.Background(), testChannelsCWA[0], tc.urn) + assert.Equal(t, metadata, tc.metadata) + } +} + +var cwaReceiveURL = "/c/cwa/receive" + +var testCasesCWA = []ChannelHandleTestCase{ + {Label: "Receive Message CWA", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/helloCWA.json")), Status: 200, Response: "Handled", NoQueueErrorCheck: true, NoInvalidChannelCheck: true, + Text: Sp("Hello World"), URN: Sp("whatsapp:5678"), ExternalID: Sp("external_id"), Date: Tp(time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC)), + PrepRequest: addValidSignature}, + {Label: "Receive Duplicate Valid Message", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/duplicateCWA.json")), Status: 200, Response: "Handled", NoQueueErrorCheck: true, NoInvalidChannelCheck: true, + Text: Sp("Hello World"), URN: Sp("whatsapp:5678"), ExternalID: Sp("external_id"), Date: Tp(time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC)), + PrepRequest: addValidSignature}, + + {Label: "Receive Valid Voice Message", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/voiceCWA.json")), Status: 200, Response: "Handled", NoQueueErrorCheck: true, NoInvalidChannelCheck: true, + Text: Sp(""), URN: Sp("whatsapp:5678"), ExternalID: Sp("external_id"), Attachment: Sp("https://foo.bar/attachmentURL_Voice"), Date: Tp(time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC)), + PrepRequest: addValidSignature}, + + {Label: "Receive Valid Button Message", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/buttonCWA.json")), Status: 200, Response: "Handled", NoQueueErrorCheck: true, NoInvalidChannelCheck: true, + Text: Sp("No"), URN: Sp("whatsapp:5678"), ExternalID: Sp("external_id"), Date: Tp(time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC)), + PrepRequest: addValidSignature}, + + {Label: "Receive Valid Document Message", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/documentCWA.json")), Status: 200, Response: "Handled", NoQueueErrorCheck: true, NoInvalidChannelCheck: true, + Text: Sp("80skaraokesonglistartist"), URN: Sp("whatsapp:5678"), ExternalID: Sp("external_id"), Attachment: Sp("https://foo.bar/attachmentURL_Document"), Date: Tp(time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC)), + PrepRequest: addValidSignature}, + {Label: "Receive Valid Image Message", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/imageCWA.json")), Status: 200, Response: "Handled", NoQueueErrorCheck: true, NoInvalidChannelCheck: true, + Text: Sp("Check out my new phone!"), URN: Sp("whatsapp:5678"), ExternalID: Sp("external_id"), Attachment: Sp("https://foo.bar/attachmentURL_Image"), Date: Tp(time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC)), + PrepRequest: addValidSignature}, + {Label: "Receive Valid Video Message", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/videoCWA.json")), Status: 200, Response: "Handled", NoQueueErrorCheck: true, NoInvalidChannelCheck: true, + Text: Sp("Check out my new phone!"), URN: Sp("whatsapp:5678"), ExternalID: Sp("external_id"), Attachment: Sp("https://foo.bar/attachmentURL_Video"), Date: Tp(time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC)), + PrepRequest: addValidSignature}, + {Label: "Receive Valid Audio Message", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/audioCWA.json")), Status: 200, Response: "Handled", NoQueueErrorCheck: true, NoInvalidChannelCheck: true, + Text: Sp("Check out my new phone!"), URN: Sp("whatsapp:5678"), ExternalID: Sp("external_id"), Attachment: Sp("https://foo.bar/attachmentURL_Audio"), Date: Tp(time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC)), + PrepRequest: addValidSignature}, + {Label: "Receive Valid Location Message", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/locationCWA.json")), Status: 200, Response: `"type":"msg"`, + Text: Sp(""), Attachment: Sp("geo:0.000000,1.000000"), URN: Sp("whatsapp:5678"), ExternalID: Sp("external_id"), Date: Tp(time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC)), + PrepRequest: addValidSignature}, + + {Label: "Receive Invalid JSON", URL: cwaReceiveURL, Data: "not json", Status: 400, Response: "unable to parse", PrepRequest: addValidSignature}, + {Label: "Receive Invalid JSON", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/invalidFrom.json")), Status: 400, Response: "invalid whatsapp id", PrepRequest: addValidSignature}, + {Label: "Receive Invalid JSON", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/invalidTimestamp.json")), Status: 400, Response: "invalid timestamp", PrepRequest: addValidSignature}, + + {Label: "Receive Valid Status", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/validStatusCWA.json")), Status: 200, Response: `"type":"status"`, + MsgStatus: Sp("S"), ExternalID: Sp("external_id"), PrepRequest: addValidSignature}, + {Label: "Receive Invalid Status", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/invalidStatusCWA.json")), Status: 400, Response: `"unknown status: in_orbit"`, PrepRequest: addValidSignature}, + {Label: "Receive Ignore Status", URL: cwaReceiveURL, Data: string(courier.ReadFile("./testdata/cwa/ignoreStatusCWA.json")), Status: 200, Response: `"ignoring status: deleted"`, PrepRequest: addValidSignature}, +} + func TestHandler(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accessToken := r.Header.Get("Authorization") + defer r.Body.Close() + + // invalid auth token + if accessToken != "Bearer a123" { + fmt.Printf("Access token: %s\n", accessToken) + http.Error(w, "invalid auth token", 403) + return + } + + if strings.HasSuffix(r.URL.Path, "image") { + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Image"}`)) + return + } + + if strings.HasSuffix(r.URL.Path, "audio") { + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Audio"}`)) + return + } + + if strings.HasSuffix(r.URL.Path, "voice") { + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Voice"}`)) + return + } + + if strings.HasSuffix(r.URL.Path, "video") { + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Video"}`)) + return + } + + if strings.HasSuffix(r.URL.Path, "document") { + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Document"}`)) + return + } + + // valid token + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL"}`)) + + })) + graphURL = server.URL + + RunChannelTestCases(t, testChannelsCWA, newHandler("CWA", "Cloud API WhatsApp", false), testCasesCWA) RunChannelTestCases(t, testChannelsFBA, newHandler("FBA", "Facebook", false), testCasesFBA) RunChannelTestCases(t, testChannelsIG, newHandler("IG", "Instagram", false), testCasesIG) - } func BenchmarkHandler(b *testing.B) { @@ -256,6 +362,7 @@ func TestVerify(t *testing.T) { // setSendURL takes care of setting the send_url to our test server host func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { sendURL = s.URL + graphURL = s.URL } var SendTestCasesFBA = []ChannelSendTestCase{ @@ -387,13 +494,210 @@ var SendTestCasesIG = []ChannelSendTestCase{ SendPrep: setSendURL}, } +var SendTestCasesCWA = []ChannelSendTestCase{ + {Label: "Plain Send", + Text: "Simple Message", URN: "whatsapp:250788123123", Path: "/12345_ID/messages", + Status: "W", ExternalID: "157b5e14568e8", + ResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, ResponseStatus: 201, + RequestBody: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"Simple Message"}}`, + SendPrep: setSendURL}, + {Label: "Unicode Send", + Text: "☺", URN: "whatsapp:250788123123", Path: "/12345_ID/messages", + Status: "W", ExternalID: "157b5e14568e8", + ResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, ResponseStatus: 201, + RequestBody: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"☺"}}`, + SendPrep: setSendURL}, + {Label: "Audio Send", + Text: "audio caption", + URN: "whatsapp:250788123123", + Status: "W", ExternalID: "157b5e14568e8", + Attachments: []string{"audio/mpeg:https://foo.bar/audio.mp3"}, + Responses: map[MockedRequest]MockedResponse{ + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"audio caption"}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + }, + SendPrep: setSendURL}, + {Label: "Document Send", + Text: "document caption", + URN: "whatsapp:250788123123", + Status: "W", ExternalID: "157b5e14568e8", + Attachments: []string{"application/pdf:https://foo.bar/document.pdf"}, + Responses: map[MockedRequest]MockedResponse{ + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"https://foo.bar/document.pdf"}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"document caption"}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + }, + SendPrep: setSendURL}, + + {Label: "Image Send", + Text: "document caption", + URN: "whatsapp:250788123123", + Status: "W", ExternalID: "157b5e14568e8", + Attachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + Responses: map[MockedRequest]MockedResponse{ + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg"}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"document caption"}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + }, + SendPrep: setSendURL}, + {Label: "Video Send", + Text: "video caption", + URN: "whatsapp:250788123123", + Status: "W", ExternalID: "157b5e14568e8", + Attachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + Responses: map[MockedRequest]MockedResponse{ + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"https://foo.bar/video.mp4"}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"video caption"}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + }, + SendPrep: setSendURL}, + + {Label: "Template Send", + Text: "templated message", + URN: "whatsapp:250788123123", + Status: "W", ExternalID: "157b5e14568e8", + Metadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "language": "eng", "variables": ["Chef", "tomorrow"]}}`), + ResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, ResponseStatus: 200, + RequestBody: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"template","template":{"name":"revive_issue","language":{"policy":"deterministic","code":"en"},"components":[{"type":"body","sub_type":"","index":"","parameters":[{"type":"text","text":"Chef"},{"type":"text","text":"tomorrow"}]}]}}`, + SendPrep: setSendURL, + }, + + {Label: "Template Country Language", + Text: "templated message", + URN: "whatsapp:250788123123", + Status: "W", ExternalID: "157b5e14568e8", + Metadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "language": "eng", "country": "US", "variables": ["Chef", "tomorrow"]}}`), + ResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, ResponseStatus: 200, + RequestBody: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"template","template":{"name":"revive_issue","language":{"policy":"deterministic","code":"en_US"},"components":[{"type":"body","sub_type":"","index":"","parameters":[{"type":"text","text":"Chef"},{"type":"text","text":"tomorrow"}]}]}}`, + SendPrep: setSendURL, + }, + {Label: "Template Invalid Language", + Text: "templated message", URN: "whatsapp:250788123123", + Error: `unable to decode template: {"templating": { "template": { "name": "revive_issue", "uuid": "8ca114b4-bee2-4d3b-aaf1-9aa6b48d41e8" }, "language": "bnt", "variables": ["Chef", "tomorrow"]}} for channel: 8eb23e93-5ecb-45ba-b726-3b064e0c56ab: unable to find mapping for language: bnt`, + Metadata: json.RawMessage(`{"templating": { "template": { "name": "revive_issue", "uuid": "8ca114b4-bee2-4d3b-aaf1-9aa6b48d41e8" }, "language": "bnt", "variables": ["Chef", "tomorrow"]}}`), + }, + {Label: "Interactive Button Message Send", + Text: "Interactive Button Msg", URN: "whatsapp:250788123123", QuickReplies: []string{"BUTTON1"}, + Status: "W", ExternalID: "157b5e14568e8", + ResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, ResponseStatus: 201, + RequestBody: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + SendPrep: setSendURL}, + {Label: "Interactive List Message Send", + Text: "Interactive List Msg", URN: "whatsapp:250788123123", QuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, + Status: "W", ExternalID: "157b5e14568e8", + ResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, ResponseStatus: 201, + RequestBody: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"list","body":{"text":"Interactive List Msg"},"action":{"button":"Menu","sections":[{"rows":[{"id":"0","title":"ROW1"},{"id":"1","title":"ROW2"},{"id":"2","title":"ROW3"},{"id":"3","title":"ROW4"}]}]}}}`, + SendPrep: setSendURL}, + {Label: "Interactive Button Message Send with attachment", + Text: "Interactive Button Msg", URN: "whatsapp:250788123123", QuickReplies: []string{"BUTTON1"}, + Status: "W", ExternalID: "157b5e14568e8", + Attachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + Responses: map[MockedRequest]MockedResponse{ + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg"}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + }, + SendPrep: setSendURL}, + {Label: "Interactive List Message Send with attachment", + Text: "Interactive List Msg", URN: "whatsapp:250788123123", QuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, + Status: "W", ExternalID: "157b5e14568e8", + Attachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + Responses: map[MockedRequest]MockedResponse{ + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg"}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + MockedRequest{ + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","preview_url":false,"recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"list","body":{"text":"Interactive List Msg"},"action":{"button":"Menu","sections":[{"rows":[{"id":"0","title":"ROW1"},{"id":"1","title":"ROW2"},{"id":"2","title":"ROW3"},{"id":"3","title":"ROW4"}]}]}}}`, + }: MockedResponse{ + Status: 201, + Body: `{ "messages": [{"id": "157b5e14568e8"}] }`, + }, + }, + SendPrep: setSendURL}, +} + func TestSending(t *testing.T) { // shorter max msg length for testing maxMsgLength = 100 var ChannelFBA = courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FBA", "12345", "", map[string]interface{}{courier.ConfigAuthToken: "a123"}) var ChannelIG = courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "IG", "12345", "", map[string]interface{}{courier.ConfigAuthToken: "a123"}) + var ChannelCWA = courier.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "CWA", "12345", "", map[string]interface{}{courier.ConfigAuthToken: "a123", configCWAPhoneNumberID: "12345_ID"}) RunChannelSendTestCases(t, ChannelFBA, newHandler("FBA", "Facebook", false), SendTestCasesFBA, nil) RunChannelSendTestCases(t, ChannelIG, newHandler("IG", "Instagram", false), SendTestCasesIG, nil) + RunChannelSendTestCases(t, ChannelCWA, newHandler("CWA", "Cloud API WhatsApp", false), SendTestCasesCWA, nil) } func TestSigning(t *testing.T) { diff --git a/handlers/facebookapp/testdata/cwa/audioCWA.json b/handlers/facebookapp/testdata/cwa/audioCWA.json new file mode 100644 index 000000000..47ce575db --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/audioCWA.json @@ -0,0 +1,43 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "from": "5678", + "id": "external_id", + "audio": { + "file": "/usr/local/wamedia/shared/b1cf38-8734-4ad3-b4a1-ef0c10d0d683", + "id": "id_audio", + "mime_type": "image/jpeg", + "sha256": "29ed500fa64eb55fc19dc4124acb300e5dcc54a0f822a301ae99944db", + "caption": "Check out my new phone!" + }, + "timestamp": "1454119029", + "type": "audio" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/buttonCWA.json b/handlers/facebookapp/testdata/cwa/buttonCWA.json new file mode 100644 index 000000000..36efcca6e --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/buttonCWA.json @@ -0,0 +1,44 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "button": { + "payload": "No-Button-Payload", + "text": "No" + }, + "context": { + "from": "5678", + "id": "gBGGFmkiWVVPAgkgQkwi7IORac0" + }, + "from": "5678", + "id": "external_id", + "timestamp": "1454119029", + "type": "button" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/documentCWA.json b/handlers/facebookapp/testdata/cwa/documentCWA.json new file mode 100644 index 000000000..d65921ef4 --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/documentCWA.json @@ -0,0 +1,43 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "from": "5678", + "id": "external_id", + "timestamp": "1454119029", + "type": "document", + "document": { + "caption": "80skaraokesonglistartist", + "file": "/usr/local/wamedia/shared/fc233119-733f-49c-bcbd-b2f68f798e33", + "id": "id_document", + "mime_type": "application/pdf", + "sha256": "3b11fa6ef2bde1dd14726e09d3edaf782120919d06f6484f32d5d5caa4b8e" + } + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/duplicateCWA.json b/handlers/facebookapp/testdata/cwa/duplicateCWA.json new file mode 100644 index 000000000..f857c63f7 --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/duplicateCWA.json @@ -0,0 +1,48 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "from": "5678", + "id": "external_id", + "timestamp": "1454119029", + "text": { + "body": "Hello World" + }, + "type": "text" + }, + { + "from": "5678", + "id": "external_id", + "timestamp": "1454119029", + "text": { + "body": "Hello World" + }, + "type": "text" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/helloCWA.json b/handlers/facebookapp/testdata/cwa/helloCWA.json new file mode 100644 index 000000000..f49303d2f --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/helloCWA.json @@ -0,0 +1,39 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "from": "5678", + "id": "external_id", + "timestamp": "1454119029", + "text": { + "body": "Hello World" + }, + "type": "text" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/ignoreStatusCWA.json b/handlers/facebookapp/testdata/cwa/ignoreStatusCWA.json new file mode 100644 index 000000000..bf9f69714 --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/ignoreStatusCWA.json @@ -0,0 +1,49 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "statuses": [ + { + "id": "external_id", + "recipient_id": "5678", + "status": "deleted", + "timestamp": "1454119029", + "type": "message", + "conversation": { + "id": "CONVERSATION_ID", + "expiration_timestamp": 1454119029, + "origin": { + "type": "referral_conversion" + } + }, + "pricing": { + "pricing_model": "CBP", + "billable": false, + "category": "referral_conversion" + } + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/imageCWA.json b/handlers/facebookapp/testdata/cwa/imageCWA.json new file mode 100644 index 000000000..f06b631fc --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/imageCWA.json @@ -0,0 +1,43 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "from": "5678", + "id": "external_id", + "image": { + "file": "/usr/local/wamedia/shared/b1cf38-8734-4ad3-b4a1-ef0c10d0d683", + "id": "id_image", + "mime_type": "image/jpeg", + "sha256": "29ed500fa64eb55fc19dc4124acb300e5dcc54a0f822a301ae99944db", + "caption": "Check out my new phone!" + }, + "timestamp": "1454119029", + "type": "image" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/invalidFrom.json b/handlers/facebookapp/testdata/cwa/invalidFrom.json new file mode 100644 index 000000000..052db4a38 --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/invalidFrom.json @@ -0,0 +1,39 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "bla" + } + ], + "messages": [ + { + "from": "bla", + "id": "external_id", + "timestamp": "1454119029", + "text": { + "body": "Hello World" + }, + "type": "text" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/invalidStatusCWA.json b/handlers/facebookapp/testdata/cwa/invalidStatusCWA.json new file mode 100644 index 000000000..60676257f --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/invalidStatusCWA.json @@ -0,0 +1,49 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "statuses": [ + { + "id": "external_id", + "recipient_id": "5678", + "status": "in_orbit", + "timestamp": "1454119029", + "type": "message", + "conversation": { + "id": "CONVERSATION_ID", + "expiration_timestamp": 1454119029, + "origin": { + "type": "referral_conversion" + } + }, + "pricing": { + "pricing_model": "CBP", + "billable": false, + "category": "referral_conversion" + } + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/invalidTimestamp.json b/handlers/facebookapp/testdata/cwa/invalidTimestamp.json new file mode 100644 index 000000000..e9f301ce9 --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/invalidTimestamp.json @@ -0,0 +1,39 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "bla" + } + ], + "messages": [ + { + "from": "bla", + "id": "external_id", + "timestamp": "asdf", + "text": { + "body": "Hello World" + }, + "type": "text" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/locationCWA.json b/handlers/facebookapp/testdata/cwa/locationCWA.json new file mode 100644 index 000000000..15cedaa1f --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/locationCWA.json @@ -0,0 +1,43 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "from": "5678", + "id": "external_id", + "location": { + "address": "Main Street Beach, Santa Cruz, CA", + "latitude": 0.000000, + "longitude": 1.000000, + "name": "Main Street Beach", + "url": "https://foursquare.com/v/4d7031d35b5df7744" + }, + "timestamp": "1454119029", + "type": "location" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/validStatusCWA.json b/handlers/facebookapp/testdata/cwa/validStatusCWA.json new file mode 100644 index 000000000..0aaf2edb7 --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/validStatusCWA.json @@ -0,0 +1,49 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "statuses": [ + { + "id": "external_id", + "recipient_id": "5678", + "status": "sent", + "timestamp": "1454119029", + "type": "message", + "conversation": { + "id": "CONVERSATION_ID", + "expiration_timestamp": 1454119029, + "origin": { + "type": "referral_conversion" + } + }, + "pricing": { + "pricing_model": "CBP", + "billable": false, + "category": "referral_conversion" + } + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/videoCWA.json b/handlers/facebookapp/testdata/cwa/videoCWA.json new file mode 100644 index 000000000..210dd9a81 --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/videoCWA.json @@ -0,0 +1,43 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "from": "5678", + "id": "external_id", + "video": { + "file": "/usr/local/wamedia/shared/b1cf38-8734-4ad3-b4a1-ef0c10d0d683", + "id": "id_video", + "mime_type": "image/jpeg", + "sha256": "29ed500fa64eb55fc19dc4124acb300e5dcc54a0f822a301ae99944db", + "caption": "Check out my new phone!" + }, + "timestamp": "1454119029", + "type": "video" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/cwa/voiceCWA.json b/handlers/facebookapp/testdata/cwa/voiceCWA.json new file mode 100644 index 000000000..3e2022eff --- /dev/null +++ b/handlers/facebookapp/testdata/cwa/voiceCWA.json @@ -0,0 +1,42 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "12345", + "phone_number_id": "27681414235104944" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "from": "5678", + "id": "external_id", + "timestamp": "1454119029", + "type": "voice", + "voice": { + "file": "/usr/local/wamedia/shared/463e/b7ec/ff4e4d9bb1101879cbd411b2", + "id": "id_voice", + "mime_type": "audio/ogg; codecs=opus", + "sha256": "fa9e1807d936b7cebe63654ea3a7912b1fa9479220258d823590521ef53b0710" + } + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file