From b3c60bc9c32a1b3de66bb2760be7a5d3aafe71dc Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 5 Sep 2023 13:46:28 +0200 Subject: [PATCH 1/8] Add support to filter media by dimensions and content type precedence for media support config --- handlers/media.go | 20 +++++++++++++++++--- handlers/media_test.go | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/handlers/media.go b/handlers/media.go index 136040584..f1a51bd53 100644 --- a/handlers/media.go +++ b/handlers/media.go @@ -20,8 +20,10 @@ const ( ) type MediaTypeSupport struct { - Types []string - MaxBytes int + Types []string + MaxBytes int + MaxWidth int + MaxHeight int } // Attachment is a resolved attachment @@ -84,7 +86,10 @@ func resolveAttachment(ctx context.Context, b courier.Backend, contentType, medi } mediaType, _ := parseContentType(media.ContentType()) - mediaSupport := support[mediaType] + mediaSupport, ok := support[MediaType(media.ContentType())] + if !ok { + mediaSupport = support[mediaType] + } // our candidates are the uploaded media and any alternates of the same media type candidates := append([]courier.Media{media}, filterMediaByType(media.Alternates(), mediaType)...) @@ -99,6 +104,11 @@ func resolveAttachment(ctx context.Context, b courier.Backend, contentType, medi candidates = filterMediaBySize(candidates, mediaSupport.MaxBytes) } + // narrow down the candidates to the ones that don't exceed our max dimensions + if mediaSupport.MaxWidth > 0 && mediaSupport.MaxHeight > 0 { + candidates = filterMediaByDimensions(candidates, mediaSupport.MaxWidth, mediaSupport.MaxHeight) + } + // if we have no candidates, we can't use this media if len(candidates) == 0 { return nil, nil @@ -144,6 +154,10 @@ func filterMediaBySize(in []courier.Media, maxBytes int) []courier.Media { return filterMedia(in, func(m courier.Media) bool { return m.Size() <= maxBytes }) } +func filterMediaByDimensions(in []courier.Media, maxWidth int, MaxHeight int) []courier.Media { + return filterMedia(in, func(m courier.Media) bool { return m.Width() <= maxWidth && m.Height() <= MaxHeight }) +} + func filterMedia(in []courier.Media, f func(courier.Media) bool) []courier.Media { filtered := make([]courier.Media, 0, len(in)) for _, m := range in { diff --git a/handlers/media_test.go b/handlers/media_test.go index 5fd132db6..84d8f3d13 100644 --- a/handlers/media_test.go +++ b/handlers/media_test.go @@ -139,6 +139,29 @@ func TestResolveAttachments(t *testing.T) { mediaSupport: map[handlers.MediaType]handlers.MediaTypeSupport{}, err: "invalid attachment format: http://mock.com/1234/test.jpg", }, + { // 14: resolveable uploaded image URL with matching dimensions + attachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, + mediaSupport: map[handlers.MediaType]handlers.MediaTypeSupport{handlers.MediaTypeImage: {Types: []string{"image/jpeg", "image/png"}, MaxWidth: 1000, MaxHeight: 1000}}, + allowURLOnly: true, + resolved: []*handlers.Attachment{ + {Type: handlers.MediaTypeImage, Name: "test.jpg", ContentType: "image/jpeg", URL: "http://mock.com/1234/test.jpg", Media: imageJPG, Thumbnail: nil}, + }, + }, + { // 15: resolveable uploaded image URL without matching dimensions + attachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, + mediaSupport: map[handlers.MediaType]handlers.MediaTypeSupport{handlers.MediaTypeImage: {Types: []string{"image/jpeg", "image/png"}, MaxWidth: 100, MaxHeight: 100}}, + allowURLOnly: true, + resolved: []*handlers.Attachment{}, + errors: []*courier.ChannelError{courier.ErrorMediaUnresolveable("image/jpeg")}, + }, + { // 16: resolveable uploaded image URL without matching dimensions by specific content type precendence + attachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, + mediaSupport: map[handlers.MediaType]handlers.MediaTypeSupport{handlers.MediaTypeImage: {Types: []string{"image/jpeg", "image/png"}, MaxWidth: 100, MaxHeight: 100}, handlers.MediaType("image/jpeg"): {Types: []string{"image/jpeg", "image/png"}, MaxWidth: 1000, MaxHeight: 1000}}, + allowURLOnly: true, + resolved: []*handlers.Attachment{ + {Type: handlers.MediaTypeImage, Name: "test.jpg", ContentType: "image/jpeg", URL: "http://mock.com/1234/test.jpg", Media: imageJPG, Thumbnail: nil}, + }, + }, } for i, tc := range tcs { From ac9916219f803e671b7778e3f249aca9274744ba Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Fri, 28 Jul 2023 15:16:12 +0200 Subject: [PATCH 2/8] Support sending and receiving stickers for WAC and D3C --- handlers/dialog360/dialog360.go | 993 ++++++++++ handlers/dialog360/dialog360_test.go | 654 +++++++ handlers/facebookapp/facebookapp.go | 1684 +++++++++++++++++ handlers/facebookapp/facebookapp_test.go | 1648 ++++++++++++++++ .../facebookapp/testdata/wac/stickerWAC.json | 42 + handlers/meta/testdata/wac/stickerWAC.json | 42 + 6 files changed, 5063 insertions(+) create mode 100644 handlers/dialog360/dialog360.go create mode 100644 handlers/dialog360/dialog360_test.go create mode 100644 handlers/facebookapp/facebookapp.go create mode 100644 handlers/facebookapp/facebookapp_test.go create mode 100644 handlers/facebookapp/testdata/wac/stickerWAC.json create mode 100644 handlers/meta/testdata/wac/stickerWAC.json diff --git a/handlers/dialog360/dialog360.go b/handlers/dialog360/dialog360.go new file mode 100644 index 000000000..4414ec15e --- /dev/null +++ b/handlers/dialog360/dialog360.go @@ -0,0 +1,993 @@ +package dialog360 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/buger/jsonparser" + "github.com/nyaruka/courier" + "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/utils" + "github.com/nyaruka/gocommon/urns" + "github.com/pkg/errors" +) + +const ( + d3AuthorizationKey = "D360-API-KEY" +) + +var ( + // max for the body + maxMsgLength = 1000 +) + +func init() { + courier.RegisterHandler(newWAHandler(courier.ChannelType("D3C"), "360Dialog")) +} + +type handler struct { + handlers.BaseHandler +} + +func newWAHandler(channelType courier.ChannelType, name string) courier.ChannelHandler { + return &handler{handlers.NewBaseHandler(channelType, name)} +} + +// Initialize is called by the engine once everything is loaded +func (h *handler) Initialize(s courier.Server) error { + h.SetServer(s) + s.AddHandlerRoute(h, http.MethodPost, "receive", courier.ChannelLogTypeMultiReceive, handlers.JSONPayload(h, h.receiveEvent)) + return nil +} + +var waStatusMapping = map[string]courier.MsgStatus{ + "sent": courier.MsgStatusSent, + "delivered": courier.MsgStatusDelivered, + "read": courier.MsgStatusDelivered, + "failed": courier.MsgStatusFailed, +} + +var waIgnoreStatuses = map[string]bool{ + "deleted": true, +} + +type Sender struct { + ID string `json:"id"` + UserRef string `json:"user_ref,omitempty"` +} + +type User struct { + ID string `json:"id"` +} + +// { +// "object":"page", +// "entry":[{ +// "id":"180005062406476", +// "time":1514924367082, +// "messaging":[{ +// "sender": {"id":"1630934236957797"}, +// "recipient":{"id":"180005062406476"}, +// "timestamp":1514924366807, +// "message":{ +// "mid":"mid.$cAAD5QiNHkz1m6cyj11guxokwkhi2", +// "seq":33116, +// "text":"65863634" +// } +// }] +// }] +// } + +type wacMedia struct { + Caption string `json:"caption"` + Filename string `json:"filename"` + ID string `json:"id"` + Mimetype string `json:"mime_type"` + SHA256 string `json:"sha256"` +} + +type wacSticker struct { + Animated bool `json:"animated"` + 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"` + 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 *wacMedia `json:"image"` + Audio *wacMedia `json:"audio"` + Video *wacMedia `json:"video"` + Document *wacMedia `json:"document"` + Voice *wacMedia `json:"voice"` + Sticker *wacSticker `json:"sticker"` + 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"` + Interactive struct { + Type string `json:"type"` + ButtonReply struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"button_reply,omitempty"` + ListReply struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"list_reply,omitempty"` + } `json:"interactive,omitempty"` + Errors []struct { + Code int `json:"code"` + Title string `json:"title"` + } `json:"errors"` + } `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"` + } `json:"conversation"` + Pricing *struct { + PricingModel string `json:"pricing_model"` + Billable bool `json:"billable"` + Category string `json:"category"` + } `json:"pricing"` + Errors []struct { + Code int `json:"code"` + Title string `json:"title"` + } `json:"errors"` + } `json:"statuses"` + Errors []struct { + Code int `json:"code"` + Title string `json:"title"` + } `json:"errors"` + } `json:"value"` + } `json:"changes"` + } `json:"entry"` +} + +// 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, payload *moPayload, clog *courier.ChannelLog) ([]courier.Event, error) { + + // is not a 'whatsapp_business_account' object? ignore it + if payload.Object != "whatsapp_business_account" { + return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request") + } + + // no entries? ignore this request + if len(payload.Entry) == 0 { + return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request, no entries") + } + + var events []courier.Event + var data []any + + events, data, err := h.processCloudWhatsAppPayload(ctx, channel, payload, w, r, clog) + if err != nil { + return nil, err + } + + return events, courier.WriteDataResponse(w, http.StatusOK, "Events Handled", data) +} + +func (h *handler) processCloudWhatsAppPayload(ctx context.Context, channel courier.Channel, payload *moPayload, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, []any, 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([]any, 0, 2) + + seenMsgIDs := make(map[string]bool) + 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 { + if seenMsgIDs[msg.ID] { + continue + } + + // create our date from the timestamp + ts, err := strconv.ParseInt(msg.Timestamp, 10, 64) + if err != nil { + return nil, nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, fmt.Sprintf("invalid timestamp: %s", msg.Timestamp)) + } + date := time.Unix(ts, 0).UTC() + + urn, err := urns.NewWhatsAppURN(msg.From) + if err != nil { + return nil, nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, err.Error()) + } + + for _, msgError := range msg.Errors { + clog.Error(courier.ErrorExternal(strconv.Itoa(msgError.Code), msgError.Title)) + } + + 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, clog) + } else if msg.Type == "voice" && msg.Voice != nil { + text = msg.Voice.Caption + mediaURL, err = resolveMediaURL(channel, msg.Voice.ID, clog) + } 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, clog) + } else if msg.Type == "image" && msg.Image != nil { + text = msg.Image.Caption + mediaURL, err = resolveMediaURL(channel, msg.Image.ID, clog) + } else if msg.Type == "sticker" && msg.Sticker != nil { + mediaURL, err = resolveMediaURL(channel, msg.Sticker.ID, clog) + } else if msg.Type == "video" && msg.Video != nil { + text = msg.Video.Caption + mediaURL, err = resolveMediaURL(channel, msg.Video.ID, clog) + } else if msg.Type == "location" && msg.Location != nil { + mediaURL = fmt.Sprintf("geo:%f,%f", msg.Location.Latitude, msg.Location.Longitude) + } else if msg.Type == "interactive" && msg.Interactive.Type == "button_reply" { + text = msg.Interactive.ButtonReply.Title + } else if msg.Type == "interactive" && msg.Interactive.Type == "list_reply" { + text = msg.Interactive.ListReply.Title + } else { + // we received a message type we do not support. + courier.LogRequestError(r, channel, fmt.Errorf("unsupported message type %s", msg.Type)) + continue + } + + // create our message + event := h.Backend().NewIncomingMsg(channel, urn, text, msg.ID, clog).WithReceivedOn(date).WithContactName(contactNames[msg.From]) + + // 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, clog) + if err != nil { + return nil, nil, err + } + + events = append(events, event) + data = append(data, courier.NewMsgReceiveData(event)) + seenMsgIDs[msg.ID] = true + } + + 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.WriteAndLogRequestIgnored(ctx, h, channel, w, r, fmt.Sprintf("unknown status: %s", status.Status)) + } + continue + } + + for _, statusError := range status.Errors { + clog.Error(courier.ErrorExternal(strconv.Itoa(statusError.Code), statusError.Title)) + } + + event := h.Backend().NewStatusUpdateByExternalID(channel, status.ID, msgStatus, clog) + err := h.Backend().WriteStatusUpdate(ctx, event) + if err != nil { + return nil, nil, err + } + + events = append(events, event) + data = append(data, courier.NewStatusData(event)) + + } + + for _, chError := range change.Value.Errors { + clog.Error(courier.ErrorExternal(strconv.Itoa(chError.Code), chError.Title)) + } + + } + + } + return events, data, nil +} + +// BuildAttachmentRequest to download media for message attachment with Bearer token set +func (h *handler) BuildAttachmentRequest(ctx context.Context, b courier.Backend, channel courier.Channel, attachmentURL string, clog *courier.ChannelLog) (*http.Request, error) { + token := channel.StringConfigForKey(courier.ConfigAuthToken, "") + if token == "" { + return nil, fmt.Errorf("missing token for D3C channel") + } + + // set the access token as the authorization header + req, _ := http.NewRequest(http.MethodGet, attachmentURL, nil) + req.Header.Set("User-Agent", utils.HTTPUserAgent) + req.Header.Set(d3AuthorizationKey, token) + return req, nil +} + +var _ courier.AttachmentRequestBuilder = (*handler)(nil) + +func resolveMediaURL(channel courier.Channel, mediaID string, clog *courier.ChannelLog) (string, error) { + // sometimes WA will send an attachment with status=undownloaded and no ID + if mediaID == "" { + return "", nil + } + + token := channel.StringConfigForKey(courier.ConfigAuthToken, "") + if token == "" { + return "", fmt.Errorf("missing token for D3C channel") + } + + urlStr := channel.StringConfigForKey(courier.ConfigBaseURL, "") + url, err := url.Parse(urlStr) + if err != nil { + return "", fmt.Errorf("invalid base url set for D3C channel: %s", err) + } + + mediaPath, _ := url.Parse(mediaID) + mediaURL := url.ResolveReference(mediaPath).String() + + req, _ := http.NewRequest(http.MethodGet, mediaURL, nil) + req.Header.Set("User-Agent", utils.HTTPUserAgent) + req.Header.Set(d3AuthorizationKey, token) + + resp, respBody, err := handlers.RequestHTTP(req, clog) + if err != nil || resp.StatusCode/100 != 2 { + return "", fmt.Errorf("failed to request media URL for D3C channel: %s", err) + } + + fbFileURL, err := jsonparser.GetString(respBody, "url") + if err != nil { + return "", fmt.Errorf("missing url field in response for D3C media: %s", err) + } + + fileURL := strings.ReplaceAll(fbFileURL, "https://lookaside.fbsbx.com", urlStr) + + return fileURL, nil +} + +type wacMTMedia struct { + ID string `json:"id,omitempty"` + Link string `json:"link,omitempty"` + Caption string `json:"caption,omitempty"` + Filename string `json:"filename,omitempty"` +} + +type wacMTSection struct { + Title string `json:"title,omitempty"` + Rows []wacMTSectionRow `json:"rows" validate:"required"` +} + +type wacMTSectionRow struct { + ID string `json:"id" validate:"required"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` +} + +type wacMTButton 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 wacParam struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type wacComponent struct { + Type string `json:"type"` + SubType string `json:"sub_type"` + Index string `json:"index"` + Params []*wacParam `json:"parameters"` +} + +type wacText struct { + Body string `json:"body"` + PreviewURL bool `json:"preview_url"` +} + +type wacLanguage struct { + Policy string `json:"policy"` + Code string `json:"code"` +} + +type wacTemplate struct { + Name string `json:"name"` + Language *wacLanguage `json:"language"` + Components []*wacComponent `json:"components"` +} + +type wacInteractive struct { + Type string `json:"type"` + Header *struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Video *wacMTMedia `json:"video,omitempty"` + Image *wacMTMedia `json:"image,omitempty"` + Document *wacMTMedia `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 []wacMTSection `json:"sections,omitempty"` + Buttons []wacMTButton `json:"buttons,omitempty"` + } `json:"action,omitempty"` +} + +type wacMTPayload struct { + MessagingProduct string `json:"messaging_product"` + RecipientType string `json:"recipient_type"` + To string `json:"to"` + Type string `json:"type"` + + Text *wacText `json:"text,omitempty"` + + Document *wacMTMedia `json:"document,omitempty"` + Image *wacMTMedia `json:"image,omitempty"` + Audio *wacMTMedia `json:"audio,omitempty"` + Video *wacMTMedia `json:"video,omitempty"` + Sticker *wacMTMedia `json:"sticker,omitempty"` + + Interactive *wacInteractive `json:"interactive,omitempty"` + + Template *wacTemplate `json:"template,omitempty"` +} + +type wacMTResponse struct { + Messages []*struct { + ID string `json:"id"` + } `json:"messages"` + Error struct { + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` +} + +// Send implements courier.ChannelHandler +func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + conn := h.Backend().RedisPool().Get() + defer conn.Close() + + // get our token + // can't do anything without an access token + accessToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") + if accessToken == "" { + return nil, fmt.Errorf("missing token for D3C channel") + } + + urlStr := msg.Channel().StringConfigForKey(courier.ConfigBaseURL, "") + url, err := url.Parse(urlStr) + if err != nil { + return nil, fmt.Errorf("invalid base url set for D3C channel: %s", err) + } + sendURL, _ := url.Parse("/messages") + + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) + + hasCaption := false + + msgParts := make([]string, 0) + if msg.Text() != "" { + msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) + } + qrs := msg.QuickReplies() + lang := getSupportedLanguage(msg.Locale()) + + var payloadAudio wacMTPayload + + for i := 0; i < len(msgParts)+len(msg.Attachments()); i++ { + payload := wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path()} + + if len(msg.Attachments()) == 0 { + // do we have a template? + templating, err := h.getTemplating(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 := wacTemplate{Name: templating.Template.Name, Language: &wacLanguage{Policy: "deterministic", Code: lang.code}} + payload.Template = &template + + component := &wacComponent{Type: "body"} + + for _, v := range templating.Variables { + component.Params = append(component.Params, &wacParam{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 + text := &wacText{PreviewURL: false} + payload.Type = "text" + if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + text.PreviewURL = true + } + text.Body = msgParts[i-len(msg.Attachments())] + payload.Text = text + } else { + if len(qrs) > 0 { + payload.Type = "interactive" + // We can use buttons + if len(qrs) <= 3 { + interactive := wacInteractive{Type: "button", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + btns := make([]wacMTButton, len(qrs)) + for i, qr := range qrs { + btns[i] = wacMTButton{ + Type: "reply", + } + btns[i].Reply.ID = fmt.Sprint(i) + btns[i].Reply.Title = qr + } + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []wacMTSection "json:\"sections,omitempty\"" + Buttons []wacMTButton "json:\"buttons,omitempty\"" + }{Buttons: btns} + payload.Interactive = &interactive + } else if len(qrs) <= 10 { + interactive := wacInteractive{Type: "list", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + section := wacMTSection{ + Rows: make([]wacMTSectionRow, len(qrs)), + } + for i, qr := range qrs { + section.Rows[i] = wacMTSectionRow{ + ID: fmt.Sprint(i), + Title: qr, + } + } + + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []wacMTSection "json:\"sections,omitempty\"" + Buttons []wacMTButton "json:\"buttons,omitempty\"" + }{Button: lang.menu, Sections: []wacMTSection{ + section, + }} + + payload.Interactive = &interactive + } else { + return nil, fmt.Errorf("too many quick replies WAC supports only up to 10 quick replies") + } + } else { + // this is still a msg part + text := &wacText{PreviewURL: false} + payload.Type = "text" + if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + text.PreviewURL = true + } + text.Body = msgParts[i-len(msg.Attachments())] + payload.Text = text + } + } + } + + } else if i < len(msg.Attachments()) && (len(qrs) == 0 || len(qrs) > 3) { + attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) + splitedAttType := strings.Split(attType, "/") + attType = splitedAttType[0] + attFormat := splitedAttType[1] + if attType == "application" { + attType = "document" + } + payload.Type = attType + media := wacMTMedia{Link: attURL} + + if len(msgParts) == 1 && attType != "audio" && len(msg.Attachments()) == 1 && len(msg.QuickReplies()) == 0 { + media.Caption = msgParts[i] + hasCaption = true + } + + if attType == "image" { + if attFormat == "webp" { + payload.Type = "sticker" + payload.Sticker = &media + } else { + payload.Image = &media + } + + } else if attType == "audio" { + payload.Audio = &media + } else if attType == "video" { + payload.Video = &media + } else if attType == "document" { + filename, err := utils.BasePathForURL(attURL) + if err != nil { + filename = "" + } + if filename != "" { + media.Filename = filename + } + payload.Document = &media + } + } else { + if len(qrs) > 0 { + payload.Type = "interactive" + // We can use buttons + if len(qrs) <= 3 { + interactive := wacInteractive{Type: "button", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i]}} + + if len(msg.Attachments()) > 0 { + hasCaption = true + attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) + attType = strings.Split(attType, "/")[0] + if attType == "application" { + attType = "document" + } + if attType == "image" { + image := wacMTMedia{ + Link: attURL, + } + interactive.Header = &struct { + Type string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *wacMTMedia "json:\"video,omitempty\"" + Image *wacMTMedia "json:\"image,omitempty\"" + Document *wacMTMedia "json:\"document,omitempty\"" + }{Type: "image", Image: &image} + } else if attType == "video" { + video := wacMTMedia{ + Link: attURL, + } + interactive.Header = &struct { + Type string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *wacMTMedia "json:\"video,omitempty\"" + Image *wacMTMedia "json:\"image,omitempty\"" + Document *wacMTMedia "json:\"document,omitempty\"" + }{Type: "video", Video: &video} + } else if attType == "document" { + filename, err := utils.BasePathForURL(attURL) + if err != nil { + return nil, err + } + document := wacMTMedia{ + Link: attURL, + Filename: filename, + } + interactive.Header = &struct { + Type string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *wacMTMedia "json:\"video,omitempty\"" + Image *wacMTMedia "json:\"image,omitempty\"" + Document *wacMTMedia "json:\"document,omitempty\"" + }{Type: "document", Document: &document} + } else if attType == "audio" { + var zeroIndex bool + if i == 0 { + zeroIndex = true + } + payloadAudio = wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path(), Type: "audio", Audio: &wacMTMedia{Link: attURL}} + status, err := requestD3C(payloadAudio, accessToken, status, sendURL, zeroIndex, clog) + if err != nil { + return status, nil + } + } else { + interactive.Type = "button" + interactive.Body.Text = msgParts[i] + } + } + + btns := make([]wacMTButton, len(qrs)) + for i, qr := range qrs { + btns[i] = wacMTButton{ + Type: "reply", + } + btns[i].Reply.ID = fmt.Sprint(i) + btns[i].Reply.Title = qr + } + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []wacMTSection "json:\"sections,omitempty\"" + Buttons []wacMTButton "json:\"buttons,omitempty\"" + }{Buttons: btns} + payload.Interactive = &interactive + + } else if len(qrs) <= 10 { + interactive := wacInteractive{Type: "list", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + section := wacMTSection{ + Rows: make([]wacMTSectionRow, len(qrs)), + } + for i, qr := range qrs { + section.Rows[i] = wacMTSectionRow{ + ID: fmt.Sprint(i), + Title: qr, + } + } + + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []wacMTSection "json:\"sections,omitempty\"" + Buttons []wacMTButton "json:\"buttons,omitempty\"" + }{Button: lang.menu, Sections: []wacMTSection{ + section, + }} + + payload.Interactive = &interactive + } else { + return nil, fmt.Errorf("too many quick replies WAC supports only up to 10 quick replies") + } + } else { + // this is still a msg part + text := &wacText{PreviewURL: false} + payload.Type = "text" + if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + text.PreviewURL = true + } + text.Body = msgParts[i-len(msg.Attachments())] + payload.Text = text + } + } + + var zeroIndex bool + if i == 0 { + zeroIndex = true + } + + status, err := requestD3C(payload, accessToken, status, sendURL, zeroIndex, clog) + if err != nil { + return status, err + } + + if hasCaption { + break + } + } + return status, nil +} + +func requestD3C(payload wacMTPayload, accessToken string, status courier.StatusUpdate, wacPhoneURL *url.URL, zeroIndex bool, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + jsonBody, err := json.Marshal(payload) + if err != nil { + return status, err + } + + req, err := http.NewRequest(http.MethodPost, wacPhoneURL.String(), bytes.NewReader(jsonBody)) + if err != nil { + return nil, err + } + + req.Header.Set(d3AuthorizationKey, accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + _, respBody, _ := handlers.RequestHTTP(req, clog) + respPayload := &wacMTResponse{} + err = json.Unmarshal(respBody, respPayload) + if err != nil { + clog.Error(courier.ErrorResponseUnparseable("JSON")) + return status, nil + } + + if respPayload.Error.Code != 0 { + clog.Error(courier.ErrorExternal(strconv.Itoa(respPayload.Error.Code), respPayload.Error.Message)) + return status, nil + } + + externalID := respPayload.Messages[0].ID + if zeroIndex && externalID != "" { + status.SetExternalID(externalID) + } + // this was wired successfully + status.SetStatus(courier.MsgStatusWired) + return status, nil +} + +func (h *handler) getTemplating(msg courier.Msg) (*MsgTemplating, error) { + if len(msg.Metadata()) == 0 { + return nil, nil + } + + metadata := &struct { + Templating *MsgTemplating `json:"templating"` + }{} + if err := json.Unmarshal(msg.Metadata(), metadata); err != nil { + return nil, err + } + + if metadata.Templating == nil { + return nil, nil + } + + if err := utils.Validate(metadata.Templating); err != nil { + return nil, errors.Wrapf(err, "invalid templating definition") + } + + return metadata.Templating, nil +} + +type MsgTemplating struct { + Template struct { + Name string `json:"name" validate:"required"` + UUID string `json:"uuid" validate:"required"` + } `json:"template" validate:"required,dive"` + Namespace string `json:"namespace"` + Variables []string `json:"variables"` +} + +func getSupportedLanguage(lc courier.Locale) languageInfo { + // look for exact match + if lang := supportedLanguages[lc]; lang.code != "" { + return lang + } + + // if we have a country, strip that off and look again for a match + l, c := lc.ToParts() + if c != "" { + if lang := supportedLanguages[courier.Locale(l)]; lang.code != "" { + return lang + } + } + return supportedLanguages["eng"] // fallback to English +} + +type languageInfo struct { + code string + menu string // translation of "Menu" +} + +// Mapping from engine locales to supported languages. Note that these are not all valid BCP47 codes, e.g. fil +// see https://developers.facebook.com/docs/whatsapp/api/messages/message-templates/ +var supportedLanguages = map[courier.Locale]languageInfo{ + "afr": {code: "af", menu: "Kieslys"}, // Afrikaans + "sqi": {code: "sq", menu: "Menu"}, // Albanian + "ara": {code: "ar", menu: "قائمة"}, // Arabic + "aze": {code: "az", menu: "Menu"}, // Azerbaijani + "ben": {code: "bn", menu: "Menu"}, // Bengali + "bul": {code: "bg", menu: "Menu"}, // Bulgarian + "cat": {code: "ca", menu: "Menu"}, // Catalan + "zho": {code: "zh_CN", menu: "菜单"}, // Chinese + "zho-CN": {code: "zh_CN", menu: "菜单"}, // Chinese (CHN) + "zho-HK": {code: "zh_HK", menu: "菜单"}, // Chinese (HKG) + "zho-TW": {code: "zh_TW", menu: "菜单"}, // Chinese (TAI) + "hrv": {code: "hr", menu: "Menu"}, // Croatian + "ces": {code: "cs", menu: "Menu"}, // Czech + "dah": {code: "da", menu: "Menu"}, // Danish + "nld": {code: "nl", menu: "Menu"}, // Dutch + "eng": {code: "en", menu: "Menu"}, // English + "eng-GB": {code: "en_GB", menu: "Menu"}, // English (UK) + "eng-US": {code: "en_US", menu: "Menu"}, // English (US) + "est": {code: "et", menu: "Menu"}, // Estonian + "fil": {code: "fil", menu: "Menu"}, // Filipino + "fin": {code: "fi", menu: "Menu"}, // Finnish + "fra": {code: "fr", menu: "Menu"}, // French + "kat": {code: "ka", menu: "Menu"}, // Georgian + "deu": {code: "de", menu: "Menü"}, // German + "ell": {code: "el", menu: "Menu"}, // Greek + "guj": {code: "gu", menu: "Menu"}, // Gujarati + "hau": {code: "ha", menu: "Menu"}, // Hausa + "enb": {code: "he", menu: "תפריט"}, // Hebrew + "hin": {code: "hi", menu: "Menu"}, // Hindi + "hun": {code: "hu", menu: "Menu"}, // Hungarian + "ind": {code: "id", menu: "Menu"}, // Indonesian + "gle": {code: "ga", menu: "Roghchlár"}, // Irish + "ita": {code: "it", menu: "Menu"}, // Italian + "jpn": {code: "ja", menu: "Menu"}, // Japanese + "kan": {code: "kn", menu: "Menu"}, // Kannada + "kaz": {code: "kk", menu: "Menu"}, // Kazakh + "kin": {code: "rw_RW", menu: "Menu"}, // Kinyarwanda + "kor": {code: "ko", menu: "Menu"}, // Korean + "kir": {code: "ky_KG", menu: "Menu"}, // Kyrgyzstan + "lao": {code: "lo", menu: "Menu"}, // Lao + "lav": {code: "lv", menu: "Menu"}, // Latvian + "lit": {code: "lt", menu: "Menu"}, // Lithuanian + "mal": {code: "ml", menu: "Menu"}, // Malayalam + "mkd": {code: "mk", menu: "Menu"}, // Macedonian + "msa": {code: "ms", menu: "Menu"}, // Malay + "mar": {code: "mr", menu: "Menu"}, // Marathi + "nob": {code: "nb", menu: "Menu"}, // Norwegian + "fas": {code: "fa", menu: "Menu"}, // Persian + "pol": {code: "pl", menu: "Menu"}, // Polish + "por": {code: "pt_PT", menu: "Menu"}, // Portuguese + "por-BR": {code: "pt_BR", menu: "Menu"}, // Portuguese (BR) + "por-PT": {code: "pt_PT", menu: "Menu"}, // Portuguese (POR) + "pan": {code: "pa", menu: "Menu"}, // Punjabi + "ron": {code: "ro", menu: "Menu"}, // Romanian + "rus": {code: "ru", menu: "Menu"}, // Russian + "srp": {code: "sr", menu: "Menu"}, // Serbian + "slk": {code: "sk", menu: "Menu"}, // Slovak + "slv": {code: "sl", menu: "Menu"}, // Slovenian + "spa": {code: "es", menu: "Menú"}, // Spanish + "spa-AR": {code: "es_AR", menu: "Menú"}, // Spanish (ARG) + "spa-ES": {code: "es_ES", menu: "Menú"}, // Spanish (SPA) + "spa-MX": {code: "es_MX", menu: "Menú"}, // Spanish (MEX) + "swa": {code: "sw", menu: "Menyu"}, // Swahili + "swe": {code: "sv", menu: "Menu"}, // Swedish + "tam": {code: "ta", menu: "Menu"}, // Tamil + "tel": {code: "te", menu: "Menu"}, // Telugu + "tha": {code: "th", menu: "Menu"}, // Thai + "tur": {code: "tr", menu: "Menu"}, // Turkish + "ukr": {code: "uk", menu: "Menu"}, // Ukrainian + "urd": {code: "ur", menu: "Menu"}, // Urdu + "uzb": {code: "uz", menu: "Menu"}, // Uzbek + "vie": {code: "vi", menu: "Menu"}, // Vietnamese + "zul": {code: "zu", menu: "Menu"}, // Zulu +} diff --git a/handlers/dialog360/dialog360_test.go b/handlers/dialog360/dialog360_test.go new file mode 100644 index 000000000..f740b1331 --- /dev/null +++ b/handlers/dialog360/dialog360_test.go @@ -0,0 +1,654 @@ +package dialog360 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/nyaruka/courier" + . "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/test" + "github.com/nyaruka/gocommon/httpx" + "github.com/stretchr/testify/assert" +) + +var testChannels = []courier.Channel{ + test.NewMockChannel( + "8eb23e93-5ecb-45ba-b726-3b064e0c568c", + "D3C", + "250788383383", + "RW", + map[string]any{ + "auth_token": "the-auth-token", + "base_url": "https://waba-v2.360dialog.io", + }), +} + +var ( + d3CReceiveURL = "/c/d3c/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive" +) + +var testCasesD3C = []IncomingTestCase{ + { + Label: "Receive Message WAC", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/helloWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Hello World"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Duplicate Valid Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/duplicateWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Hello World"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Valid Voice Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/voiceWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp(""), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_voice"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Valid Button Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/buttonWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("No"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Valid Document Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/documentWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("80skaraokesonglistartist"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_document"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Valid Image Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/imageWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Check out my new phone!"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_image"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Valid Sticker Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/stickerWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp(""), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_sticker"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Valid Video Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/videoWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Check out my new phone!"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_video"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Valid Audio Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/audioWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Check out my new phone!"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_audio"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Valid Location Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/locationWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"type":"msg"`, + ExpectedMsgText: Sp(""), + ExpectedAttachments: []string{"geo:0.000000,1.000000"}, + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Invalid JSON", + URL: d3CReceiveURL, + Data: "not json", + ExpectedRespStatus: 400, + ExpectedBodyContains: "unable to parse", + }, + { + Label: "Receive Invalid FROM", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/invalidFrom.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid whatsapp id", + }, + { + Label: "Receive Invalid timestamp JSON", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/invalidTimestamp.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid timestamp", + }, + { + Label: "Receive Message WAC with error message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/errorMsg.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("131051", "Unsupported message type")}, + NoInvalidChannelCheck: true, + }, + { + Label: "Receive error message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/errorErrors.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("0", "We were unable to authenticate the app user")}, + NoInvalidChannelCheck: true, + }, + { + Label: "Receive Valid Status", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/validStatusWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"type":"status"`, + ExpectedMsgStatus: "S", + ExpectedExternalID: "external_id", + }, + { + Label: "Receive Valid Status with error message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/errorStatus.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"type":"status"`, + ExpectedMsgStatus: "F", + ExpectedExternalID: "external_id", + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("131014", "Request for url https://URL.jpg failed with error: 404 (Not Found)")}, + }, + { + Label: "Receive Invalid Status", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/invalidStatusWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"unknown status: in_orbit"`, + }, + { + Label: "Receive Ignore Status", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/ignoreStatusWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"ignoring status: deleted"`, + }, + { + Label: "Receive Valid Interactive Button Reply Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/buttonReplyWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Yes"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, + { + Label: "Receive Valid Interactive List Reply Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("./testdata/wac/listReplyWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Yes"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, +} + +func buildMockD3MediaService(testChannels []courier.Channel, testCases []IncomingTestCase) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fileURL := "" + + if strings.HasSuffix(r.URL.Path, "id_voice") { + fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_voice" + } + if strings.HasSuffix(r.URL.Path, "id_document") { + fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_document" + } + if strings.HasSuffix(r.URL.Path, "id_image") { + fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_image" + } + if strings.HasSuffix(r.URL.Path, "id_video") { + fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_video" + } + if strings.HasSuffix(r.URL.Path, "id_audio") { + fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_audio" + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf(`{ "url": "%s" }`, fileURL))) + })) + testChannels[0].(*test.MockChannel).SetConfig("base_url", server.URL) + + // update our tests media urls + for _, tc := range testCases { + for i := range tc.ExpectedAttachments { + if !strings.HasPrefix(tc.ExpectedAttachments[i], "geo:") { + tc.ExpectedAttachments[i] = strings.ReplaceAll(tc.ExpectedAttachments[i], "https://waba-v2.360dialog.io", server.URL) + } + } + } + + return server +} + +func TestIncoming(t *testing.T) { + + d3MediaService := buildMockD3MediaService(testChannels, testCasesD3C) + defer d3MediaService.Close() + + RunIncomingTestCases(t, testChannels, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), testCasesD3C) +} + +func BenchmarkHandler(b *testing.B) { + d3MediaService := buildMockD3MediaService(testChannels, testCasesD3C) + defer d3MediaService.Close() + RunChannelBenchmarks(b, testChannels, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), testCasesD3C) +} + +func TestBuildAttachmentRequest(t *testing.T) { + mb := test.NewMockBackend() + + d3CHandler := &handler{NewBaseHandler(courier.ChannelType("D3C"), "360Dialog")} + req, _ := d3CHandler.BuildAttachmentRequest(context.Background(), mb, testChannels[0], "https://example.org/v1/media/41", nil) + assert.Equal(t, "https://example.org/v1/media/41", req.URL.String()) + assert.Equal(t, "the-auth-token", req.Header.Get("D360-API-KEY")) + +} + +// setSendURL takes care of setting the base_url to our test server host +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { + c.(*test.MockChannel).SetConfig("base_url", s.URL) +} + +var SendTestCasesD3C = []OutgoingTestCase{ + { + Label: "Plain Send", + MsgText: "Simple Message", + MsgURN: "whatsapp:250788123123", + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"Simple Message","preview_url":false}}`, + ExpectedRequestPath: "/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Unicode Send", + MsgText: "☺", + MsgURN: "whatsapp:250788123123", + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"☺","preview_url":false}}`, + ExpectedRequestPath: "/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Audio Send", + MsgText: "audio caption", + MsgURN: "whatsapp:250788123123", + MsgAttachments: []string{"audio/mpeg:https://foo.bar/audio.mp3"}, + MockResponses: map[MockedRequest]*httpx.MockResponse{ + { + Method: "POST", + Path: "/messages", + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + { + Method: "POST", + Path: "/messages", + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"audio caption","preview_url":false}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + }, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Document Send", + MsgText: "document caption", + MsgURN: "whatsapp:250788123123", + MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"https://foo.bar/document.pdf","caption":"document caption","filename":"document.pdf"}}`, + ExpectedRequestPath: "/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Image Send", + MsgText: "image caption", + MsgURN: "whatsapp:250788123123", + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg","caption":"image caption"}}`, + ExpectedRequestPath: "/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Sticker Send", + MsgText: "sticker caption", + MsgURN: "whatsapp:250788123123", + MsgAttachments: []string{"image/webp:https://foo.bar/sticker.webp"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"sticker","sticker":{"link":"https://foo.bar/sticker.webp","caption":"sticker caption"}}`, + ExpectedRequestPath: "/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Video Send", + MsgText: "video caption", + MsgURN: "whatsapp:250788123123", + MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"https://foo.bar/video.mp4","caption":"video caption"}}`, + ExpectedRequestPath: "/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Template Send", + MsgText: "templated message", + MsgURN: "whatsapp:250788123123", + MsgLocale: "eng", + MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"]}}`), + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_product":"whatsapp","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", + MsgText: "templated message", + MsgURN: "whatsapp:250788123123", + MsgLocale: "eng-US", + MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"]}}`), + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Template Invalid Language", + MsgText: "templated message", + MsgURN: "whatsapp:250788123123", + MsgLocale: "bnt", + MsgMetadata: json.RawMessage(`{"templating": { "template": { "name": "revive_issue", "uuid": "8ca114b4-bee2-4d3b-aaf1-9aa6b48d41e8" }, "variables": ["Chef", "tomorrow"]}}`), + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive Button Message Send", + MsgText: "Interactive Button Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"BUTTON1"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive List Message Send", + MsgText: "Interactive List Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive List Message Send In Spanish", + MsgText: "Hola", + MsgURN: "whatsapp:250788123123", + MsgLocale: "spa", + MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"list","body":{"text":"Hola"},"action":{"button":"Menú","sections":[{"rows":[{"id":"0","title":"ROW1"},{"id":"1","title":"ROW2"},{"id":"2","title":"ROW3"},{"id":"3","title":"ROW4"}]}]}}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive Button Message Send with image attachment", + MsgText: "Interactive Button Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"BUTTON1"}, + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"https://foo.bar/image.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestPath: "/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive Button Message Send with video attachment", + MsgText: "Interactive Button Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"BUTTON1"}, + MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"https://foo.bar/video.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestPath: "/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive Button Message Send with document attachment", + MsgText: "Interactive Button Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"BUTTON1"}, + MsgAttachments: []string{"document/pdf:https://foo.bar/document.pdf"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"https://foo.bar/document.pdf","filename":"document.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestPath: "/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive Button Message Send with audio attachment", + MsgText: "Interactive Button Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3"}, + MsgAttachments: []string{"audio/mp3:https://foo.bar/audio.mp3"}, + MockResponses: map[MockedRequest]*httpx.MockResponse{ + { + Method: "POST", + Path: "/messages", + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + { + Method: "POST", + Path: "/messages", + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"ROW1"}},{"type":"reply","reply":{"id":"1","title":"ROW2"}},{"type":"reply","reply":{"id":"2","title":"ROW3"}}]}}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + }, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive List Message Send with attachment", + MsgText: "Interactive List Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponses: map[MockedRequest]*httpx.MockResponse{ + { + Method: "POST", + Path: "/messages", + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg"}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + { + Method: "POST", + Path: "/messages", + Body: `{"messaging_product":"whatsapp","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"}]}]}}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + }, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Link Sending", + MsgText: "Link Sending https://link.com", + MsgURN: "whatsapp:250788123123", + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"Link Sending https://link.com","preview_url":true}}`, + ExpectedRequestPath: "/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Error Bad JSON", + MsgText: "Error", + MsgURN: "whatsapp:250788123123", + MockResponseBody: `bad json`, + MockResponseStatus: 403, + ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseUnparseable("JSON")}, + ExpectedMsgStatus: "E", + SendPrep: setSendURL, + }, + { + Label: "Error", + MsgText: "Error", + MsgURN: "whatsapp:250788123123", + MockResponseBody: `{ "error": {"message": "(#130429) Rate limit hit","code": 130429 }}`, + MockResponseStatus: 403, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("130429", "(#130429) Rate limit hit")}, + ExpectedMsgStatus: "E", + SendPrep: setSendURL, + }, +} + +func TestOutgoing(t *testing.T) { + // shorter max msg length for testing + maxMsgLength = 100 + + var ChannelWAC = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "D3C", "12345_ID", "", map[string]any{ + "auth_token": "the-auth-token", + "base_url": "https://waba-v2.360dialog.io", + }) + checkRedacted := []string{"the-auth-token"} + + RunOutgoingTestCases(t, ChannelWAC, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), SendTestCasesD3C, checkRedacted, nil) +} +func TestGetSupportedLanguage(t *testing.T) { + assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.NilLocale)) + assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.Locale("eng"))) + assert.Equal(t, languageInfo{"en_US", "Menu"}, getSupportedLanguage(courier.Locale("eng-US"))) + assert.Equal(t, languageInfo{"pt_PT", "Menu"}, getSupportedLanguage(courier.Locale("por"))) + assert.Equal(t, languageInfo{"pt_PT", "Menu"}, getSupportedLanguage(courier.Locale("por-PT"))) + assert.Equal(t, languageInfo{"pt_BR", "Menu"}, getSupportedLanguage(courier.Locale("por-BR"))) + assert.Equal(t, languageInfo{"fil", "Menu"}, getSupportedLanguage(courier.Locale("fil"))) + assert.Equal(t, languageInfo{"fr", "Menu"}, getSupportedLanguage(courier.Locale("fra-CA"))) + assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.Locale("run"))) +} diff --git a/handlers/facebookapp/facebookapp.go b/handlers/facebookapp/facebookapp.go new file mode 100644 index 000000000..d50fbd358 --- /dev/null +++ b/handlers/facebookapp/facebookapp.go @@ -0,0 +1,1684 @@ +package facebookapp + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/buger/jsonparser" + "github.com/nyaruka/courier" + "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/utils" + "github.com/nyaruka/gocommon/urns" + "github.com/pkg/errors" +) + +// Endpoints we hit +var ( + sendURL = "https://graph.facebook.com/v12.0/me/messages" + graphURL = "https://graph.facebook.com/v12.0/" + + signatureHeader = "X-Hub-Signature-256" + + maxRequestBodyBytes int64 = 1024 * 1024 + + // max for the body + maxMsgLength = 1000 + + // Sticker ID substitutions + stickerIDToEmoji = map[int64]string{ + 369239263222822: "👍", // small + 369239343222814: "👍", // medium + 369239383222810: "👍", // big + } + + tagByTopic = map[string]string{ + "event": "CONFIRMED_EVENT_UPDATE", + "purchase": "POST_PURCHASE_UPDATE", + "account": "ACCOUNT_UPDATE", + "agent": "HUMAN_AGENT", + } +) + +// keys for extra in channel events +const ( + referrerIDKey = "referrer_id" + sourceKey = "source" + adIDKey = "ad_id" + typeKey = "type" + titleKey = "title" + payloadKey = "payload" +) + +var waStatusMapping = map[string]courier.MsgStatus{ + "sent": courier.MsgStatusSent, + "delivered": courier.MsgStatusDelivered, + "read": courier.MsgStatusDelivered, + "failed": courier.MsgStatusFailed, +} + +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, []string{courier.ConfigAuthToken})} +} + +func init() { + courier.RegisterHandler(newHandler("IG", "Instagram", false)) + courier.RegisterHandler(newHandler("FBA", "Facebook", false)) + courier.RegisterHandler(newHandler("WAC", "WhatsApp Cloud", false)) + +} + +type handler struct { + handlers.BaseHandler +} + +// Initialize is called by the engine once everything is loaded +func (h *handler) Initialize(s courier.Server) error { + h.SetServer(s) + s.AddHandlerRoute(h, http.MethodGet, "receive", courier.ChannelLogTypeWebhookVerify, h.receiveVerify) + s.AddHandlerRoute(h, http.MethodPost, "receive", courier.ChannelLogTypeMultiReceive, handlers.JSONPayload(h, h.receiveEvents)) + return nil +} + +type Sender struct { + ID string `json:"id"` + UserRef string `json:"user_ref,omitempty"` +} + +type User struct { + ID string `json:"id"` +} + +// { +// "object":"page", +// "entry":[{ +// "id":"180005062406476", +// "time":1514924367082, +// "messaging":[{ +// "sender": {"id":"1630934236957797"}, +// "recipient":{"id":"180005062406476"}, +// "timestamp":1514924366807, +// "message":{ +// "mid":"mid.$cAAD5QiNHkz1m6cyj11guxokwkhi2", +// "seq":33116, +// "text":"65863634" +// } +// }] +// }] +// } + +type wacMedia struct { + Caption string `json:"caption"` + Filename string `json:"filename"` + ID string `json:"id"` + Mimetype string `json:"mime_type"` + SHA256 string `json:"sha256"` +} + +type wacSticker struct { + Animated bool `json:"animated"` + 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"` + 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 *wacMedia `json:"image"` + Audio *wacMedia `json:"audio"` + Video *wacMedia `json:"video"` + Document *wacMedia `json:"document"` + Voice *wacMedia `json:"voice"` + Sticker *wacSticker `json:"sticker"` + 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"` + Interactive struct { + Type string `json:"type"` + ButtonReply struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"button_reply,omitempty"` + ListReply struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"list_reply,omitempty"` + } `json:"interactive,omitempty"` + Errors []struct { + Code int `json:"code"` + Title string `json:"title"` + } `json:"errors"` + } `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"` + } `json:"conversation"` + Pricing *struct { + PricingModel string `json:"pricing_model"` + Billable bool `json:"billable"` + Category string `json:"category"` + } `json:"pricing"` + Errors []struct { + Code int `json:"code"` + Title string `json:"title"` + } `json:"errors"` + } `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"` + Timestamp int64 `json:"timestamp"` + + OptIn *struct { + Ref string `json:"ref"` + UserRef string `json:"user_ref"` + } `json:"optin"` + + Referral *struct { + Ref string `json:"ref"` + Source string `json:"source"` + Type string `json:"type"` + AdID string `json:"ad_id"` + } `json:"referral"` + + Postback *struct { + MID string `json:"mid"` + Title string `json:"title"` + Payload string `json:"payload"` + Referral struct { + Ref string `json:"ref"` + Source string `json:"source"` + Type string `json:"type"` + AdID string `json:"ad_id"` + } `json:"referral"` + } `json:"postback"` + + Message *struct { + IsEcho bool `json:"is_echo"` + MID string `json:"mid"` + Text string `json:"text"` + IsDeleted bool `json:"is_deleted"` + Attachments []struct { + Type string `json:"type"` + Payload *struct { + URL string `json:"url"` + StickerID int64 `json:"sticker_id"` + Coordinates *struct { + Lat float64 `json:"lat"` + Long float64 `json:"long"` + } `json:"coordinates"` + } + } `json:"attachments"` + } `json:"message"` + + Delivery *struct { + MIDs []string `json:"mids"` + Watermark int64 `json:"watermark"` + } `json:"delivery"` + } `json:"messaging"` + } `json:"entry"` +} + +func (h *handler) RedactValues(ch courier.Channel) []string { + vals := h.BaseHandler.RedactValues(ch) + vals = append(vals, h.Server().Config().FacebookApplicationSecret, h.Server().Config().FacebookWebhookSecret, h.Server().Config().WhatsappAdminSystemUserToken) + return vals +} + +// WriteRequestError writes the passed in error to our response writer +func (h *handler) WriteRequestError(ctx context.Context, w http.ResponseWriter, err error) error { + return courier.WriteError(w, http.StatusOK, err) +} + +// GetChannel returns the channel +func (h *handler) GetChannel(ctx context.Context, r *http.Request) (courier.Channel, error) { + if r.Method == http.MethodGet { + return nil, nil + } + + payload := &moPayload{} + err := handlers.DecodeAndValidateJSON(payload, r) + if err != nil { + return nil, err + } + + // is not a 'page' and 'instagram' object? ignore it + 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 + if len(payload.Entry) == 0 { + return nil, fmt.Errorf("no entries found") + } + + var channelAddress string + + //if object is 'page' returns type FBA, if object is 'instagram' returns type IG + if payload.Object == "page" { + 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 { + if len(payload.Entry[0].Changes) == 0 { + return nil, fmt.Errorf("no changes found") + } + + channelAddress = payload.Entry[0].Changes[0].Value.Metadata.PhoneNumberID + if channelAddress == "" { + return nil, fmt.Errorf("no channel address found") + } + return h.Backend().GetChannelByAddress(ctx, courier.ChannelType("WAC"), courier.ChannelAddress(channelAddress)) + } +} + +// receiveVerify handles Facebook's webhook verification callback +func (h *handler) receiveVerify(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, error) { + mode := r.URL.Query().Get("hub.mode") + + // this isn't a subscribe verification, that's an error + if mode != "subscribe" { + return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("unknown request")) + } + + // verify the token against our server facebook webhook secret, if the same return the challenge FB sent us + secret := r.URL.Query().Get("hub.verify_token") + if secret != h.Server().Config().FacebookWebhookSecret { + return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("token does not match secret")) + } + // and respond with the challenge token + _, err := fmt.Fprint(w, r.URL.Query().Get("hub.challenge")) + return nil, err +} + +func resolveMediaURL(mediaID string, token string, clog *courier.ChannelLog) (string, error) { + if token == "" { + return "", fmt.Errorf("missing token for WA channel") + } + + base, _ := url.Parse(graphURL) + path, _ := url.Parse(fmt.Sprintf("/%s", mediaID)) + retrieveURL := base.ResolveReference(path) + + // set the access token as the authorization header + req, _ := http.NewRequest(http.MethodGet, retrieveURL.String(), nil) + //req.Header.Set("User-Agent", utils.HTTPUserAgent) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + resp, respBody, err := handlers.RequestHTTP(req, clog) + if err != nil || resp.StatusCode/100 != 2 { + return "", errors.New("error resolving media URL") + } + + mediaURL, err := jsonparser.GetString(respBody, "url") + return mediaURL, err +} + +// receiveEvents is our HTTP handler function for incoming messages and status updates +func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, payload *moPayload, clog *courier.ChannelLog) ([]courier.Event, error) { + err := h.validateSignature(r) + if err != nil { + return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + } + + // is not a 'page' and 'instagram' object? ignore it + if payload.Object != "page" && payload.Object != "instagram" && payload.Object != "whatsapp_business_account" { + return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request") + } + + // no entries? ignore this request + if len(payload.Entry) == 0 { + return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request, no entries") + } + + var events []courier.Event + var data []any + + if channel.ChannelType() == "FBA" || channel.ChannelType() == "IG" { + events, data, err = h.processFacebookInstagramPayload(ctx, channel, payload, w, r, clog) + } else { + events, data, err = h.processCloudWhatsAppPayload(ctx, channel, payload, w, r, clog) + + } + + if err != nil { + return nil, err + } + + return events, courier.WriteDataResponse(w, http.StatusOK, "Events Handled", data) +} + +func (h *handler) processCloudWhatsAppPayload(ctx context.Context, channel courier.Channel, payload *moPayload, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, []any, 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([]any, 0, 2) + + token := h.Server().Config().WhatsappAdminSystemUserToken + + seenMsgIDs := make(map[string]bool, 2) + 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 { + if seenMsgIDs[msg.ID] { + continue + } + + // 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) + } + + for _, msgError := range msg.Errors { + clog.Error(courier.ErrorExternal(strconv.Itoa(msgError.Code), msgError.Title)) + } + + 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(msg.Audio.ID, token, clog) + } else if msg.Type == "voice" && msg.Voice != nil { + text = msg.Voice.Caption + mediaURL, err = resolveMediaURL(msg.Voice.ID, token, clog) + } 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(msg.Document.ID, token, clog) + } else if msg.Type == "image" && msg.Image != nil { + text = msg.Image.Caption + mediaURL, err = resolveMediaURL(msg.Image.ID, token, clog) + } else if msg.Type == "sticker" && msg.Sticker != nil { + mediaURL, err = resolveMediaURL(msg.Sticker.ID, token, clog) + } else if msg.Type == "video" && msg.Video != nil { + text = msg.Video.Caption + mediaURL, err = resolveMediaURL(msg.Video.ID, token, clog) + } else if msg.Type == "location" && msg.Location != nil { + mediaURL = fmt.Sprintf("geo:%f,%f", msg.Location.Latitude, msg.Location.Longitude) + } else if msg.Type == "interactive" && msg.Interactive.Type == "button_reply" { + text = msg.Interactive.ButtonReply.Title + } else if msg.Type == "interactive" && msg.Interactive.Type == "list_reply" { + text = msg.Interactive.ListReply.Title + } else { + // we received a message type we do not support. + courier.LogRequestError(r, channel, fmt.Errorf("unsupported message type %s", msg.Type)) + continue + } + + // create our message + event := h.Backend().NewIncomingMsg(channel, urn, text, msg.ID, clog).WithReceivedOn(date).WithContactName(contactNames[msg.From]) + + // 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, clog) + if err != nil { + return nil, nil, err + } + + events = append(events, event) + data = append(data, courier.NewMsgReceiveData(event)) + seenMsgIDs[msg.ID] = true + } + + 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 + } + + for _, statusError := range status.Errors { + clog.Error(courier.ErrorExternal(strconv.Itoa(statusError.Code), statusError.Title)) + } + + event := h.Backend().NewStatusUpdateByExternalID(channel, status.ID, msgStatus, clog) + err := h.Backend().WriteStatusUpdate(ctx, event) + if err != nil { + return nil, nil, err + } + + events = append(events, event) + data = append(data, courier.NewStatusData(event)) + + } + + for _, chError := range change.Value.Errors { + clog.Error(courier.ErrorExternal(strconv.Itoa(chError.Code), chError.Title)) + } + + } + + } + return events, data, nil +} + +func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel courier.Channel, payload *moPayload, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, []any, error) { + var err 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([]any, 0, 2) + + seenMsgIDs := make(map[string]bool, 2) + + // for each entry + for _, entry := range payload.Entry { + // no entry, ignore + if len(entry.Messaging) == 0 { + continue + } + + // grab our message, there is always a single one + msg := entry.Messaging[0] + + // ignore this entry if it is to another page + if channel.Address() != msg.Recipient.ID { + continue + } + + // create our date from the timestamp (they give us millis, arg is nanos) + date := time.Unix(0, msg.Timestamp*1000000).UTC() + + sender := msg.Sender.UserRef + if sender == "" { + sender = msg.Sender.ID + } + + var urn urns.URN + + // create our URN + if payload.Object == "instagram" { + urn, err = urns.NewInstagramURN(sender) + if err != nil { + return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + } + } else { + urn, err = urns.NewFacebookURN(sender) + if err != nil { + return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + } + } + + if msg.OptIn != nil { + // this is an opt in, if we have a user_ref, use that as our URN (this is a checkbox plugin) + // TODO: + // We need to deal with the case of them responding and remapping the user_ref in that case: + // https://developers.facebook.com/docs/messenger-platform/discovery/checkbox-plugin + // Right now that we even support this isn't documented and I don't think anybody uses it, so leaving that out. + // (things will still work, we just will have dupe contacts, one with user_ref for the first contact, then with the real id when they reply) + if msg.OptIn.UserRef != "" { + urn, err = urns.NewFacebookURN(urns.FacebookRefPrefix + msg.OptIn.UserRef) + if err != nil { + return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + } + } + + event := h.Backend().NewChannelEvent(channel, courier.Referral, urn, clog).WithOccurredOn(date) + + // build our extra + extra := map[string]any{ + referrerIDKey: msg.OptIn.Ref, + } + event = event.WithExtra(extra) + + err := h.Backend().WriteChannelEvent(ctx, event, clog) + if err != nil { + return nil, nil, err + } + + events = append(events, event) + data = append(data, courier.NewEventReceiveData(event)) + + } else if msg.Postback != nil { + // by default postbacks are treated as new conversations, unless we have referral information + eventType := courier.NewConversation + if msg.Postback.Referral.Ref != "" { + eventType = courier.Referral + } + event := h.Backend().NewChannelEvent(channel, eventType, urn, clog).WithOccurredOn(date) + + // build our extra + extra := map[string]any{ + titleKey: msg.Postback.Title, + payloadKey: msg.Postback.Payload, + } + + // add in referral information if we have it + if eventType == courier.Referral { + extra[referrerIDKey] = msg.Postback.Referral.Ref + extra[sourceKey] = msg.Postback.Referral.Source + extra[typeKey] = msg.Postback.Referral.Type + + if msg.Postback.Referral.AdID != "" { + extra[adIDKey] = msg.Postback.Referral.AdID + } + } + + event = event.WithExtra(extra) + + err := h.Backend().WriteChannelEvent(ctx, event, clog) + if err != nil { + return nil, nil, err + } + + events = append(events, event) + data = append(data, courier.NewEventReceiveData(event)) + + } else if msg.Referral != nil { + // this is an incoming referral + event := h.Backend().NewChannelEvent(channel, courier.Referral, urn, clog).WithOccurredOn(date) + + // build our extra + extra := map[string]any{ + sourceKey: msg.Referral.Source, + typeKey: msg.Referral.Type, + } + + // add referrer id if present + if msg.Referral.Ref != "" { + extra[referrerIDKey] = msg.Referral.Ref + } + + // add ad id if present + if msg.Referral.AdID != "" { + extra[adIDKey] = msg.Referral.AdID + } + event = event.WithExtra(extra) + + err := h.Backend().WriteChannelEvent(ctx, event, clog) + if err != nil { + return nil, nil, err + } + + events = append(events, event) + data = append(data, courier.NewEventReceiveData(event)) + + } else if msg.Message != nil { + // this is an incoming message + if seenMsgIDs[msg.Message.MID] { + continue + } + + // ignore echos + if msg.Message.IsEcho { + data = append(data, courier.NewInfoData("ignoring echo")) + continue + } + + if msg.Message.IsDeleted { + h.Backend().DeleteMsgByExternalID(ctx, channel, msg.Message.MID) + data = append(data, courier.NewInfoData("msg deleted")) + continue + } + + has_story_mentions := false + + text := msg.Message.Text + + attachmentURLs := make([]string, 0, 2) + + // if we have a sticker ID, use that as our text + for _, att := range msg.Message.Attachments { + if att.Type == "image" && att.Payload != nil && att.Payload.StickerID != 0 { + text = stickerIDToEmoji[att.Payload.StickerID] + } + + if att.Type == "location" { + attachmentURLs = append(attachmentURLs, fmt.Sprintf("geo:%f,%f", att.Payload.Coordinates.Lat, att.Payload.Coordinates.Long)) + } + + if att.Type == "story_mention" { + data = append(data, courier.NewInfoData("ignoring story_mention")) + has_story_mentions = true + continue + } + + if att.Payload != nil && att.Payload.URL != "" { + attachmentURLs = append(attachmentURLs, att.Payload.URL) + } + + } + + // if we have a story mention, skip and do not save any message + if has_story_mentions { + continue + } + + // create our message + event := h.Backend().NewIncomingMsg(channel, urn, text, msg.Message.MID, clog).WithReceivedOn(date) + + // add any attachment URL found + for _, attURL := range attachmentURLs { + event.WithAttachment(attURL) + } + + err := h.Backend().WriteMsg(ctx, event, clog) + if err != nil { + return nil, nil, err + } + + events = append(events, event) + data = append(data, courier.NewMsgReceiveData(event)) + seenMsgIDs[msg.Message.MID] = true + + } else if msg.Delivery != nil { + // this is a delivery report + for _, mid := range msg.Delivery.MIDs { + event := h.Backend().NewStatusUpdateByExternalID(channel, mid, courier.MsgStatusDelivered, clog) + err := h.Backend().WriteStatusUpdate(ctx, event) + if err != nil { + return nil, nil, err + } + + events = append(events, event) + data = append(data, courier.NewStatusData(event)) + } + + } else { + data = append(data, courier.NewInfoData("ignoring unknown entry type")) + } + } + + return events, data, nil +} + +// { +// "messaging_type": "" +// "recipient": { +// "id":"" +// }, +// "message": { +// "text":"hello, world!" +// "attachment":{ +// "type":"image", +// "payload":{ +// "url":"http://www.messenger-rocks.com/image.jpg", +// "is_reusable":true +// } +// } +// } +// } +type mtPayload struct { + MessagingType string `json:"messaging_type"` + Tag string `json:"tag,omitempty"` + Recipient struct { + UserRef string `json:"user_ref,omitempty"` + ID string `json:"id,omitempty"` + } `json:"recipient"` + Message struct { + Text string `json:"text,omitempty"` + QuickReplies []mtQuickReply `json:"quick_replies,omitempty"` + Attachment *mtAttachment `json:"attachment,omitempty"` + } `json:"message"` +} + +type mtAttachment struct { + Type string `json:"type"` + Payload struct { + URL string `json:"url"` + IsReusable bool `json:"is_reusable"` + } `json:"payload"` +} + +type mtQuickReply struct { + Title string `json:"title"` + Payload string `json:"payload"` + ContentType string `json:"content_type"` +} + +func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + if msg.Channel().ChannelType() == "FBA" || msg.Channel().ChannelType() == "IG" { + return h.sendFacebookInstagramMsg(ctx, msg, clog) + } else if msg.Channel().ChannelType() == "WAC" { + return h.sendCloudAPIWhatsappMsg(ctx, msg, clog) + } + + return nil, fmt.Errorf("unssuported channel type") +} + +type fbaMTResponse struct { + ExternalID string `json:"message_id"` + RecipientID string `json:"recipient_id"` + Error struct { + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` +} + +func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + // can't do anything without an access token + accessToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") + if accessToken == "" { + return nil, fmt.Errorf("missing access token") + } + + isHuman := msg.Origin() == courier.MsgOriginChat || msg.Origin() == courier.MsgOriginTicket + payload := mtPayload{} + + if msg.Topic() != "" || isHuman { + payload.MessagingType = "MESSAGE_TAG" + + if msg.Topic() != "" { + payload.Tag = tagByTopic[msg.Topic()] + } else if isHuman { + // this will most likely fail if we're out of the 7 day window.. but user was warned and we try anyway + payload.Tag = "HUMAN_AGENT" + } + } else { + if msg.ResponseToExternalID() != "" { + payload.MessagingType = "RESPONSE" + } else { + payload.MessagingType = "UPDATE" + } + } + + // build our recipient + if msg.URN().IsFacebookRef() { + payload.Recipient.UserRef = msg.URN().FacebookRef() + } else { + payload.Recipient.ID = msg.URN().Path() + } + + msgURL, _ := url.Parse(sendURL) + query := url.Values{} + query.Set("access_token", accessToken) + msgURL.RawQuery = query.Encode() + + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) + + msgParts := make([]string, 0) + if msg.Text() != "" { + msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) + } + + // send each part and each attachment separately. we send attachments first as otherwise quick replies + // attached to text messages get hidden when images get delivered + for i := 0; i < len(msgParts)+len(msg.Attachments()); i++ { + if i < len(msg.Attachments()) { + // this is an attachment + payload.Message.Attachment = &mtAttachment{} + attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) + attType = strings.Split(attType, "/")[0] + if attType == "application" { + attType = "file" + } + payload.Message.Attachment.Type = attType + payload.Message.Attachment.Payload.URL = attURL + payload.Message.Attachment.Payload.IsReusable = true + payload.Message.Text = "" + } else { + // this is still a msg part + payload.Message.Text = msgParts[i-len(msg.Attachments())] + payload.Message.Attachment = nil + } + + // include any quick replies on the last piece we send + if i == (len(msgParts)+len(msg.Attachments()))-1 { + for _, qr := range msg.QuickReplies() { + payload.Message.QuickReplies = append(payload.Message.QuickReplies, mtQuickReply{qr, qr, "text"}) + } + } else { + payload.Message.QuickReplies = nil + } + + jsonBody, err := json.Marshal(payload) + if err != nil { + return status, err + } + + req, err := http.NewRequest(http.MethodPost, msgURL.String(), bytes.NewReader(jsonBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + _, respBody, _ := handlers.RequestHTTP(req, clog) + respPayload := &fbaMTResponse{} + err = json.Unmarshal(respBody, respPayload) + if err != nil { + clog.Error(courier.ErrorResponseUnparseable("JSON")) + return status, nil + } + + if respPayload.Error.Code != 0 { + clog.Error(courier.ErrorExternal(strconv.Itoa(respPayload.Error.Code), respPayload.Error.Message)) + return status, nil + } + + if respPayload.ExternalID == "" { + clog.Error(courier.ErrorResponseValueMissing("message_id")) + return status, nil + } + + // if this is our first message, record the external id + if i == 0 { + status.SetExternalID(respPayload.ExternalID) + if msg.URN().IsFacebookRef() { + recipientID := respPayload.RecipientID + if recipientID == "" { + clog.Error(courier.ErrorResponseValueMissing("recipient_id")) + return status, nil + } + + referralID := msg.URN().FacebookRef() + + realIDURN, err := urns.NewFacebookURN(recipientID) + if err != nil { + clog.RawError(errors.Errorf("unable to make facebook urn from %s", recipientID)) + } + + contact, err := h.Backend().GetContact(ctx, msg.Channel(), msg.URN(), "", "", clog) + if err != nil { + clog.RawError(errors.Errorf("unable to get contact for %s", msg.URN().String())) + } + realURN, err := h.Backend().AddURNtoContact(ctx, msg.Channel(), contact, realIDURN) + if err != nil { + clog.RawError(errors.Errorf("unable to add real facebook URN %s to contact with uuid %s", realURN.String(), contact.UUID())) + } + referralIDExtURN, err := urns.NewURNFromParts(urns.ExternalScheme, referralID, "", "") + if err != nil { + clog.RawError(errors.Errorf("unable to make ext urn from %s", referralID)) + } + extURN, err := h.Backend().AddURNtoContact(ctx, msg.Channel(), contact, referralIDExtURN) + if err != nil { + clog.RawError(errors.Errorf("unable to add URN %s to contact with uuid %s", extURN.String(), contact.UUID())) + } + + referralFacebookURN, err := h.Backend().RemoveURNfromContact(ctx, msg.Channel(), contact, msg.URN()) + if err != nil { + clog.RawError(errors.Errorf("unable to remove referral facebook URN %s from contact with uuid %s", referralFacebookURN.String(), contact.UUID())) + } + + } + + } + + // this was wired successfully + status.SetStatus(courier.MsgStatusWired) + } + + return status, nil +} + +type wacMTMedia struct { + ID string `json:"id,omitempty"` + Link string `json:"link,omitempty"` + Caption string `json:"caption,omitempty"` + Filename string `json:"filename,omitempty"` +} + +type wacMTSection struct { + Title string `json:"title,omitempty"` + Rows []wacMTSectionRow `json:"rows" validate:"required"` +} + +type wacMTSectionRow struct { + ID string `json:"id" validate:"required"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` +} + +type wacMTButton 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 wacParam struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type wacComponent struct { + Type string `json:"type"` + SubType string `json:"sub_type"` + Index string `json:"index"` + Params []*wacParam `json:"parameters"` +} + +type wacText struct { + Body string `json:"body"` + PreviewURL bool `json:"preview_url"` +} + +type wacLanguage struct { + Policy string `json:"policy"` + Code string `json:"code"` +} + +type wacTemplate struct { + Name string `json:"name"` + Language *wacLanguage `json:"language"` + Components []*wacComponent `json:"components"` +} + +type wacInteractive struct { + Type string `json:"type"` + Header *struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Video *wacMTMedia `json:"video,omitempty"` + Image *wacMTMedia `json:"image,omitempty"` + Document *wacMTMedia `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 []wacMTSection `json:"sections,omitempty"` + Buttons []wacMTButton `json:"buttons,omitempty"` + } `json:"action,omitempty"` +} + +type wacMTPayload struct { + MessagingProduct string `json:"messaging_product"` + RecipientType string `json:"recipient_type"` + To string `json:"to"` + Type string `json:"type"` + + Text *wacText `json:"text,omitempty"` + + Document *wacMTMedia `json:"document,omitempty"` + Image *wacMTMedia `json:"image,omitempty"` + Audio *wacMTMedia `json:"audio,omitempty"` + Video *wacMTMedia `json:"video,omitempty"` + Sticker *wacMTMedia `json:"sticker,omitempty"` + + Interactive *wacInteractive `json:"interactive,omitempty"` + + Template *wacTemplate `json:"template,omitempty"` +} + +type wacMTResponse struct { + Messages []*struct { + ID string `json:"id"` + } `json:"messages"` + Error struct { + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` +} + +func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + // can't do anything without an access token + accessToken := h.Server().Config().WhatsappAdminSystemUserToken + + base, _ := url.Parse(graphURL) + path, _ := url.Parse(fmt.Sprintf("/%s/messages", msg.Channel().Address())) + wacPhoneURL := base.ResolveReference(path) + + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) + + hasCaption := false + + msgParts := make([]string, 0) + if msg.Text() != "" { + msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) + } + qrs := msg.QuickReplies() + lang := getSupportedLanguage(msg.Locale()) + + var payloadAudio wacMTPayload + + for i := 0; i < len(msgParts)+len(msg.Attachments()); i++ { + payload := wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path()} + + if len(msg.Attachments()) == 0 { + // do we have a template? + templating, err := h.getTemplating(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 := wacTemplate{Name: templating.Template.Name, Language: &wacLanguage{Policy: "deterministic", Code: lang.code}} + payload.Template = &template + + component := &wacComponent{Type: "body"} + + for _, v := range templating.Variables { + component.Params = append(component.Params, &wacParam{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 + text := &wacText{PreviewURL: false} + payload.Type = "text" + if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + text.PreviewURL = true + } + text.Body = msgParts[i-len(msg.Attachments())] + payload.Text = text + } else { + if len(qrs) > 0 { + payload.Type = "interactive" + // We can use buttons + if len(qrs) <= 3 { + interactive := wacInteractive{Type: "button", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + btns := make([]wacMTButton, len(qrs)) + for i, qr := range qrs { + btns[i] = wacMTButton{ + Type: "reply", + } + btns[i].Reply.ID = fmt.Sprint(i) + btns[i].Reply.Title = qr + } + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []wacMTSection "json:\"sections,omitempty\"" + Buttons []wacMTButton "json:\"buttons,omitempty\"" + }{Buttons: btns} + payload.Interactive = &interactive + } else if len(qrs) <= 10 { + interactive := wacInteractive{Type: "list", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + section := wacMTSection{ + Rows: make([]wacMTSectionRow, len(qrs)), + } + for i, qr := range qrs { + section.Rows[i] = wacMTSectionRow{ + ID: fmt.Sprint(i), + Title: qr, + } + } + + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []wacMTSection "json:\"sections,omitempty\"" + Buttons []wacMTButton "json:\"buttons,omitempty\"" + }{Button: lang.menu, Sections: []wacMTSection{ + section, + }} + + payload.Interactive = &interactive + } else { + return nil, fmt.Errorf("too many quick replies WAC supports only up to 10 quick replies") + } + } else { + // this is still a msg part + text := &wacText{PreviewURL: false} + payload.Type = "text" + if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + text.PreviewURL = true + } + text.Body = msgParts[i-len(msg.Attachments())] + payload.Text = text + } + } + } + + } else if i < len(msg.Attachments()) && (len(qrs) == 0 || len(qrs) > 3) { + attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) + splitedAttType := strings.Split(attType, "/") + attType = splitedAttType[0] + attFormat := splitedAttType[1] + + if attType == "application" { + attType = "document" + } + payload.Type = attType + media := wacMTMedia{Link: attURL} + + if len(msgParts) == 1 && attType != "audio" && len(msg.Attachments()) == 1 && len(msg.QuickReplies()) == 0 { + media.Caption = msgParts[i] + hasCaption = true + } + + if attType == "image" { + if attFormat == "webp" { + payload.Type = "sticker" + payload.Sticker = &media + } else { + payload.Image = &media + } + } else if attType == "audio" { + payload.Audio = &media + } else if attType == "video" { + payload.Video = &media + } else if attType == "document" { + filename, err := utils.BasePathForURL(attURL) + if err != nil { + filename = "" + } + if filename != "" { + media.Filename = filename + } + payload.Document = &media + } + } else { + if len(qrs) > 0 { + payload.Type = "interactive" + // We can use buttons + if len(qrs) <= 3 { + interactive := wacInteractive{Type: "button", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i]}} + + if len(msg.Attachments()) > 0 { + hasCaption = true + attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) + attType = strings.Split(attType, "/")[0] + if attType == "application" { + attType = "document" + } + if attType == "image" { + image := wacMTMedia{ + Link: attURL, + } + interactive.Header = &struct { + Type string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *wacMTMedia "json:\"video,omitempty\"" + Image *wacMTMedia "json:\"image,omitempty\"" + Document *wacMTMedia "json:\"document,omitempty\"" + }{Type: "image", Image: &image} + } else if attType == "video" { + video := wacMTMedia{ + Link: attURL, + } + interactive.Header = &struct { + Type string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *wacMTMedia "json:\"video,omitempty\"" + Image *wacMTMedia "json:\"image,omitempty\"" + Document *wacMTMedia "json:\"document,omitempty\"" + }{Type: "video", Video: &video} + } else if attType == "document" { + filename, err := utils.BasePathForURL(attURL) + if err != nil { + return nil, err + } + document := wacMTMedia{ + Link: attURL, + Filename: filename, + } + interactive.Header = &struct { + Type string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *wacMTMedia "json:\"video,omitempty\"" + Image *wacMTMedia "json:\"image,omitempty\"" + Document *wacMTMedia "json:\"document,omitempty\"" + }{Type: "document", Document: &document} + } else if attType == "audio" { + var zeroIndex bool + if i == 0 { + zeroIndex = true + } + payloadAudio = wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path(), Type: "audio", Audio: &wacMTMedia{Link: attURL}} + status, err := requestWAC(payloadAudio, accessToken, status, wacPhoneURL, zeroIndex, clog) + if err != nil { + return status, nil + } + } else { + interactive.Type = "button" + interactive.Body.Text = msgParts[i] + } + } + + btns := make([]wacMTButton, len(qrs)) + for i, qr := range qrs { + btns[i] = wacMTButton{ + Type: "reply", + } + btns[i].Reply.ID = fmt.Sprint(i) + btns[i].Reply.Title = qr + } + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []wacMTSection "json:\"sections,omitempty\"" + Buttons []wacMTButton "json:\"buttons,omitempty\"" + }{Buttons: btns} + payload.Interactive = &interactive + + } else if len(qrs) <= 10 { + interactive := wacInteractive{Type: "list", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + section := wacMTSection{ + Rows: make([]wacMTSectionRow, len(qrs)), + } + for i, qr := range qrs { + section.Rows[i] = wacMTSectionRow{ + ID: fmt.Sprint(i), + Title: qr, + } + } + + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []wacMTSection "json:\"sections,omitempty\"" + Buttons []wacMTButton "json:\"buttons,omitempty\"" + }{Button: lang.menu, Sections: []wacMTSection{ + section, + }} + + payload.Interactive = &interactive + } else { + return nil, fmt.Errorf("too many quick replies WAC supports only up to 10 quick replies") + } + } else { + // this is still a msg part + text := &wacText{PreviewURL: false} + payload.Type = "text" + if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + text.PreviewURL = true + } + text.Body = msgParts[i-len(msg.Attachments())] + payload.Text = text + } + } + + var zeroIndex bool + if i == 0 { + zeroIndex = true + } + + status, err := requestWAC(payload, accessToken, status, wacPhoneURL, zeroIndex, clog) + if err != nil { + return status, err + } + + if hasCaption { + break + } + } + return status, nil +} + +func requestWAC(payload wacMTPayload, accessToken string, status courier.StatusUpdate, wacPhoneURL *url.URL, zeroIndex bool, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + jsonBody, err := json.Marshal(payload) + if err != nil { + return status, err + } + + req, err := http.NewRequest(http.MethodPost, wacPhoneURL.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") + + _, respBody, _ := handlers.RequestHTTP(req, clog) + respPayload := &wacMTResponse{} + err = json.Unmarshal(respBody, respPayload) + if err != nil { + clog.Error(courier.ErrorResponseUnparseable("JSON")) + return status, nil + } + + if respPayload.Error.Code != 0 { + clog.Error(courier.ErrorExternal(strconv.Itoa(respPayload.Error.Code), respPayload.Error.Message)) + return status, nil + } + + externalID := respPayload.Messages[0].ID + if zeroIndex && externalID != "" { + status.SetExternalID(externalID) + } + // this was wired successfully + status.SetStatus(courier.MsgStatusWired) + return status, nil +} + +// DescribeURN looks up URN metadata for new contacts +func (h *handler) DescribeURN(ctx context.Context, channel courier.Channel, urn urns.URN, clog *courier.ChannelLog) (map[string]string, error) { + if channel.ChannelType() == "WAC" { + return map[string]string{}, nil + } + + // can't do anything with facebook refs, ignore them + if urn.IsFacebookRef() { + return map[string]string{}, nil + } + + accessToken := channel.StringConfigForKey(courier.ConfigAuthToken, "") + if accessToken == "" { + return nil, fmt.Errorf("missing access token") + } + + // build a request to lookup the stats for this contact + base, _ := url.Parse(graphURL) + path, _ := url.Parse(fmt.Sprintf("/%s", urn.Path())) + u := base.ResolveReference(path) + query := url.Values{} + var name string + + if fmt.Sprint(channel.ChannelType()) == "FBA" { + query.Set("fields", "first_name,last_name") + } + + query.Set("access_token", accessToken) + u.RawQuery = query.Encode() + req, _ := http.NewRequest(http.MethodGet, u.String(), nil) + + resp, respBody, err := handlers.RequestHTTP(req, clog) + if err != nil || resp.StatusCode/100 != 2 { + return nil, errors.New("unable to look up contact data") + } + + // read our first and last name or complete name + if fmt.Sprint(channel.ChannelType()) == "FBA" { + firstName, _ := jsonparser.GetString(respBody, "first_name") + lastName, _ := jsonparser.GetString(respBody, "last_name") + name = utils.JoinNonEmpty(" ", firstName, lastName) + } else { + name, _ = jsonparser.GetString(respBody, "name") + } + + return map[string]string{"name": name}, nil + +} + +// see https://developers.facebook.com/docs/messenger-platform/webhook#security +func (h *handler) validateSignature(r *http.Request) error { + headerSignature := r.Header.Get(signatureHeader) + if headerSignature == "" { + return fmt.Errorf("missing request signature") + } + appSecret := h.Server().Config().FacebookApplicationSecret + + body, err := handlers.ReadBody(r, maxRequestBodyBytes) + if err != nil { + return fmt.Errorf("unable to read request body: %s", err) + } + + expectedSignature, err := fbCalculateSignature(appSecret, body) + if err != nil { + return err + } + + signature := "" + if len(headerSignature) == 71 && strings.HasPrefix(headerSignature, "sha256=") { + signature = strings.TrimPrefix(headerSignature, "sha256=") + } + + // compare signatures in way that isn't sensitive to a timing attack + if !hmac.Equal([]byte(expectedSignature), []byte(signature)) { + return fmt.Errorf("invalid request signature, expected: %s got: %s for body: '%s'", expectedSignature, signature, string(body)) + } + + return nil +} + +func fbCalculateSignature(appSecret string, body []byte) (string, error) { + var buffer bytes.Buffer + buffer.Write(body) + + // hash with SHA1 + mac := hmac.New(sha256.New, []byte(appSecret)) + mac.Write(buffer.Bytes()) + + return hex.EncodeToString(mac.Sum(nil)), nil +} + +func (h *handler) getTemplating(msg courier.Msg) (*MsgTemplating, error) { + if len(msg.Metadata()) == 0 { + return nil, nil + } + + metadata := &struct { + Templating *MsgTemplating `json:"templating"` + }{} + if err := json.Unmarshal(msg.Metadata(), metadata); err != nil { + return nil, err + } + + if metadata.Templating == nil { + return nil, nil + } + + if err := utils.Validate(metadata.Templating); err != nil { + return nil, errors.Wrapf(err, "invalid templating definition") + } + + return metadata.Templating, nil +} + +// BuildAttachmentRequest to download media for message attachment with Bearer token set +func (h *handler) BuildAttachmentRequest(ctx context.Context, b courier.Backend, channel courier.Channel, attachmentURL string, clog *courier.ChannelLog) (*http.Request, error) { + token := h.Server().Config().WhatsappAdminSystemUserToken + if token == "" { + return nil, fmt.Errorf("missing token for WAC channel") + } + + req, _ := http.NewRequest(http.MethodGet, attachmentURL, nil) + + // set the access token as the authorization header for WAC + if channel.ChannelType() == "WAC" { + req.Header.Set("Authorization", "Bearer "+token) + } + return req, nil +} + +var _ courier.AttachmentRequestBuilder = (*handler)(nil) + +type MsgTemplating struct { + Template struct { + Name string `json:"name" validate:"required"` + UUID string `json:"uuid" validate:"required"` + } `json:"template" validate:"required,dive"` + Namespace string `json:"namespace"` + Variables []string `json:"variables"` +} + +func getSupportedLanguage(lc courier.Locale) languageInfo { + // look for exact match + if lang := supportedLanguages[lc]; lang.code != "" { + return lang + } + + // if we have a country, strip that off and look again for a match + l, c := lc.ToParts() + if c != "" { + if lang := supportedLanguages[courier.Locale(l)]; lang.code != "" { + return lang + } + } + return supportedLanguages["eng"] // fallback to English +} + +type languageInfo struct { + code string + menu string // translation of "Menu" +} + +// Mapping from engine locales to supported languages. Note that these are not all valid BCP47 codes, e.g. fil +// see https://developers.facebook.com/docs/whatsapp/api/messages/message-templates/ +var supportedLanguages = map[courier.Locale]languageInfo{ + "afr": {code: "af", menu: "Kieslys"}, // Afrikaans + "sqi": {code: "sq", menu: "Menu"}, // Albanian + "ara": {code: "ar", menu: "قائمة"}, // Arabic + "aze": {code: "az", menu: "Menu"}, // Azerbaijani + "ben": {code: "bn", menu: "Menu"}, // Bengali + "bul": {code: "bg", menu: "Menu"}, // Bulgarian + "cat": {code: "ca", menu: "Menu"}, // Catalan + "zho": {code: "zh_CN", menu: "菜单"}, // Chinese + "zho-CN": {code: "zh_CN", menu: "菜单"}, // Chinese (CHN) + "zho-HK": {code: "zh_HK", menu: "菜单"}, // Chinese (HKG) + "zho-TW": {code: "zh_TW", menu: "菜单"}, // Chinese (TAI) + "hrv": {code: "hr", menu: "Menu"}, // Croatian + "ces": {code: "cs", menu: "Menu"}, // Czech + "dah": {code: "da", menu: "Menu"}, // Danish + "nld": {code: "nl", menu: "Menu"}, // Dutch + "eng": {code: "en", menu: "Menu"}, // English + "eng-GB": {code: "en_GB", menu: "Menu"}, // English (UK) + "eng-US": {code: "en_US", menu: "Menu"}, // English (US) + "est": {code: "et", menu: "Menu"}, // Estonian + "fil": {code: "fil", menu: "Menu"}, // Filipino + "fin": {code: "fi", menu: "Menu"}, // Finnish + "fra": {code: "fr", menu: "Menu"}, // French + "kat": {code: "ka", menu: "Menu"}, // Georgian + "deu": {code: "de", menu: "Menü"}, // German + "ell": {code: "el", menu: "Menu"}, // Greek + "guj": {code: "gu", menu: "Menu"}, // Gujarati + "hau": {code: "ha", menu: "Menu"}, // Hausa + "enb": {code: "he", menu: "תפריט"}, // Hebrew + "hin": {code: "hi", menu: "Menu"}, // Hindi + "hun": {code: "hu", menu: "Menu"}, // Hungarian + "ind": {code: "id", menu: "Menu"}, // Indonesian + "gle": {code: "ga", menu: "Roghchlár"}, // Irish + "ita": {code: "it", menu: "Menu"}, // Italian + "jpn": {code: "ja", menu: "Menu"}, // Japanese + "kan": {code: "kn", menu: "Menu"}, // Kannada + "kaz": {code: "kk", menu: "Menu"}, // Kazakh + "kin": {code: "rw_RW", menu: "Menu"}, // Kinyarwanda + "kor": {code: "ko", menu: "Menu"}, // Korean + "kir": {code: "ky_KG", menu: "Menu"}, // Kyrgyzstan + "lao": {code: "lo", menu: "Menu"}, // Lao + "lav": {code: "lv", menu: "Menu"}, // Latvian + "lit": {code: "lt", menu: "Menu"}, // Lithuanian + "mal": {code: "ml", menu: "Menu"}, // Malayalam + "mkd": {code: "mk", menu: "Menu"}, // Macedonian + "msa": {code: "ms", menu: "Menu"}, // Malay + "mar": {code: "mr", menu: "Menu"}, // Marathi + "nob": {code: "nb", menu: "Menu"}, // Norwegian + "fas": {code: "fa", menu: "Menu"}, // Persian + "pol": {code: "pl", menu: "Menu"}, // Polish + "por": {code: "pt_PT", menu: "Menu"}, // Portuguese + "por-BR": {code: "pt_BR", menu: "Menu"}, // Portuguese (BR) + "por-PT": {code: "pt_PT", menu: "Menu"}, // Portuguese (POR) + "pan": {code: "pa", menu: "Menu"}, // Punjabi + "ron": {code: "ro", menu: "Menu"}, // Romanian + "rus": {code: "ru", menu: "Menu"}, // Russian + "srp": {code: "sr", menu: "Menu"}, // Serbian + "slk": {code: "sk", menu: "Menu"}, // Slovak + "slv": {code: "sl", menu: "Menu"}, // Slovenian + "spa": {code: "es", menu: "Menú"}, // Spanish + "spa-AR": {code: "es_AR", menu: "Menú"}, // Spanish (ARG) + "spa-ES": {code: "es_ES", menu: "Menú"}, // Spanish (SPA) + "spa-MX": {code: "es_MX", menu: "Menú"}, // Spanish (MEX) + "swa": {code: "sw", menu: "Menyu"}, // Swahili + "swe": {code: "sv", menu: "Menu"}, // Swedish + "tam": {code: "ta", menu: "Menu"}, // Tamil + "tel": {code: "te", menu: "Menu"}, // Telugu + "tha": {code: "th", menu: "Menu"}, // Thai + "tur": {code: "tr", menu: "Menu"}, // Turkish + "ukr": {code: "uk", menu: "Menu"}, // Ukrainian + "urd": {code: "ur", menu: "Menu"}, // Urdu + "uzb": {code: "uz", menu: "Menu"}, // Uzbek + "vie": {code: "vi", menu: "Menu"}, // Vietnamese + "zul": {code: "zu", menu: "Menu"}, // Zulu +} diff --git a/handlers/facebookapp/facebookapp_test.go b/handlers/facebookapp/facebookapp_test.go new file mode 100644 index 000000000..08b380131 --- /dev/null +++ b/handlers/facebookapp/facebookapp_test.go @@ -0,0 +1,1648 @@ +package facebookapp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/nyaruka/courier" + . "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/test" + "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/gocommon/urns" + "github.com/stretchr/testify/assert" +) + +var testChannelsFBA = []courier.Channel{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "FBA", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}), +} + +var testChannelsIG = []courier.Channel{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "IG", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}), +} + +var testChannelsWAC = []courier.Channel{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "WAC", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}), +} + +var testCasesFBA = []IncomingTestCase{ + { + Label: "Receive Message FBA", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/helloMsgFBA.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Hello World"), + ExpectedURN: "facebook:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Invalid Signature", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/helloMsgFBA.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid request signature", + PrepRequest: addInvalidSignature, + }, + { + Label: "No Duplicate Receive Message", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/duplicateMsgFBA.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedMsgText: Sp("Hello World"), + ExpectedURN: "facebook:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Attachment", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/attachmentFBA.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedMsgText: Sp(""), + ExpectedAttachments: []string{"https://image-url/foo.png"}, + ExpectedURN: "facebook:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Location", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/locationAttachment.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedMsgText: Sp(""), + ExpectedAttachments: []string{"geo:1.200000,-1.300000"}, + ExpectedURN: "facebook:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Thumbs Up", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/thumbsUp.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedMsgText: Sp("👍"), + ExpectedURN: "facebook:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive OptIn UserRef", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/optInUserRef.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedURN: "facebook:ref:optin_user_ref", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + ExpectedEvent: courier.Referral, + ExpectedEventExtra: map[string]any{"referrer_id": "optin_ref"}, + PrepRequest: addValidSignature, + }, + { + Label: "Receive OptIn", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/optIn.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedURN: "facebook:5678", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + ExpectedEvent: courier.Referral, + ExpectedEventExtra: map[string]any{"referrer_id": "optin_ref"}, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Get Started", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/postbackGetStarted.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedURN: "facebook:5678", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + ExpectedEvent: courier.NewConversation, + ExpectedEventExtra: map[string]any{"title": "postback title", "payload": "get_started"}, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Referral Postback", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/postback.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedURN: "facebook:5678", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + ExpectedEvent: courier.Referral, + ExpectedEventExtra: map[string]any{"title": "postback title", "payload": "postback payload", "referrer_id": "postback ref", "source": "postback source", "type": "postback type"}, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Referral", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/postbackReferral.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedURN: "facebook:5678", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + ExpectedEvent: courier.Referral, + ExpectedEventExtra: map[string]any{"title": "postback title", "payload": "get_started", "referrer_id": "postback ref", "source": "postback source", "type": "postback type", "ad_id": "ad id"}, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Referral", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/referral.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"referrer_id":"referral id"`, + ExpectedURN: "facebook:5678", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + ExpectedEvent: courier.Referral, + ExpectedEventExtra: map[string]any{"referrer_id": "referral id", "source": "referral source", "type": "referral type", "ad_id": "ad id"}, + PrepRequest: addValidSignature, + }, + { + Label: "Receive DLR", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/dlr.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedMsgStatus: courier.MsgStatusDelivered, + ExpectedExternalID: "mid.1458668856218:ed81099e15d3f4f233", + PrepRequest: addValidSignature, + }, + { + Label: "Different Page", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/differentPageFBA.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"data":[]`, + PrepRequest: addValidSignature, + }, + { + Label: "Echo", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/echoFBA.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `ignoring echo`, + PrepRequest: addValidSignature, + }, + { + Label: "Not Page", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/notPage.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "object expected 'page', 'instagram' or 'whatsapp_business_account', found notpage", + NoLogsExpected: true, + PrepRequest: addValidSignature, + }, + { + Label: "No Entries", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/noEntriesFBA.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "no entries found", + NoLogsExpected: true, + PrepRequest: addValidSignature, + }, + { + Label: "No Messaging Entries", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/noMessagingEntriesFBA.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + PrepRequest: addValidSignature, + }, + { + Label: "Unknown Messaging Entry", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/unknownMessagingEntryFBA.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + PrepRequest: addValidSignature, + }, + { + Label: "Not JSON", + URL: "/c/fba/receive", + Data: "not JSON", + ExpectedRespStatus: 200, + ExpectedBodyContains: "unable to parse request JSON", + NoLogsExpected: true, + PrepRequest: addValidSignature, + }, + { + Label: "Invalid URN", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/invalidURNFBA.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid facebook id", + PrepRequest: addValidSignature, + }, +} + +var testCasesIG = []IncomingTestCase{ + { + Label: "Receive Message", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/helloMsgIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Hello World"), + ExpectedURN: "instagram:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Invalid Signature", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/helloMsgIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid request signature", + PrepRequest: addInvalidSignature, + }, + { + Label: "No Duplicate Receive Message", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/duplicateMsgIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedMsgText: Sp("Hello World"), + ExpectedURN: "instagram:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Attachment", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/attachmentIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedMsgText: Sp(""), + ExpectedAttachments: []string{"https://image-url/foo.png"}, + ExpectedURN: "instagram:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Like Heart", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/like_heart.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedMsgText: Sp(""), + ExpectedURN: "instagram:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Icebreaker Get Started", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/icebreakerGetStarted.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedURN: "instagram:5678", + ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), + ExpectedEvent: courier.NewConversation, + ExpectedEventExtra: map[string]any{"title": "icebreaker question", "payload": "get_started"}, + PrepRequest: addValidSignature, + }, + { + Label: "Different Page", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/differentPageIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"data":[]`, + PrepRequest: addValidSignature, + }, + { + Label: "Echo", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/echoIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `ignoring echo`, + PrepRequest: addValidSignature, + }, + { + Label: "No Entries", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/noEntriesIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "no entries found", + NoLogsExpected: true, + PrepRequest: addValidSignature, + }, + { + Label: "Not Instagram", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/notInstagram.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "object expected 'page', 'instagram' or 'whatsapp_business_account', found notinstagram", + NoLogsExpected: true, + PrepRequest: addValidSignature, + }, + { + Label: "No Messaging Entries", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/noMessagingEntriesIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + PrepRequest: addValidSignature, + }, + { + Label: "Unknown Messaging Entry", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/unknownMessagingEntryIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + PrepRequest: addValidSignature, + }, + { + Label: "Not JSON", + URL: "/c/ig/receive", + Data: "not JSON", + ExpectedRespStatus: 200, + ExpectedBodyContains: "unable to parse request JSON", + NoLogsExpected: true, + PrepRequest: addValidSignature, + }, + { + Label: "Invalid URN", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/invalidURNIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid instagram id", + PrepRequest: addValidSignature, + }, + { + Label: "Story Mention", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/storyMentionIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `ignoring story_mention`, + PrepRequest: addValidSignature, + }, + { + Label: "Message unsent", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/unsentMsgIG.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `msg deleted`, + PrepRequest: addValidSignature, + }, +} + +func addValidSignature(r *http.Request) { + body, _ := ReadBody(r, maxRequestBodyBytes) + sig, _ := fbCalculateSignature("fb_app_secret", body) + r.Header.Set(signatureHeader, fmt.Sprintf("sha256=%s", string(sig))) +} + +func addInvalidSignature(r *http.Request) { + r.Header.Set(signatureHeader, "invalidsig") +} + +// mocks the call to the Facebook graph API +func buildMockFBGraphFBA(testCases []IncomingTestCase) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accessToken := r.URL.Query().Get("access_token") + defer r.Body.Close() + + // invalid auth token + if accessToken != "a123" { + http.Error(w, "invalid auth token", 403) + } + + // user has a name + if strings.HasSuffix(r.URL.Path, "1337") { + w.Write([]byte(`{ "first_name": "John", "last_name": "Doe"}`)) + return + } + // no name + w.Write([]byte(`{ "first_name": "", "last_name": ""}`)) + })) + graphURL = server.URL + + return server +} + +// mocks the call to the Facebook graph API +func buildMockFBGraphIG(testCases []IncomingTestCase) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accessToken := r.URL.Query().Get("access_token") + defer r.Body.Close() + + // invalid auth token + if accessToken != "a123" { + http.Error(w, "invalid auth token", 403) + } + + // user has a name + if strings.HasSuffix(r.URL.Path, "1337") { + w.Write([]byte(`{ "name": "John Doe"}`)) + return + } + + // no name + w.Write([]byte(`{ "name": ""}`)) + })) + graphURL = server.URL + + return server +} + +func TestDescribeURNForFBA(t *testing.T) { + fbGraph := buildMockFBGraphFBA(testCasesFBA) + defer fbGraph.Close() + + channel := testChannelsFBA[0] + handler := newHandler("FBA", "Facebook", false) + handler.Initialize(newServer(nil)) + clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, channel, handler.RedactValues(channel)) + + tcs := []struct { + urn urns.URN + expectedMetadata map[string]string + }{ + {"facebook:1337", map[string]string{"name": "John Doe"}}, + {"facebook:4567", map[string]string{"name": ""}}, + {"facebook:ref:1337", map[string]string{}}, + } + + for _, tc := range tcs { + metadata, _ := handler.(courier.URNDescriber).DescribeURN(context.Background(), channel, tc.urn, clog) + assert.Equal(t, metadata, tc.expectedMetadata) + } + + AssertChannelLogRedaction(t, clog, []string{"a123", "wac_admin_system_user_token"}) +} + +func TestDescribeURNForIG(t *testing.T) { + fbGraph := buildMockFBGraphIG(testCasesIG) + defer fbGraph.Close() + + channel := testChannelsIG[0] + handler := newHandler("IG", "Instagram", false) + handler.Initialize(newServer(nil)) + clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, channel, handler.RedactValues(channel)) + + tcs := []struct { + urn urns.URN + expectedMetadata map[string]string + }{ + {"instagram:1337", map[string]string{"name": "John Doe"}}, + {"instagram:4567", map[string]string{"name": ""}}, + } + + for _, tc := range tcs { + metadata, _ := handler.(courier.URNDescriber).DescribeURN(context.Background(), channel, tc.urn, clog) + assert.Equal(t, metadata, tc.expectedMetadata) + } + + AssertChannelLogRedaction(t, clog, []string{"a123", "wac_admin_system_user_token"}) +} + +func TestDescribeURNForWAC(t *testing.T) { + channel := testChannelsWAC[0] + handler := newHandler("WAC", "Cloud API WhatsApp", false) + handler.Initialize(newServer(nil)) + clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, channel, handler.RedactValues(channel)) + + tcs := []struct { + urn urns.URN + expectedMetadata map[string]string + }{ + {"whatsapp:1337", map[string]string{}}, + {"whatsapp:4567", map[string]string{}}, + } + + for _, tc := range tcs { + metadata, _ := handler.(courier.URNDescriber).DescribeURN(context.Background(), testChannelsWAC[0], tc.urn, clog) + assert.Equal(t, metadata, tc.expectedMetadata) + } + + AssertChannelLogRedaction(t, clog, []string{"a123", "wac_admin_system_user_token"}) +} + +var wacReceiveURL = "/c/wac/receive" + +var testCasesWAC = []IncomingTestCase{ + { + Label: "Receive Message WAC", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/helloWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Hello World"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Duplicate Valid Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/duplicateWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Hello World"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Voice Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/voiceWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp(""), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Voice"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Button Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/buttonWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("No"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Document Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/documentWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("80skaraokesonglistartist"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Document"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Image Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/imageWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Check out my new phone!"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Image"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Sticker Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/stickerWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp(""), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Sticker"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Video Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/videoWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Check out my new phone!"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Video"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Audio Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/audioWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Check out my new phone!"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Audio"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Location Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/locationWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"type":"msg"`, + ExpectedMsgText: Sp(""), + ExpectedAttachments: []string{"geo:0.000000,1.000000"}, + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Invalid JSON", + URL: wacReceiveURL, + Data: "not json", + ExpectedRespStatus: 200, + ExpectedBodyContains: "unable to parse", + NoLogsExpected: true, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Invalid From", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/invalidFrom.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid whatsapp id", + PrepRequest: addValidSignature, + }, + { + Label: "Receive Invalid Timestamp", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/invalidTimestamp.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid timestamp", + PrepRequest: addValidSignature, + }, + { + Label: "Receive Message WAC invalid signature", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/helloWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid request signature", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + PrepRequest: addInvalidSignature, + }, + { + Label: "Receive Message WAC with error message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/errorMsg.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("131051", "Unsupported message type")}, + NoInvalidChannelCheck: true, + PrepRequest: addValidSignature, + }, + { + Label: "Receive error message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/errorErrors.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("0", "We were unable to authenticate the app user")}, + NoInvalidChannelCheck: true, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Status", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/validStatusWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"type":"status"`, + ExpectedMsgStatus: "S", + ExpectedExternalID: "external_id", + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Status with error message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/errorStatus.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"type":"status"`, + ExpectedMsgStatus: "F", + ExpectedExternalID: "external_id", + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("131014", "Request for url https://URL.jpg failed with error: 404 (Not Found)")}, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Invalid Status", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/invalidStatusWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"unknown status: in_orbit"`, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Ignore Status", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/ignoreStatusWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"ignoring status: deleted"`, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Interactive Button Reply Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/buttonReplyWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Yes"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Interactive List Reply Message", + URL: wacReceiveURL, + Data: string(test.ReadFile("./testdata/wac/listReplyWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp("Yes"), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, +} + +func TestIncoming(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" && accessToken != "Bearer wac_admin_system_user_token" { + fmt.Printf("Access token: %s\n", accessToken) + http.Error(w, "invalid auth token", http.StatusForbidden) + 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 + } + + if strings.HasSuffix(r.URL.Path, "sticker") { + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Sticker"}`)) + return + } + + // valid token + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL"}`)) + + })) + graphURL = server.URL + + RunIncomingTestCases(t, testChannelsWAC, newHandler("WAC", "Cloud API WhatsApp", false), testCasesWAC) + RunIncomingTestCases(t, testChannelsFBA, newHandler("FBA", "Facebook", false), testCasesFBA) + RunIncomingTestCases(t, testChannelsIG, newHandler("IG", "Instagram", false), testCasesIG) +} + +func BenchmarkHandler(b *testing.B) { + fbService := buildMockFBGraphFBA(testCasesFBA) + + RunChannelBenchmarks(b, testChannelsFBA, newHandler("FBA", "Facebook", false), testCasesFBA) + fbService.Close() + + fbServiceIG := buildMockFBGraphIG(testCasesIG) + + RunChannelBenchmarks(b, testChannelsIG, newHandler("IG", "Instagram", false), testCasesIG) + fbServiceIG.Close() +} + +func TestVerify(t *testing.T) { + RunIncomingTestCases(t, testChannelsFBA, newHandler("FBA", "Facebook", false), []IncomingTestCase{ + { + Label: "Valid Secret", + URL: "/c/fba/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", + ExpectedRespStatus: 200, + ExpectedBodyContains: "yarchallenge", + NoLogsExpected: true, + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + }, + { + Label: "Verify No Mode", + URL: "/c/fba/receive", + ExpectedRespStatus: 200, + ExpectedBodyContains: "unknown request", + NoLogsExpected: true, + }, + { + Label: "Verify No Secret", + URL: "/c/fba/receive?hub.mode=subscribe", + ExpectedRespStatus: 200, + ExpectedBodyContains: "token does not match secret", + NoLogsExpected: true, + }, + { + Label: "Invalid Secret", + URL: "/c/fba/receive?hub.mode=subscribe&hub.verify_token=blah", + ExpectedRespStatus: 200, + ExpectedBodyContains: "token does not match secret", + NoLogsExpected: true, + }, + { + Label: "Valid Secret", + URL: "/c/fba/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", + ExpectedRespStatus: 200, + ExpectedBodyContains: "yarchallenge", + NoLogsExpected: true, + }, + }) + + RunIncomingTestCases(t, testChannelsIG, newHandler("IG", "Instagram", false), []IncomingTestCase{ + { + Label: "Valid Secret", + URL: "/c/ig/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", + ExpectedRespStatus: 200, + ExpectedBodyContains: "yarchallenge", + NoLogsExpected: true, + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + }, + { + Label: "Verify No Mode", + URL: "/c/ig/receive", + ExpectedRespStatus: 200, + ExpectedBodyContains: "unknown request", + NoLogsExpected: true, + }, + { + Label: "Verify No Secret", + URL: "/c/ig/receive?hub.mode=subscribe", + ExpectedRespStatus: 200, + ExpectedBodyContains: "token does not match secret", + NoLogsExpected: true, + }, + { + Label: "Invalid Secret", + URL: "/c/ig/receive?hub.mode=subscribe&hub.verify_token=blah", + ExpectedRespStatus: 200, + ExpectedBodyContains: "token does not match secret", + NoLogsExpected: true, + }, + { + Label: "Valid Secret", + URL: "/c/ig/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", + ExpectedRespStatus: 200, + ExpectedBodyContains: "yarchallenge", + NoLogsExpected: true, + }, + }) +} + +// 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 = []OutgoingTestCase{ + { + Label: "Text only chat message", + MsgText: "Simple Message", + MsgURN: "facebook:12345", + MsgOrigin: courier.MsgOriginChat, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"HUMAN_AGENT","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Text only broadcast message", + MsgText: "Simple Message", + MsgURN: "facebook:12345", + MsgOrigin: courier.MsgOriginBroadcast, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Text only flow response", + MsgText: "Simple Message", + MsgURN: "facebook:12345", + MsgOrigin: courier.MsgOriginFlow, + MsgResponseToExternalID: "23526", + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"RESPONSE","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Text only flow response using referal URN", + MsgText: "Simple Message", + MsgURN: "facebook:ref:67890", + MsgOrigin: courier.MsgOriginFlow, + MsgResponseToExternalID: "23526", + MockResponseBody: `{"message_id": "mid.133", "recipient_id": "12345"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"RESPONSE","recipient":{"user_ref":"67890"},"message":{"text":"Simple Message"}}`, + ExpectedContactURNs: map[string]bool{"facebook:12345": true, "ext:67890": true, "facebook:ref:67890": false}, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Quick replies on a broadcast message", + MsgText: "Are you happy?", + MsgURN: "facebook:12345", + MsgOrigin: courier.MsgOriginBroadcast, + MsgQuickReplies: []string{"Yes", "No"}, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"text":"Are you happy?","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Message that exceeds max text length", + MsgText: "This is a long message which spans more than one part, what will actually be sent in the end if we exceed the max length?", + MsgURN: "facebook:12345", + MsgQuickReplies: []string{"Yes", "No"}, + MsgTopic: "account", + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"ACCOUNT_UPDATE","recipient":{"id":"12345"},"message":{"text":"we exceed the max length?","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Image attachment", + MsgURN: "facebook:12345", + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"attachment":{"type":"image","payload":{"url":"https://foo.bar/image.jpg","is_reusable":true}}}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Text, image attachment, quick replies and explicit message topic", + MsgText: "This is some text.", + MsgURN: "facebook:12345", + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgQuickReplies: []string{"Yes", "No"}, + MsgTopic: "event", + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"CONFIRMED_EVENT_UPDATE","recipient":{"id":"12345"},"message":{"text":"This is some text.","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Document attachment", + MsgURN: "facebook:12345", + MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"attachment":{"type":"file","payload":{"url":"https://foo.bar/document.pdf","is_reusable":true}}}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Response doesn't contain message id", + MsgText: "ID Error", + MsgURN: "facebook:12345", + MockResponseBody: `{ "is_error": true }`, + MockResponseStatus: 200, + ExpectedMsgStatus: "E", + ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("message_id")}, + SendPrep: setSendURL, + }, + { + Label: "Response status code is non-200", + MsgText: "Error", + MsgURN: "facebook:12345", + MockResponseBody: `{ "is_error": true }`, + MockResponseStatus: 403, + ExpectedMsgStatus: "E", + ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("message_id")}, + SendPrep: setSendURL, + }, + { + Label: "Response is invalid JSON", + MsgText: "Error", + MsgURN: "facebook:12345", + MockResponseBody: `bad json`, + MockResponseStatus: 200, + ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseUnparseable("JSON")}, + ExpectedMsgStatus: "E", + SendPrep: setSendURL, + }, + { + Label: "Response is channel specific error", + MsgText: "Error", + MsgURN: "facebook:12345", + MockResponseBody: `{ "error": {"message": "The image size is too large.","code": 36000 }}`, + MockResponseStatus: 400, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("36000", "The image size is too large.")}, + ExpectedMsgStatus: "E", + SendPrep: setSendURL, + }, +} + +var SendTestCasesIG = []OutgoingTestCase{ + { + Label: "Text only chat message", + MsgText: "Simple Message", + MsgURN: "instagram:12345", + MsgOrigin: courier.MsgOriginChat, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"HUMAN_AGENT","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Text only broadcast message", + MsgText: "Simple Message", + MsgURN: "instagram:12345", + MsgOrigin: courier.MsgOriginBroadcast, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Text only flow response", + MsgText: "Simple Message", + MsgURN: "instagram:12345", + MsgOrigin: courier.MsgOriginFlow, + MsgResponseToExternalID: "23526", + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"RESPONSE","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Quick replies on a broadcast message", + MsgText: "Are you happy?", + MsgURN: "instagram:12345", + MsgOrigin: courier.MsgOriginBroadcast, + MsgQuickReplies: []string{"Yes", "No"}, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"text":"Are you happy?","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Message that exceeds max text length", + MsgText: "This is a long message which spans more than one part, what will actually be sent in the end if we exceed the max length?", + MsgURN: "instagram:12345", + MsgQuickReplies: []string{"Yes", "No"}, + MsgTopic: "account", + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"ACCOUNT_UPDATE","recipient":{"id":"12345"},"message":{"text":"we exceed the max length?","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Image attachment", + MsgURN: "instagram:12345", + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"attachment":{"type":"image","payload":{"url":"https://foo.bar/image.jpg","is_reusable":true}}}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Text, image attachment, quick replies and explicit message topic", + MsgText: "This is some text.", + MsgURN: "instagram:12345", + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgQuickReplies: []string{"Yes", "No"}, + MsgTopic: "event", + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"CONFIRMED_EVENT_UPDATE","recipient":{"id":"12345"},"message":{"text":"This is some text.","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Explicit human agent tag", + MsgText: "Simple Message", + MsgURN: "instagram:12345", + MsgTopic: "agent", + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"HUMAN_AGENT","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Document attachment", + MsgURN: "instagram:12345", + MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"attachment":{"type":"file","payload":{"url":"https://foo.bar/document.pdf","is_reusable":true}}}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "mid.133", + SendPrep: setSendURL, + }, + { + Label: "Response doesn't contain message id", + MsgText: "ID Error", + MsgURN: "instagram:12345", + MockResponseBody: `{ "is_error": true }`, + MockResponseStatus: 200, + ExpectedMsgStatus: "E", + ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("message_id")}, + SendPrep: setSendURL, + }, + { + Label: "Response status code is non-200", + MsgText: "Error", + MsgURN: "instagram:12345", + MockResponseBody: `{ "is_error": true }`, + MockResponseStatus: 403, + ExpectedMsgStatus: "E", + ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("message_id")}, + SendPrep: setSendURL, + }, + { + Label: "Response is invalid JSON", + MsgText: "Error", + MsgURN: "instagram:12345", + MockResponseBody: `bad json`, + MockResponseStatus: 200, + ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseUnparseable("JSON")}, + ExpectedMsgStatus: "E", + SendPrep: setSendURL, + }, + { + Label: "Response is channel specific error", + MsgText: "Error", + MsgURN: "instagram:12345", + MockResponseBody: `{ "error": {"message": "The image size is too large.","code": 36000 }}`, + MockResponseStatus: 400, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("36000", "The image size is too large.")}, + ExpectedMsgStatus: "E", + SendPrep: setSendURL, + }, +} + +var SendTestCasesWAC = []OutgoingTestCase{ + { + Label: "Plain Send", + MsgText: "Simple Message", + MsgURN: "whatsapp:250788123123", + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"Simple Message","preview_url":false}}`, + ExpectedRequestPath: "/12345_ID/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Unicode Send", + MsgText: "☺", + MsgURN: "whatsapp:250788123123", + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"☺","preview_url":false}}`, + ExpectedRequestPath: "/12345_ID/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Audio Send", + MsgText: "audio caption", + MsgURN: "whatsapp:250788123123", + MsgAttachments: []string{"audio/mpeg:https://foo.bar/audio.mp3"}, + MockResponses: map[MockedRequest]*httpx.MockResponse{ + { + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + { + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"audio caption","preview_url":false}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + }, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Document Send", + MsgText: "document caption", + MsgURN: "whatsapp:250788123123", + MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"https://foo.bar/document.pdf","caption":"document caption","filename":"document.pdf"}}`, + ExpectedRequestPath: "/12345_ID/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Image Send", + MsgText: "image caption", + MsgURN: "whatsapp:250788123123", + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg","caption":"image caption"}}`, + ExpectedRequestPath: "/12345_ID/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Sticker Send", + MsgText: "sticker caption", + MsgURN: "whatsapp:250788123123", + MsgAttachments: []string{"image/webp:https://foo.bar/sticker.webp"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"sticker","sticker":{"link":"https://foo.bar/sticker.webp","caption":"sticker caption"}}`, + ExpectedRequestPath: "/12345_ID/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Video Send", + MsgText: "video caption", + MsgURN: "whatsapp:250788123123", + MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"https://foo.bar/video.mp4","caption":"video caption"}}`, + ExpectedRequestPath: "/12345_ID/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Template Send", + MsgText: "templated message", + MsgURN: "whatsapp:250788123123", + MsgLocale: "eng", + MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"]}}`), + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_product":"whatsapp","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", + MsgText: "templated message", + MsgURN: "whatsapp:250788123123", + MsgLocale: "eng-US", + MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"]}}`), + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Template Invalid Language", + MsgText: "templated message", + MsgURN: "whatsapp:250788123123", + MsgLocale: "bnt", + MsgMetadata: json.RawMessage(`{"templating": { "template": { "name": "revive_issue", "uuid": "8ca114b4-bee2-4d3b-aaf1-9aa6b48d41e8" }, "variables": ["Chef", "tomorrow"]}}`), + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive Button Message Send", + MsgText: "Interactive Button Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"BUTTON1"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive List Message Send", + MsgText: "Interactive List Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive List Message Send In Spanish", + MsgText: "Hola", + MsgURN: "whatsapp:250788123123", + MsgLocale: "spa", + MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"list","body":{"text":"Hola"},"action":{"button":"Menú","sections":[{"rows":[{"id":"0","title":"ROW1"},{"id":"1","title":"ROW2"},{"id":"2","title":"ROW3"},{"id":"3","title":"ROW4"}]}]}}}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive Button Message Send with image attachment", + MsgText: "Interactive Button Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"BUTTON1"}, + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"https://foo.bar/image.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestPath: "/12345_ID/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive Button Message Send with video attachment", + MsgText: "Interactive Button Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"BUTTON1"}, + MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"https://foo.bar/video.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestPath: "/12345_ID/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive Button Message Send with document attachment", + MsgText: "Interactive Button Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"BUTTON1"}, + MsgAttachments: []string{"document/pdf:https://foo.bar/document.pdf"}, + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"https://foo.bar/document.pdf","filename":"document.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestPath: "/12345_ID/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive Button Message Send with audio attachment", + MsgText: "Interactive Button Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3"}, + MsgAttachments: []string{"audio/mp3:https://foo.bar/audio.mp3"}, + MockResponses: map[MockedRequest]*httpx.MockResponse{ + { + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + { + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"ROW1"}},{"type":"reply","reply":{"id":"1","title":"ROW2"}},{"type":"reply","reply":{"id":"2","title":"ROW3"}}]}}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + }, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Interactive List Message Send with attachment", + MsgText: "Interactive List Msg", + MsgURN: "whatsapp:250788123123", + MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponses: map[MockedRequest]*httpx.MockResponse{ + { + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg"}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + { + Method: "POST", + Path: "/12345_ID/messages", + Body: `{"messaging_product":"whatsapp","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"}]}]}}}`, + }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), + }, + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Link Sending", + MsgText: "Link Sending https://link.com", + MsgURN: "whatsapp:250788123123", + MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, + MockResponseStatus: 201, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"Link Sending https://link.com","preview_url":true}}`, + ExpectedRequestPath: "/12345_ID/messages", + ExpectedMsgStatus: "W", + ExpectedExternalID: "157b5e14568e8", + SendPrep: setSendURL, + }, + { + Label: "Error Bad JSON", + MsgText: "Error", + MsgURN: "whatsapp:250788123123", + MockResponseBody: `bad json`, + MockResponseStatus: 403, + ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseUnparseable("JSON")}, + ExpectedMsgStatus: "E", + SendPrep: setSendURL, + }, + { + Label: "Error", + MsgText: "Error", + MsgURN: "whatsapp:250788123123", + MockResponseBody: `{ "error": {"message": "(#130429) Rate limit hit","code": 130429 }}`, + MockResponseStatus: 403, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("130429", "(#130429) Rate limit hit")}, + ExpectedMsgStatus: "E", + SendPrep: setSendURL, + }, +} + +func TestOutgoing(t *testing.T) { + // shorter max msg length for testing + maxMsgLength = 100 + + var ChannelFBA = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FBA", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}) + var ChannelIG = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "IG", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}) + var ChannelWAC = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "WAC", "12345_ID", "", map[string]any{courier.ConfigAuthToken: "a123"}) + + checkRedacted := []string{"wac_admin_system_user_token", "missing_facebook_app_secret", "missing_facebook_webhook_secret", "a123"} + + RunOutgoingTestCases(t, ChannelFBA, newHandler("FBA", "Facebook", false), SendTestCasesFBA, checkRedacted, nil) + RunOutgoingTestCases(t, ChannelIG, newHandler("IG", "Instagram", false), SendTestCasesIG, checkRedacted, nil) + RunOutgoingTestCases(t, ChannelWAC, newHandler("WAC", "Cloud API WhatsApp", false), SendTestCasesWAC, checkRedacted, nil) +} + +func TestSigning(t *testing.T) { + tcs := []struct { + Body string + Signature string + }{ + { + "hello world", + "f39034b29165ec6a5104d9aef27266484ab26c8caa7bca8bcb2dd02e8be61b17", + }, + { + "hello world2", + "60905fdf409d0b4f721e99f6f25b31567a68a6b45e933d814e17a246be4c5a53", + }, + } + + for i, tc := range tcs { + sig, err := fbCalculateSignature("sesame", []byte(tc.Body)) + assert.NoError(t, err) + assert.Equal(t, tc.Signature, sig, "%d: mismatched signature", i) + } +} + +func newServer(backend courier.Backend) courier.Server { + config := courier.NewConfig() + config.WhatsappAdminSystemUserToken = "wac_admin_system_user_token" + return courier.NewServer(config, backend) +} + +func TestBuildAttachmentRequest(t *testing.T) { + mb := test.NewMockBackend() + s := newServer(mb) + wacHandler := &handler{NewBaseHandlerWithParams(courier.ChannelType("WAC"), "WhatsApp Cloud", false, nil)} + wacHandler.Initialize(s) + req, _ := wacHandler.BuildAttachmentRequest(context.Background(), mb, testChannelsWAC[0], "https://example.org/v1/media/41", nil) + assert.Equal(t, "https://example.org/v1/media/41", req.URL.String()) + assert.Equal(t, "Bearer wac_admin_system_user_token", req.Header.Get("Authorization")) + + fbaHandler := &handler{NewBaseHandlerWithParams(courier.ChannelType("FBA"), "Facebook", false, nil)} + fbaHandler.Initialize(s) + req, _ = fbaHandler.BuildAttachmentRequest(context.Background(), mb, testChannelsFBA[0], "https://example.org/v1/media/41", nil) + assert.Equal(t, "https://example.org/v1/media/41", req.URL.String()) + assert.Equal(t, http.Header{}, req.Header) + + igHandler := &handler{NewBaseHandlerWithParams(courier.ChannelType("IG"), "Instagram", false, nil)} + igHandler.Initialize(s) + req, _ = igHandler.BuildAttachmentRequest(context.Background(), mb, testChannelsFBA[0], "https://example.org/v1/media/41", nil) + assert.Equal(t, "https://example.org/v1/media/41", req.URL.String()) + assert.Equal(t, http.Header{}, req.Header) +} + +func TestGetSupportedLanguage(t *testing.T) { + assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.NilLocale)) + assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.Locale("eng"))) + assert.Equal(t, languageInfo{"en_US", "Menu"}, getSupportedLanguage(courier.Locale("eng-US"))) + assert.Equal(t, languageInfo{"pt_PT", "Menu"}, getSupportedLanguage(courier.Locale("por"))) + assert.Equal(t, languageInfo{"pt_PT", "Menu"}, getSupportedLanguage(courier.Locale("por-PT"))) + assert.Equal(t, languageInfo{"pt_BR", "Menu"}, getSupportedLanguage(courier.Locale("por-BR"))) + assert.Equal(t, languageInfo{"fil", "Menu"}, getSupportedLanguage(courier.Locale("fil"))) + assert.Equal(t, languageInfo{"fr", "Menu"}, getSupportedLanguage(courier.Locale("fra-CA"))) + assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.Locale("run"))) +} diff --git a/handlers/facebookapp/testdata/wac/stickerWAC.json b/handlers/facebookapp/testdata/wac/stickerWAC.json new file mode 100644 index 000000000..481319f1e --- /dev/null +++ b/handlers/facebookapp/testdata/wac/stickerWAC.json @@ -0,0 +1,42 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "+250 788 123 200", + "phone_number_id": "12345" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "from": "5678", + "id": "external_id", + "sticker": { + "file": "/usr/local/wamedia/shared/b1cf38-8734-4ad3-b4a1-ef0c10d0d683", + "id": "id_sticker", + "mime_type": "image/webp", + "sha256": "29ed500fa64eb55fc19dc4124acb300e5dcc54a0f822a301ae99944db" + }, + "timestamp": "1454119029", + "type": "sticker" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/meta/testdata/wac/stickerWAC.json b/handlers/meta/testdata/wac/stickerWAC.json new file mode 100644 index 000000000..481319f1e --- /dev/null +++ b/handlers/meta/testdata/wac/stickerWAC.json @@ -0,0 +1,42 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "8856996819413533", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "+250 788 123 200", + "phone_number_id": "12345" + }, + "contacts": [ + { + "profile": { + "name": "Kerry Fisher" + }, + "wa_id": "5678" + } + ], + "messages": [ + { + "from": "5678", + "id": "external_id", + "sticker": { + "file": "/usr/local/wamedia/shared/b1cf38-8734-4ad3-b4a1-ef0c10d0d683", + "id": "id_sticker", + "mime_type": "image/webp", + "sha256": "29ed500fa64eb55fc19dc4124acb300e5dcc54a0f822a301ae99944db" + }, + "timestamp": "1454119029", + "type": "sticker" + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file From 56c5fb1e771b4f2bc6bc94d92617e46eeba77d19 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Wed, 6 Sep 2023 17:17:07 +0200 Subject: [PATCH 3/8] Fix conflicts --- handlers/dialog360/dialog360.go | 57 +++++++++++++-------- handlers/dialog360/dialog360_test.go | 72 +++++++++++++++++++-------- handlers/meta/testdata/wac/audio.json | 2 +- 3 files changed, 86 insertions(+), 45 deletions(-) diff --git a/handlers/dialog360/dialog360.go b/handlers/dialog360/dialog360.go index 4414ec15e..e7bdbadde 100644 --- a/handlers/dialog360/dialog360.go +++ b/handlers/dialog360/dialog360.go @@ -28,6 +28,15 @@ var ( maxMsgLength = 1000 ) +// see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types +var mediaSupport = map[handlers.MediaType]handlers.MediaTypeSupport{ + handlers.MediaType("image/webp"): {Types: []string{"image/webp"}, MaxBytes: 100 * 1024, MaxWidth: 512, MaxHeight: 512}, + handlers.MediaTypeImage: {Types: []string{"image/jpeg", "image/png"}, MaxBytes: 5 * 1024 * 1024}, + handlers.MediaTypeAudio: {Types: []string{"audio/aac", "audio/mp4", "audio/mpeg", "audio/amr", "audio/ogg"}, MaxBytes: 16 * 1024 * 1024}, + handlers.MediaTypeVideo: {Types: []string{"video/mp4", "video/3gp"}, MaxBytes: 16 * 1024 * 1024}, + handlers.MediaTypeApplication: {MaxBytes: 100 * 1024 * 1024}, +} + func init() { courier.RegisterHandler(newWAHandler(courier.ChannelType("D3C"), "360Dialog")) } @@ -549,10 +558,15 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann var payloadAudio wacMTPayload - for i := 0; i < len(msgParts)+len(msg.Attachments()); i++ { + attachments, err := handlers.ResolveAttachments(ctx, h.Backend(), msg.Attachments(), mediaSupport, false) + if err != nil { + return nil, errors.Wrap(err, "error resolving attachments") + } + + for i := 0; i < len(msgParts)+len(attachments); i++ { payload := wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path()} - if len(msg.Attachments()) == 0 { + if len(attachments) == 0 { // do we have a template? templating, err := h.getTemplating(msg) if err != nil { @@ -573,14 +587,14 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann template.Components = append(payload.Template.Components, component) } else { - if i < (len(msgParts) + len(msg.Attachments()) - 1) { + if i < (len(msgParts) + len(attachments) - 1) { // this is still a msg part text := &wacText{PreviewURL: false} payload.Type = "text" - if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { text.PreviewURL = true } - text.Body = msgParts[i-len(msg.Attachments())] + text.Body = msgParts[i-len(attachments)] payload.Text = text } else { if len(qrs) > 0 { @@ -589,7 +603,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann if len(qrs) <= 3 { interactive := wacInteractive{Type: "button", Body: struct { Text string "json:\"text\"" - }{Text: msgParts[i-len(msg.Attachments())]}} + }{Text: msgParts[i-len(attachments)]}} btns := make([]wacMTButton, len(qrs)) for i, qr := range qrs { @@ -608,7 +622,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } else if len(qrs) <= 10 { interactive := wacInteractive{Type: "list", Body: struct { Text string "json:\"text\"" - }{Text: msgParts[i-len(msg.Attachments())]}} + }{Text: msgParts[i-len(attachments)]}} section := wacMTSection{ Rows: make([]wacMTSectionRow, len(qrs)), @@ -636,33 +650,32 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // this is still a msg part text := &wacText{PreviewURL: false} payload.Type = "text" - if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { text.PreviewURL = true } - text.Body = msgParts[i-len(msg.Attachments())] + text.Body = msgParts[i-len(attachments)] payload.Text = text } } } - } else if i < len(msg.Attachments()) && (len(qrs) == 0 || len(qrs) > 3) { - attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) - splitedAttType := strings.Split(attType, "/") - attType = splitedAttType[0] - attFormat := splitedAttType[1] + } else if i < len(attachments) && (len(qrs) == 0 || len(qrs) > 3) { + attURL := attachments[i].Media.URL() + attType := attachments[i].Type + attContentType := attachments[i].Media.ContentType() if attType == "application" { attType = "document" } - payload.Type = attType + payload.Type = string(attType) media := wacMTMedia{Link: attURL} - if len(msgParts) == 1 && attType != "audio" && len(msg.Attachments()) == 1 && len(msg.QuickReplies()) == 0 { + if len(msgParts) == 1 && attType != "audio" && len(attachments) == 1 && len(msg.QuickReplies()) == 0 { media.Caption = msgParts[i] hasCaption = true } if attType == "image" { - if attFormat == "webp" { + if attContentType == "image/webp" { payload.Type = "sticker" payload.Sticker = &media } else { @@ -692,10 +705,10 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann Text string "json:\"text\"" }{Text: msgParts[i]}} - if len(msg.Attachments()) > 0 { + if len(attachments) > 0 { hasCaption = true - attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) - attType = strings.Split(attType, "/")[0] + attURL := attachments[i].Media.URL() + attType := attachments[i].Type if attType == "application" { attType = "document" } @@ -799,10 +812,10 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // this is still a msg part text := &wacText{PreviewURL: false} payload.Type = "text" - if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { text.PreviewURL = true } - text.Body = msgParts[i-len(msg.Attachments())] + text.Body = msgParts[i-len(attachments)] payload.Text = text } } diff --git a/handlers/dialog360/dialog360_test.go b/handlers/dialog360/dialog360_test.go index f740b1331..d4a9fb5bd 100644 --- a/handlers/dialog360/dialog360_test.go +++ b/handlers/dialog360/dialog360_test.go @@ -289,6 +289,10 @@ func buildMockD3MediaService(testChannels []courier.Channel, testCases []Incomin fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_audio" } + if strings.HasSuffix(r.URL.Path, "id_sticker") { + fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_sticker" + } + w.WriteHeader(http.StatusOK) w.Write([]byte(fmt.Sprintf(`{ "url": "%s" }`, fileURL))) })) @@ -364,12 +368,12 @@ var SendTestCasesD3C = []OutgoingTestCase{ Label: "Audio Send", MsgText: "audio caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"audio/mpeg:https://foo.bar/audio.mp3"}, + MsgAttachments: []string{"audio/mpeg:http://mock.com/3456/test.mp3"}, MockResponses: map[MockedRequest]*httpx.MockResponse{ { Method: "POST", Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/3456/test.mp3"}}`, }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), { Method: "POST", @@ -385,10 +389,10 @@ var SendTestCasesD3C = []OutgoingTestCase{ Label: "Document Send", MsgText: "document caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, + MsgAttachments: []string{"application/pdf:http://mock.com/7890/test.pdf"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"https://foo.bar/document.pdf","caption":"document caption","filename":"document.pdf"}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"http://mock.com/7890/test.pdf","caption":"document caption","filename":"test.pdf"}}`, ExpectedRequestPath: "/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -398,10 +402,10 @@ var SendTestCasesD3C = []OutgoingTestCase{ Label: "Image Send", MsgText: "image caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg","caption":"image caption"}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/1234/test.jpg","caption":"image caption"}}`, ExpectedRequestPath: "/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -411,10 +415,10 @@ var SendTestCasesD3C = []OutgoingTestCase{ Label: "Sticker Send", MsgText: "sticker caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"image/webp:https://foo.bar/sticker.webp"}, + MsgAttachments: []string{"image/webp:http://mock.com/8901/test.webp"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"sticker","sticker":{"link":"https://foo.bar/sticker.webp","caption":"sticker caption"}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"sticker","sticker":{"link":"http://mock.com/8901/test.webp","caption":"sticker caption"}}`, ExpectedRequestPath: "/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -424,10 +428,10 @@ var SendTestCasesD3C = []OutgoingTestCase{ Label: "Video Send", MsgText: "video caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MsgAttachments: []string{"video/mp4:http://mock.com/5678/test.mp4"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"https://foo.bar/video.mp4","caption":"video caption"}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"http://mock.com/5678/test.mp4","caption":"video caption"}}`, ExpectedRequestPath: "/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -514,10 +518,10 @@ var SendTestCasesD3C = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"https://foo.bar/image.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"http://mock.com/1234/test.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, ExpectedRequestPath: "/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -528,10 +532,10 @@ var SendTestCasesD3C = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MsgAttachments: []string{"video/mp4:http://mock.com/5678/test.mp4"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"https://foo.bar/video.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"http://mock.com/5678/test.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, ExpectedRequestPath: "/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -542,10 +546,10 @@ var SendTestCasesD3C = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"document/pdf:https://foo.bar/document.pdf"}, + MsgAttachments: []string{"document/pdf:http://mock.com/7890/test.pdf"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"https://foo.bar/document.pdf","filename":"document.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"http://mock.com/7890/test.pdf","filename":"test.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, ExpectedRequestPath: "/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -556,12 +560,12 @@ var SendTestCasesD3C = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3"}, - MsgAttachments: []string{"audio/mp3:https://foo.bar/audio.mp3"}, + MsgAttachments: []string{"audio/mp3:http://mock.com/3456/test.mp3"}, MockResponses: map[MockedRequest]*httpx.MockResponse{ { Method: "POST", Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/3456/test.mp3"}}`, }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), { Method: "POST", @@ -578,12 +582,12 @@ var SendTestCasesD3C = []OutgoingTestCase{ MsgText: "Interactive List Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, MockResponses: map[MockedRequest]*httpx.MockResponse{ { Method: "POST", Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/1234/test.jpg"}}`, }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), { Method: "POST", @@ -629,7 +633,31 @@ var SendTestCasesD3C = []OutgoingTestCase{ }, } -func TestOutgoing(t *testing.T) { +// setupMedia takes care of having the media files needed to our test server host +func setupMedia(mb *test.MockBackend) { + imageJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/1234/test.jpg", 1024*1024, 640, 480, 0, nil) + + audioM4A := test.NewMockMedia("test.m4a", "audio/mp4", "http://mock.com/2345/test.m4a", 1024*1024, 0, 0, 200, nil) + audioMP3 := test.NewMockMedia("test.mp3", "audio/mpeg", "http://mock.com/3456/test.mp3", 1024*1024, 0, 0, 200, []courier.Media{audioM4A}) + + thumbJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/4567/test.jpg", 1024*1024, 640, 480, 0, nil) + videoMP4 := test.NewMockMedia("test.mp4", "video/mp4", "http://mock.com/5678/test.mp4", 1024*1024, 0, 0, 1000, []courier.Media{thumbJPG}) + + videoMOV := test.NewMockMedia("test.mov", "video/quicktime", "http://mock.com/6789/test.mov", 100*1024*1024, 0, 0, 2000, nil) + + filePDF := test.NewMockMedia("test.pdf", "application/pdf", "http://mock.com/7890/test.pdf", 100*1024*1024, 0, 0, 0, nil) + + stickerWEBP := test.NewMockMedia("test.webp", "image/webp", "http://mock.com/8901/test.webp", 50*1024, 480, 480, 0, nil) + + mb.MockMedia(imageJPG) + mb.MockMedia(audioMP3) + mb.MockMedia(videoMP4) + mb.MockMedia(videoMOV) + mb.MockMedia(filePDF) + mb.MockMedia(stickerWEBP) +} + +func TestSending(t *testing.T) { // shorter max msg length for testing maxMsgLength = 100 @@ -639,7 +667,7 @@ func TestOutgoing(t *testing.T) { }) checkRedacted := []string{"the-auth-token"} - RunOutgoingTestCases(t, ChannelWAC, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), SendTestCasesD3C, checkRedacted, nil) + RunChannelSendTestCases(t, ChannelWAC, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), SendTestCasesD3C, checkRedacted, setupMedia) } func TestGetSupportedLanguage(t *testing.T) { assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.NilLocale)) diff --git a/handlers/meta/testdata/wac/audio.json b/handlers/meta/testdata/wac/audio.json index f578e5fc9..40e5233b0 100644 --- a/handlers/meta/testdata/wac/audio.json +++ b/handlers/meta/testdata/wac/audio.json @@ -26,7 +26,7 @@ "audio": { "file": "/usr/local/wamedia/shared/b1cf38-8734-4ad3-b4a1-ef0c10d0d683", "id": "id_audio", - "mime_type": "image/jpeg", + "mime_type": "audio/mpeg", "sha256": "29ed500fa64eb55fc19dc4124acb300e5dcc54a0f822a301ae99944db", "caption": "Check out my new phone!" }, From a630c68830365a90a29a2ced3c5818e8d77de7ea Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Wed, 6 Sep 2023 17:18:29 +0200 Subject: [PATCH 4/8] Fix conflicts --- handlers/facebookapp/facebookapp.go | 55 +++++++++++-------- handlers/facebookapp/facebookapp_test.go | 67 ++++++++++++++++-------- 2 files changed, 80 insertions(+), 42 deletions(-) diff --git a/handlers/facebookapp/facebookapp.go b/handlers/facebookapp/facebookapp.go index d50fbd358..1db3b06b9 100644 --- a/handlers/facebookapp/facebookapp.go +++ b/handlers/facebookapp/facebookapp.go @@ -70,6 +70,15 @@ var waIgnoreStatuses = map[string]bool{ "deleted": true, } +// see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types +var wacMediaSupport = map[handlers.MediaType]handlers.MediaTypeSupport{ + handlers.MediaType("image/webp"): {Types: []string{"image/webp"}, MaxBytes: 100 * 1024, MaxWidth: 512, MaxHeight: 512}, + handlers.MediaTypeImage: {Types: []string{"image/jpeg", "image/png"}, MaxBytes: 5 * 1024 * 1024}, + handlers.MediaTypeAudio: {Types: []string{"audio/aac", "audio/mp4", "audio/mpeg", "audio/amr", "audio/ogg"}, MaxBytes: 16 * 1024 * 1024}, + handlers.MediaTypeVideo: {Types: []string{"video/mp4", "video/3gp"}, MaxBytes: 16 * 1024 * 1024}, + handlers.MediaTypeApplication: {MaxBytes: 100 * 1024 * 1024}, +} + func newHandler(channelType courier.ChannelType, name string, useUUIDRoutes bool) courier.ChannelHandler { return &handler{handlers.NewBaseHandlerWithParams(channelType, name, useUUIDRoutes, []string{courier.ConfigAuthToken})} } @@ -1129,12 +1138,17 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, qrs := msg.QuickReplies() lang := getSupportedLanguage(msg.Locale()) + attachments, err := handlers.ResolveAttachments(ctx, h.Backend(), msg.Attachments(), wacMediaSupport, false) + if err != nil { + return nil, errors.Wrap(err, "error resolving attachments") + } + var payloadAudio wacMTPayload - for i := 0; i < len(msgParts)+len(msg.Attachments()); i++ { + for i := 0; i < len(msgParts)+len(attachments); i++ { payload := wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path()} - if len(msg.Attachments()) == 0 { + if len(attachments) == 0 { // do we have a template? templating, err := h.getTemplating(msg) if err != nil { @@ -1155,14 +1169,14 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, template.Components = append(payload.Template.Components, component) } else { - if i < (len(msgParts) + len(msg.Attachments()) - 1) { + if i < (len(msgParts) + len(attachments) - 1) { // this is still a msg part text := &wacText{PreviewURL: false} payload.Type = "text" - if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { text.PreviewURL = true } - text.Body = msgParts[i-len(msg.Attachments())] + text.Body = msgParts[i-len(attachments)] payload.Text = text } else { if len(qrs) > 0 { @@ -1171,7 +1185,7 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, if len(qrs) <= 3 { interactive := wacInteractive{Type: "button", Body: struct { Text string "json:\"text\"" - }{Text: msgParts[i-len(msg.Attachments())]}} + }{Text: msgParts[i-len(attachments)]}} btns := make([]wacMTButton, len(qrs)) for i, qr := range qrs { @@ -1190,7 +1204,7 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, } else if len(qrs) <= 10 { interactive := wacInteractive{Type: "list", Body: struct { Text string "json:\"text\"" - }{Text: msgParts[i-len(msg.Attachments())]}} + }{Text: msgParts[i-len(attachments)]}} section := wacMTSection{ Rows: make([]wacMTSectionRow, len(qrs)), @@ -1218,20 +1232,19 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, // this is still a msg part text := &wacText{PreviewURL: false} payload.Type = "text" - if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { text.PreviewURL = true } - text.Body = msgParts[i-len(msg.Attachments())] + text.Body = msgParts[i-len(attachments)] payload.Text = text } } } - } else if i < len(msg.Attachments()) && (len(qrs) == 0 || len(qrs) > 3) { - attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) - splitedAttType := strings.Split(attType, "/") - attType = splitedAttType[0] - attFormat := splitedAttType[1] + } else if i < len(attachments) && (len(qrs) == 0 || len(qrs) > 3) { + attURL := attachments[i].Media.URL() + attType := string(attachments[i].Type) + attContentType := attachments[i].Media.ContentType() if attType == "application" { attType = "document" @@ -1239,13 +1252,13 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, payload.Type = attType media := wacMTMedia{Link: attURL} - if len(msgParts) == 1 && attType != "audio" && len(msg.Attachments()) == 1 && len(msg.QuickReplies()) == 0 { + if len(msgParts) == 1 && attType != "audio" && len(attachments) == 1 && len(msg.QuickReplies()) == 0 { media.Caption = msgParts[i] hasCaption = true } if attType == "image" { - if attFormat == "webp" { + if attContentType == "image/webp" { payload.Type = "sticker" payload.Sticker = &media } else { @@ -1276,8 +1289,8 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, if len(msg.Attachments()) > 0 { hasCaption = true - attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) - attType = strings.Split(attType, "/")[0] + attURL := attachments[i].Media.URL() + attType := string(attachments[i].Type) if attType == "application" { attType = "document" } @@ -1353,7 +1366,7 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, } else if len(qrs) <= 10 { interactive := wacInteractive{Type: "list", Body: struct { Text string "json:\"text\"" - }{Text: msgParts[i-len(msg.Attachments())]}} + }{Text: msgParts[i-len(attachments)]}} section := wacMTSection{ Rows: make([]wacMTSectionRow, len(qrs)), @@ -1381,10 +1394,10 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, // this is still a msg part text := &wacText{PreviewURL: false} payload.Type = "text" - if strings.Contains(msgParts[i-len(msg.Attachments())], "https://") || strings.Contains(msgParts[i-len(msg.Attachments())], "http://") { + if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { text.PreviewURL = true } - text.Body = msgParts[i-len(msg.Attachments())] + text.Body = msgParts[i-len(attachments)] payload.Text = text } } diff --git a/handlers/facebookapp/facebookapp_test.go b/handlers/facebookapp/facebookapp_test.go index 08b380131..5f63b6cec 100644 --- a/handlers/facebookapp/facebookapp_test.go +++ b/handlers/facebookapp/facebookapp_test.go @@ -1305,12 +1305,12 @@ var SendTestCasesWAC = []OutgoingTestCase{ Label: "Audio Send", MsgText: "audio caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"audio/mpeg:https://foo.bar/audio.mp3"}, + MsgAttachments: []string{"audio/mpeg:http://mock.com/3456/test.mp3"}, MockResponses: map[MockedRequest]*httpx.MockResponse{ { Method: "POST", Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/3456/test.mp3"}}`, }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), { Method: "POST", @@ -1326,10 +1326,10 @@ var SendTestCasesWAC = []OutgoingTestCase{ Label: "Document Send", MsgText: "document caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, + MsgAttachments: []string{"application/pdf:http://mock.com/7890/test.pdf"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"https://foo.bar/document.pdf","caption":"document caption","filename":"document.pdf"}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"http://mock.com/7890/test.pdf","caption":"document caption","filename":"test.pdf"}}`, ExpectedRequestPath: "/12345_ID/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -1339,10 +1339,10 @@ var SendTestCasesWAC = []OutgoingTestCase{ Label: "Image Send", MsgText: "image caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg","caption":"image caption"}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/1234/test.jpg","caption":"image caption"}}`, ExpectedRequestPath: "/12345_ID/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -1352,10 +1352,10 @@ var SendTestCasesWAC = []OutgoingTestCase{ Label: "Sticker Send", MsgText: "sticker caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"image/webp:https://foo.bar/sticker.webp"}, + MsgAttachments: []string{"image/webp:http://mock.com/8901/test.webp"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"sticker","sticker":{"link":"https://foo.bar/sticker.webp","caption":"sticker caption"}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"sticker","sticker":{"link":"http://mock.com/8901/test.webp","caption":"sticker caption"}}`, ExpectedRequestPath: "/12345_ID/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -1365,10 +1365,10 @@ var SendTestCasesWAC = []OutgoingTestCase{ Label: "Video Send", MsgText: "video caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MsgAttachments: []string{"video/mp4:http://mock.com/5678/test.mp4"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"https://foo.bar/video.mp4","caption":"video caption"}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"http://mock.com/5678/test.mp4","caption":"video caption"}}`, ExpectedRequestPath: "/12345_ID/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -1455,10 +1455,10 @@ var SendTestCasesWAC = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"https://foo.bar/image.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"http://mock.com/1234/test.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, ExpectedRequestPath: "/12345_ID/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -1469,10 +1469,10 @@ var SendTestCasesWAC = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MsgAttachments: []string{"video/mp4:http://mock.com/5678/test.mp4"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"https://foo.bar/video.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"http://mock.com/5678/test.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, ExpectedRequestPath: "/12345_ID/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -1483,10 +1483,10 @@ var SendTestCasesWAC = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"document/pdf:https://foo.bar/document.pdf"}, + MsgAttachments: []string{"document/pdf:http://mock.com/7890/test.pdf"}, MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"https://foo.bar/document.pdf","filename":"document.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"http://mock.com/7890/test.pdf","filename":"test.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, ExpectedRequestPath: "/12345_ID/messages", ExpectedMsgStatus: "W", ExpectedExternalID: "157b5e14568e8", @@ -1497,12 +1497,12 @@ var SendTestCasesWAC = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3"}, - MsgAttachments: []string{"audio/mp3:https://foo.bar/audio.mp3"}, + MsgAttachments: []string{"audio/mp3:http://mock.com/3456/test.mp3"}, MockResponses: map[MockedRequest]*httpx.MockResponse{ { Method: "POST", Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/3456/test.mp3"}}`, }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), { Method: "POST", @@ -1519,12 +1519,12 @@ var SendTestCasesWAC = []OutgoingTestCase{ MsgText: "Interactive List Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, MockResponses: map[MockedRequest]*httpx.MockResponse{ { Method: "POST", Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/1234/test.jpg"}}`, }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), { Method: "POST", @@ -1570,7 +1570,32 @@ var SendTestCasesWAC = []OutgoingTestCase{ }, } +// setupMedia takes care of having the media files needed to our test server host +func setupMedia(mb *test.MockBackend) { + imageJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/1234/test.jpg", 1024*1024, 640, 480, 0, nil) + + audioM4A := test.NewMockMedia("test.m4a", "audio/mp4", "http://mock.com/2345/test.m4a", 1024*1024, 0, 0, 200, nil) + audioMP3 := test.NewMockMedia("test.mp3", "audio/mpeg", "http://mock.com/3456/test.mp3", 1024*1024, 0, 0, 200, []courier.Media{audioM4A}) + + thumbJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/4567/test.jpg", 1024*1024, 640, 480, 0, nil) + videoMP4 := test.NewMockMedia("test.mp4", "video/mp4", "http://mock.com/5678/test.mp4", 1024*1024, 0, 0, 1000, []courier.Media{thumbJPG}) + + videoMOV := test.NewMockMedia("test.mov", "video/quicktime", "http://mock.com/6789/test.mov", 100*1024*1024, 0, 0, 2000, nil) + + filePDF := test.NewMockMedia("test.pdf", "application/pdf", "http://mock.com/7890/test.pdf", 100*1024*1024, 0, 0, 0, nil) + + stickerWEBP := test.NewMockMedia("test.webp", "image/webp", "http://mock.com/8901/test.webp", 50*1024, 480, 480, 0, nil) + + mb.MockMedia(imageJPG) + mb.MockMedia(audioMP3) + mb.MockMedia(videoMP4) + mb.MockMedia(videoMOV) + mb.MockMedia(filePDF) + mb.MockMedia(stickerWEBP) +} + func TestOutgoing(t *testing.T) { + // shorter max msg length for testing maxMsgLength = 100 @@ -1582,7 +1607,7 @@ func TestOutgoing(t *testing.T) { RunOutgoingTestCases(t, ChannelFBA, newHandler("FBA", "Facebook", false), SendTestCasesFBA, checkRedacted, nil) RunOutgoingTestCases(t, ChannelIG, newHandler("IG", "Instagram", false), SendTestCasesIG, checkRedacted, nil) - RunOutgoingTestCases(t, ChannelWAC, newHandler("WAC", "Cloud API WhatsApp", false), SendTestCasesWAC, checkRedacted, nil) + RunOutgoingTestCases(t, ChannelWAC, newHandler("WAC", "Cloud API WhatsApp", false), SendTestCasesWAC, checkRedacted, setupMedia) } func TestSigning(t *testing.T) { From 9786fb37c91cc9086d17884e32971600720a1fa5 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Wed, 6 Sep 2023 17:19:12 +0200 Subject: [PATCH 5/8] Fix tests --- handlers/dialog360/dialog360_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/handlers/dialog360/dialog360_test.go b/handlers/dialog360/dialog360_test.go index d4a9fb5bd..535727f76 100644 --- a/handlers/dialog360/dialog360_test.go +++ b/handlers/dialog360/dialog360_test.go @@ -657,7 +657,7 @@ func setupMedia(mb *test.MockBackend) { mb.MockMedia(stickerWEBP) } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { // shorter max msg length for testing maxMsgLength = 100 @@ -667,7 +667,7 @@ func TestSending(t *testing.T) { }) checkRedacted := []string{"the-auth-token"} - RunChannelSendTestCases(t, ChannelWAC, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), SendTestCasesD3C, checkRedacted, setupMedia) + RunOutgoingTestCases(t, ChannelWAC, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), SendTestCasesD3C, checkRedacted, setupMedia) } func TestGetSupportedLanguage(t *testing.T) { assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.NilLocale)) From ad6c3b736b08f4b43180cc80b5d3e4f60ed8aaec Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 23 Apr 2024 13:20:50 +0200 Subject: [PATCH 6/8] Merge --- handlers/dialog360/dialog360.go | 1006 ---------- handlers/dialog360/dialog360_test.go | 682 ------- handlers/facebookapp/facebookapp.go | 1697 ----------------- handlers/facebookapp/facebookapp_test.go | 1673 ---------------- .../facebookapp/testdata/wac/stickerWAC.json | 42 - 5 files changed, 5100 deletions(-) delete mode 100644 handlers/dialog360/dialog360.go delete mode 100644 handlers/dialog360/dialog360_test.go delete mode 100644 handlers/facebookapp/facebookapp.go delete mode 100644 handlers/facebookapp/facebookapp_test.go delete mode 100644 handlers/facebookapp/testdata/wac/stickerWAC.json diff --git a/handlers/dialog360/dialog360.go b/handlers/dialog360/dialog360.go deleted file mode 100644 index e7bdbadde..000000000 --- a/handlers/dialog360/dialog360.go +++ /dev/null @@ -1,1006 +0,0 @@ -package dialog360 - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/buger/jsonparser" - "github.com/nyaruka/courier" - "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/utils" - "github.com/nyaruka/gocommon/urns" - "github.com/pkg/errors" -) - -const ( - d3AuthorizationKey = "D360-API-KEY" -) - -var ( - // max for the body - maxMsgLength = 1000 -) - -// see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types -var mediaSupport = map[handlers.MediaType]handlers.MediaTypeSupport{ - handlers.MediaType("image/webp"): {Types: []string{"image/webp"}, MaxBytes: 100 * 1024, MaxWidth: 512, MaxHeight: 512}, - handlers.MediaTypeImage: {Types: []string{"image/jpeg", "image/png"}, MaxBytes: 5 * 1024 * 1024}, - handlers.MediaTypeAudio: {Types: []string{"audio/aac", "audio/mp4", "audio/mpeg", "audio/amr", "audio/ogg"}, MaxBytes: 16 * 1024 * 1024}, - handlers.MediaTypeVideo: {Types: []string{"video/mp4", "video/3gp"}, MaxBytes: 16 * 1024 * 1024}, - handlers.MediaTypeApplication: {MaxBytes: 100 * 1024 * 1024}, -} - -func init() { - courier.RegisterHandler(newWAHandler(courier.ChannelType("D3C"), "360Dialog")) -} - -type handler struct { - handlers.BaseHandler -} - -func newWAHandler(channelType courier.ChannelType, name string) courier.ChannelHandler { - return &handler{handlers.NewBaseHandler(channelType, name)} -} - -// Initialize is called by the engine once everything is loaded -func (h *handler) Initialize(s courier.Server) error { - h.SetServer(s) - s.AddHandlerRoute(h, http.MethodPost, "receive", courier.ChannelLogTypeMultiReceive, handlers.JSONPayload(h, h.receiveEvent)) - return nil -} - -var waStatusMapping = map[string]courier.MsgStatus{ - "sent": courier.MsgStatusSent, - "delivered": courier.MsgStatusDelivered, - "read": courier.MsgStatusDelivered, - "failed": courier.MsgStatusFailed, -} - -var waIgnoreStatuses = map[string]bool{ - "deleted": true, -} - -type Sender struct { - ID string `json:"id"` - UserRef string `json:"user_ref,omitempty"` -} - -type User struct { - ID string `json:"id"` -} - -// { -// "object":"page", -// "entry":[{ -// "id":"180005062406476", -// "time":1514924367082, -// "messaging":[{ -// "sender": {"id":"1630934236957797"}, -// "recipient":{"id":"180005062406476"}, -// "timestamp":1514924366807, -// "message":{ -// "mid":"mid.$cAAD5QiNHkz1m6cyj11guxokwkhi2", -// "seq":33116, -// "text":"65863634" -// } -// }] -// }] -// } - -type wacMedia struct { - Caption string `json:"caption"` - Filename string `json:"filename"` - ID string `json:"id"` - Mimetype string `json:"mime_type"` - SHA256 string `json:"sha256"` -} - -type wacSticker struct { - Animated bool `json:"animated"` - 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"` - 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 *wacMedia `json:"image"` - Audio *wacMedia `json:"audio"` - Video *wacMedia `json:"video"` - Document *wacMedia `json:"document"` - Voice *wacMedia `json:"voice"` - Sticker *wacSticker `json:"sticker"` - 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"` - Interactive struct { - Type string `json:"type"` - ButtonReply struct { - ID string `json:"id"` - Title string `json:"title"` - } `json:"button_reply,omitempty"` - ListReply struct { - ID string `json:"id"` - Title string `json:"title"` - } `json:"list_reply,omitempty"` - } `json:"interactive,omitempty"` - Errors []struct { - Code int `json:"code"` - Title string `json:"title"` - } `json:"errors"` - } `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"` - } `json:"conversation"` - Pricing *struct { - PricingModel string `json:"pricing_model"` - Billable bool `json:"billable"` - Category string `json:"category"` - } `json:"pricing"` - Errors []struct { - Code int `json:"code"` - Title string `json:"title"` - } `json:"errors"` - } `json:"statuses"` - Errors []struct { - Code int `json:"code"` - Title string `json:"title"` - } `json:"errors"` - } `json:"value"` - } `json:"changes"` - } `json:"entry"` -} - -// 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, payload *moPayload, clog *courier.ChannelLog) ([]courier.Event, error) { - - // is not a 'whatsapp_business_account' object? ignore it - if payload.Object != "whatsapp_business_account" { - return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request") - } - - // no entries? ignore this request - if len(payload.Entry) == 0 { - return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request, no entries") - } - - var events []courier.Event - var data []any - - events, data, err := h.processCloudWhatsAppPayload(ctx, channel, payload, w, r, clog) - if err != nil { - return nil, err - } - - return events, courier.WriteDataResponse(w, http.StatusOK, "Events Handled", data) -} - -func (h *handler) processCloudWhatsAppPayload(ctx context.Context, channel courier.Channel, payload *moPayload, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, []any, 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([]any, 0, 2) - - seenMsgIDs := make(map[string]bool) - 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 { - if seenMsgIDs[msg.ID] { - continue - } - - // create our date from the timestamp - ts, err := strconv.ParseInt(msg.Timestamp, 10, 64) - if err != nil { - return nil, nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, fmt.Sprintf("invalid timestamp: %s", msg.Timestamp)) - } - date := time.Unix(ts, 0).UTC() - - urn, err := urns.NewWhatsAppURN(msg.From) - if err != nil { - return nil, nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, err.Error()) - } - - for _, msgError := range msg.Errors { - clog.Error(courier.ErrorExternal(strconv.Itoa(msgError.Code), msgError.Title)) - } - - 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, clog) - } else if msg.Type == "voice" && msg.Voice != nil { - text = msg.Voice.Caption - mediaURL, err = resolveMediaURL(channel, msg.Voice.ID, clog) - } 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, clog) - } else if msg.Type == "image" && msg.Image != nil { - text = msg.Image.Caption - mediaURL, err = resolveMediaURL(channel, msg.Image.ID, clog) - } else if msg.Type == "sticker" && msg.Sticker != nil { - mediaURL, err = resolveMediaURL(channel, msg.Sticker.ID, clog) - } else if msg.Type == "video" && msg.Video != nil { - text = msg.Video.Caption - mediaURL, err = resolveMediaURL(channel, msg.Video.ID, clog) - } else if msg.Type == "location" && msg.Location != nil { - mediaURL = fmt.Sprintf("geo:%f,%f", msg.Location.Latitude, msg.Location.Longitude) - } else if msg.Type == "interactive" && msg.Interactive.Type == "button_reply" { - text = msg.Interactive.ButtonReply.Title - } else if msg.Type == "interactive" && msg.Interactive.Type == "list_reply" { - text = msg.Interactive.ListReply.Title - } else { - // we received a message type we do not support. - courier.LogRequestError(r, channel, fmt.Errorf("unsupported message type %s", msg.Type)) - continue - } - - // create our message - event := h.Backend().NewIncomingMsg(channel, urn, text, msg.ID, clog).WithReceivedOn(date).WithContactName(contactNames[msg.From]) - - // 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, clog) - if err != nil { - return nil, nil, err - } - - events = append(events, event) - data = append(data, courier.NewMsgReceiveData(event)) - seenMsgIDs[msg.ID] = true - } - - 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.WriteAndLogRequestIgnored(ctx, h, channel, w, r, fmt.Sprintf("unknown status: %s", status.Status)) - } - continue - } - - for _, statusError := range status.Errors { - clog.Error(courier.ErrorExternal(strconv.Itoa(statusError.Code), statusError.Title)) - } - - event := h.Backend().NewStatusUpdateByExternalID(channel, status.ID, msgStatus, clog) - err := h.Backend().WriteStatusUpdate(ctx, event) - if err != nil { - return nil, nil, err - } - - events = append(events, event) - data = append(data, courier.NewStatusData(event)) - - } - - for _, chError := range change.Value.Errors { - clog.Error(courier.ErrorExternal(strconv.Itoa(chError.Code), chError.Title)) - } - - } - - } - return events, data, nil -} - -// BuildAttachmentRequest to download media for message attachment with Bearer token set -func (h *handler) BuildAttachmentRequest(ctx context.Context, b courier.Backend, channel courier.Channel, attachmentURL string, clog *courier.ChannelLog) (*http.Request, error) { - token := channel.StringConfigForKey(courier.ConfigAuthToken, "") - if token == "" { - return nil, fmt.Errorf("missing token for D3C channel") - } - - // set the access token as the authorization header - req, _ := http.NewRequest(http.MethodGet, attachmentURL, nil) - req.Header.Set("User-Agent", utils.HTTPUserAgent) - req.Header.Set(d3AuthorizationKey, token) - return req, nil -} - -var _ courier.AttachmentRequestBuilder = (*handler)(nil) - -func resolveMediaURL(channel courier.Channel, mediaID string, clog *courier.ChannelLog) (string, error) { - // sometimes WA will send an attachment with status=undownloaded and no ID - if mediaID == "" { - return "", nil - } - - token := channel.StringConfigForKey(courier.ConfigAuthToken, "") - if token == "" { - return "", fmt.Errorf("missing token for D3C channel") - } - - urlStr := channel.StringConfigForKey(courier.ConfigBaseURL, "") - url, err := url.Parse(urlStr) - if err != nil { - return "", fmt.Errorf("invalid base url set for D3C channel: %s", err) - } - - mediaPath, _ := url.Parse(mediaID) - mediaURL := url.ResolveReference(mediaPath).String() - - req, _ := http.NewRequest(http.MethodGet, mediaURL, nil) - req.Header.Set("User-Agent", utils.HTTPUserAgent) - req.Header.Set(d3AuthorizationKey, token) - - resp, respBody, err := handlers.RequestHTTP(req, clog) - if err != nil || resp.StatusCode/100 != 2 { - return "", fmt.Errorf("failed to request media URL for D3C channel: %s", err) - } - - fbFileURL, err := jsonparser.GetString(respBody, "url") - if err != nil { - return "", fmt.Errorf("missing url field in response for D3C media: %s", err) - } - - fileURL := strings.ReplaceAll(fbFileURL, "https://lookaside.fbsbx.com", urlStr) - - return fileURL, nil -} - -type wacMTMedia struct { - ID string `json:"id,omitempty"` - Link string `json:"link,omitempty"` - Caption string `json:"caption,omitempty"` - Filename string `json:"filename,omitempty"` -} - -type wacMTSection struct { - Title string `json:"title,omitempty"` - Rows []wacMTSectionRow `json:"rows" validate:"required"` -} - -type wacMTSectionRow struct { - ID string `json:"id" validate:"required"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` -} - -type wacMTButton 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 wacParam struct { - Type string `json:"type"` - Text string `json:"text"` -} - -type wacComponent struct { - Type string `json:"type"` - SubType string `json:"sub_type"` - Index string `json:"index"` - Params []*wacParam `json:"parameters"` -} - -type wacText struct { - Body string `json:"body"` - PreviewURL bool `json:"preview_url"` -} - -type wacLanguage struct { - Policy string `json:"policy"` - Code string `json:"code"` -} - -type wacTemplate struct { - Name string `json:"name"` - Language *wacLanguage `json:"language"` - Components []*wacComponent `json:"components"` -} - -type wacInteractive struct { - Type string `json:"type"` - Header *struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - Video *wacMTMedia `json:"video,omitempty"` - Image *wacMTMedia `json:"image,omitempty"` - Document *wacMTMedia `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 []wacMTSection `json:"sections,omitempty"` - Buttons []wacMTButton `json:"buttons,omitempty"` - } `json:"action,omitempty"` -} - -type wacMTPayload struct { - MessagingProduct string `json:"messaging_product"` - RecipientType string `json:"recipient_type"` - To string `json:"to"` - Type string `json:"type"` - - Text *wacText `json:"text,omitempty"` - - Document *wacMTMedia `json:"document,omitempty"` - Image *wacMTMedia `json:"image,omitempty"` - Audio *wacMTMedia `json:"audio,omitempty"` - Video *wacMTMedia `json:"video,omitempty"` - Sticker *wacMTMedia `json:"sticker,omitempty"` - - Interactive *wacInteractive `json:"interactive,omitempty"` - - Template *wacTemplate `json:"template,omitempty"` -} - -type wacMTResponse struct { - Messages []*struct { - ID string `json:"id"` - } `json:"messages"` - Error struct { - Message string `json:"message"` - Code int `json:"code"` - } `json:"error"` -} - -// Send implements courier.ChannelHandler -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.StatusUpdate, error) { - conn := h.Backend().RedisPool().Get() - defer conn.Close() - - // get our token - // can't do anything without an access token - accessToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") - if accessToken == "" { - return nil, fmt.Errorf("missing token for D3C channel") - } - - urlStr := msg.Channel().StringConfigForKey(courier.ConfigBaseURL, "") - url, err := url.Parse(urlStr) - if err != nil { - return nil, fmt.Errorf("invalid base url set for D3C channel: %s", err) - } - sendURL, _ := url.Parse("/messages") - - status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) - - hasCaption := false - - msgParts := make([]string, 0) - if msg.Text() != "" { - msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) - } - qrs := msg.QuickReplies() - lang := getSupportedLanguage(msg.Locale()) - - var payloadAudio wacMTPayload - - attachments, err := handlers.ResolveAttachments(ctx, h.Backend(), msg.Attachments(), mediaSupport, false) - if err != nil { - return nil, errors.Wrap(err, "error resolving attachments") - } - - for i := 0; i < len(msgParts)+len(attachments); i++ { - payload := wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path()} - - if len(attachments) == 0 { - // do we have a template? - templating, err := h.getTemplating(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 := wacTemplate{Name: templating.Template.Name, Language: &wacLanguage{Policy: "deterministic", Code: lang.code}} - payload.Template = &template - - component := &wacComponent{Type: "body"} - - for _, v := range templating.Variables { - component.Params = append(component.Params, &wacParam{Type: "text", Text: v}) - } - template.Components = append(payload.Template.Components, component) - - } else { - if i < (len(msgParts) + len(attachments) - 1) { - // this is still a msg part - text := &wacText{PreviewURL: false} - payload.Type = "text" - if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { - text.PreviewURL = true - } - text.Body = msgParts[i-len(attachments)] - payload.Text = text - } else { - if len(qrs) > 0 { - payload.Type = "interactive" - // We can use buttons - if len(qrs) <= 3 { - interactive := wacInteractive{Type: "button", Body: struct { - Text string "json:\"text\"" - }{Text: msgParts[i-len(attachments)]}} - - btns := make([]wacMTButton, len(qrs)) - for i, qr := range qrs { - btns[i] = wacMTButton{ - Type: "reply", - } - btns[i].Reply.ID = fmt.Sprint(i) - btns[i].Reply.Title = qr - } - interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" - }{Buttons: btns} - payload.Interactive = &interactive - } else if len(qrs) <= 10 { - interactive := wacInteractive{Type: "list", Body: struct { - Text string "json:\"text\"" - }{Text: msgParts[i-len(attachments)]}} - - section := wacMTSection{ - Rows: make([]wacMTSectionRow, len(qrs)), - } - for i, qr := range qrs { - section.Rows[i] = wacMTSectionRow{ - ID: fmt.Sprint(i), - Title: qr, - } - } - - interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" - }{Button: lang.menu, Sections: []wacMTSection{ - section, - }} - - payload.Interactive = &interactive - } else { - return nil, fmt.Errorf("too many quick replies WAC supports only up to 10 quick replies") - } - } else { - // this is still a msg part - text := &wacText{PreviewURL: false} - payload.Type = "text" - if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { - text.PreviewURL = true - } - text.Body = msgParts[i-len(attachments)] - payload.Text = text - } - } - } - - } else if i < len(attachments) && (len(qrs) == 0 || len(qrs) > 3) { - attURL := attachments[i].Media.URL() - attType := attachments[i].Type - attContentType := attachments[i].Media.ContentType() - if attType == "application" { - attType = "document" - } - payload.Type = string(attType) - media := wacMTMedia{Link: attURL} - - if len(msgParts) == 1 && attType != "audio" && len(attachments) == 1 && len(msg.QuickReplies()) == 0 { - media.Caption = msgParts[i] - hasCaption = true - } - - if attType == "image" { - if attContentType == "image/webp" { - payload.Type = "sticker" - payload.Sticker = &media - } else { - payload.Image = &media - } - - } else if attType == "audio" { - payload.Audio = &media - } else if attType == "video" { - payload.Video = &media - } else if attType == "document" { - filename, err := utils.BasePathForURL(attURL) - if err != nil { - filename = "" - } - if filename != "" { - media.Filename = filename - } - payload.Document = &media - } - } else { - if len(qrs) > 0 { - payload.Type = "interactive" - // We can use buttons - if len(qrs) <= 3 { - interactive := wacInteractive{Type: "button", Body: struct { - Text string "json:\"text\"" - }{Text: msgParts[i]}} - - if len(attachments) > 0 { - hasCaption = true - attURL := attachments[i].Media.URL() - attType := attachments[i].Type - if attType == "application" { - attType = "document" - } - if attType == "image" { - image := wacMTMedia{ - Link: attURL, - } - interactive.Header = &struct { - Type string "json:\"type\"" - Text string "json:\"text,omitempty\"" - Video *wacMTMedia "json:\"video,omitempty\"" - Image *wacMTMedia "json:\"image,omitempty\"" - Document *wacMTMedia "json:\"document,omitempty\"" - }{Type: "image", Image: &image} - } else if attType == "video" { - video := wacMTMedia{ - Link: attURL, - } - interactive.Header = &struct { - Type string "json:\"type\"" - Text string "json:\"text,omitempty\"" - Video *wacMTMedia "json:\"video,omitempty\"" - Image *wacMTMedia "json:\"image,omitempty\"" - Document *wacMTMedia "json:\"document,omitempty\"" - }{Type: "video", Video: &video} - } else if attType == "document" { - filename, err := utils.BasePathForURL(attURL) - if err != nil { - return nil, err - } - document := wacMTMedia{ - Link: attURL, - Filename: filename, - } - interactive.Header = &struct { - Type string "json:\"type\"" - Text string "json:\"text,omitempty\"" - Video *wacMTMedia "json:\"video,omitempty\"" - Image *wacMTMedia "json:\"image,omitempty\"" - Document *wacMTMedia "json:\"document,omitempty\"" - }{Type: "document", Document: &document} - } else if attType == "audio" { - var zeroIndex bool - if i == 0 { - zeroIndex = true - } - payloadAudio = wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path(), Type: "audio", Audio: &wacMTMedia{Link: attURL}} - status, err := requestD3C(payloadAudio, accessToken, status, sendURL, zeroIndex, clog) - if err != nil { - return status, nil - } - } else { - interactive.Type = "button" - interactive.Body.Text = msgParts[i] - } - } - - btns := make([]wacMTButton, len(qrs)) - for i, qr := range qrs { - btns[i] = wacMTButton{ - Type: "reply", - } - btns[i].Reply.ID = fmt.Sprint(i) - btns[i].Reply.Title = qr - } - interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" - }{Buttons: btns} - payload.Interactive = &interactive - - } else if len(qrs) <= 10 { - interactive := wacInteractive{Type: "list", Body: struct { - Text string "json:\"text\"" - }{Text: msgParts[i-len(msg.Attachments())]}} - - section := wacMTSection{ - Rows: make([]wacMTSectionRow, len(qrs)), - } - for i, qr := range qrs { - section.Rows[i] = wacMTSectionRow{ - ID: fmt.Sprint(i), - Title: qr, - } - } - - interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" - }{Button: lang.menu, Sections: []wacMTSection{ - section, - }} - - payload.Interactive = &interactive - } else { - return nil, fmt.Errorf("too many quick replies WAC supports only up to 10 quick replies") - } - } else { - // this is still a msg part - text := &wacText{PreviewURL: false} - payload.Type = "text" - if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { - text.PreviewURL = true - } - text.Body = msgParts[i-len(attachments)] - payload.Text = text - } - } - - var zeroIndex bool - if i == 0 { - zeroIndex = true - } - - status, err := requestD3C(payload, accessToken, status, sendURL, zeroIndex, clog) - if err != nil { - return status, err - } - - if hasCaption { - break - } - } - return status, nil -} - -func requestD3C(payload wacMTPayload, accessToken string, status courier.StatusUpdate, wacPhoneURL *url.URL, zeroIndex bool, clog *courier.ChannelLog) (courier.StatusUpdate, error) { - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, err - } - - req, err := http.NewRequest(http.MethodPost, wacPhoneURL.String(), bytes.NewReader(jsonBody)) - if err != nil { - return nil, err - } - - req.Header.Set(d3AuthorizationKey, accessToken) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - _, respBody, _ := handlers.RequestHTTP(req, clog) - respPayload := &wacMTResponse{} - err = json.Unmarshal(respBody, respPayload) - if err != nil { - clog.Error(courier.ErrorResponseUnparseable("JSON")) - return status, nil - } - - if respPayload.Error.Code != 0 { - clog.Error(courier.ErrorExternal(strconv.Itoa(respPayload.Error.Code), respPayload.Error.Message)) - return status, nil - } - - externalID := respPayload.Messages[0].ID - if zeroIndex && externalID != "" { - status.SetExternalID(externalID) - } - // this was wired successfully - status.SetStatus(courier.MsgStatusWired) - return status, nil -} - -func (h *handler) getTemplating(msg courier.Msg) (*MsgTemplating, error) { - if len(msg.Metadata()) == 0 { - return nil, nil - } - - metadata := &struct { - Templating *MsgTemplating `json:"templating"` - }{} - if err := json.Unmarshal(msg.Metadata(), metadata); err != nil { - return nil, err - } - - if metadata.Templating == nil { - return nil, nil - } - - if err := utils.Validate(metadata.Templating); err != nil { - return nil, errors.Wrapf(err, "invalid templating definition") - } - - return metadata.Templating, nil -} - -type MsgTemplating struct { - Template struct { - Name string `json:"name" validate:"required"` - UUID string `json:"uuid" validate:"required"` - } `json:"template" validate:"required,dive"` - Namespace string `json:"namespace"` - Variables []string `json:"variables"` -} - -func getSupportedLanguage(lc courier.Locale) languageInfo { - // look for exact match - if lang := supportedLanguages[lc]; lang.code != "" { - return lang - } - - // if we have a country, strip that off and look again for a match - l, c := lc.ToParts() - if c != "" { - if lang := supportedLanguages[courier.Locale(l)]; lang.code != "" { - return lang - } - } - return supportedLanguages["eng"] // fallback to English -} - -type languageInfo struct { - code string - menu string // translation of "Menu" -} - -// Mapping from engine locales to supported languages. Note that these are not all valid BCP47 codes, e.g. fil -// see https://developers.facebook.com/docs/whatsapp/api/messages/message-templates/ -var supportedLanguages = map[courier.Locale]languageInfo{ - "afr": {code: "af", menu: "Kieslys"}, // Afrikaans - "sqi": {code: "sq", menu: "Menu"}, // Albanian - "ara": {code: "ar", menu: "قائمة"}, // Arabic - "aze": {code: "az", menu: "Menu"}, // Azerbaijani - "ben": {code: "bn", menu: "Menu"}, // Bengali - "bul": {code: "bg", menu: "Menu"}, // Bulgarian - "cat": {code: "ca", menu: "Menu"}, // Catalan - "zho": {code: "zh_CN", menu: "菜单"}, // Chinese - "zho-CN": {code: "zh_CN", menu: "菜单"}, // Chinese (CHN) - "zho-HK": {code: "zh_HK", menu: "菜单"}, // Chinese (HKG) - "zho-TW": {code: "zh_TW", menu: "菜单"}, // Chinese (TAI) - "hrv": {code: "hr", menu: "Menu"}, // Croatian - "ces": {code: "cs", menu: "Menu"}, // Czech - "dah": {code: "da", menu: "Menu"}, // Danish - "nld": {code: "nl", menu: "Menu"}, // Dutch - "eng": {code: "en", menu: "Menu"}, // English - "eng-GB": {code: "en_GB", menu: "Menu"}, // English (UK) - "eng-US": {code: "en_US", menu: "Menu"}, // English (US) - "est": {code: "et", menu: "Menu"}, // Estonian - "fil": {code: "fil", menu: "Menu"}, // Filipino - "fin": {code: "fi", menu: "Menu"}, // Finnish - "fra": {code: "fr", menu: "Menu"}, // French - "kat": {code: "ka", menu: "Menu"}, // Georgian - "deu": {code: "de", menu: "Menü"}, // German - "ell": {code: "el", menu: "Menu"}, // Greek - "guj": {code: "gu", menu: "Menu"}, // Gujarati - "hau": {code: "ha", menu: "Menu"}, // Hausa - "enb": {code: "he", menu: "תפריט"}, // Hebrew - "hin": {code: "hi", menu: "Menu"}, // Hindi - "hun": {code: "hu", menu: "Menu"}, // Hungarian - "ind": {code: "id", menu: "Menu"}, // Indonesian - "gle": {code: "ga", menu: "Roghchlár"}, // Irish - "ita": {code: "it", menu: "Menu"}, // Italian - "jpn": {code: "ja", menu: "Menu"}, // Japanese - "kan": {code: "kn", menu: "Menu"}, // Kannada - "kaz": {code: "kk", menu: "Menu"}, // Kazakh - "kin": {code: "rw_RW", menu: "Menu"}, // Kinyarwanda - "kor": {code: "ko", menu: "Menu"}, // Korean - "kir": {code: "ky_KG", menu: "Menu"}, // Kyrgyzstan - "lao": {code: "lo", menu: "Menu"}, // Lao - "lav": {code: "lv", menu: "Menu"}, // Latvian - "lit": {code: "lt", menu: "Menu"}, // Lithuanian - "mal": {code: "ml", menu: "Menu"}, // Malayalam - "mkd": {code: "mk", menu: "Menu"}, // Macedonian - "msa": {code: "ms", menu: "Menu"}, // Malay - "mar": {code: "mr", menu: "Menu"}, // Marathi - "nob": {code: "nb", menu: "Menu"}, // Norwegian - "fas": {code: "fa", menu: "Menu"}, // Persian - "pol": {code: "pl", menu: "Menu"}, // Polish - "por": {code: "pt_PT", menu: "Menu"}, // Portuguese - "por-BR": {code: "pt_BR", menu: "Menu"}, // Portuguese (BR) - "por-PT": {code: "pt_PT", menu: "Menu"}, // Portuguese (POR) - "pan": {code: "pa", menu: "Menu"}, // Punjabi - "ron": {code: "ro", menu: "Menu"}, // Romanian - "rus": {code: "ru", menu: "Menu"}, // Russian - "srp": {code: "sr", menu: "Menu"}, // Serbian - "slk": {code: "sk", menu: "Menu"}, // Slovak - "slv": {code: "sl", menu: "Menu"}, // Slovenian - "spa": {code: "es", menu: "Menú"}, // Spanish - "spa-AR": {code: "es_AR", menu: "Menú"}, // Spanish (ARG) - "spa-ES": {code: "es_ES", menu: "Menú"}, // Spanish (SPA) - "spa-MX": {code: "es_MX", menu: "Menú"}, // Spanish (MEX) - "swa": {code: "sw", menu: "Menyu"}, // Swahili - "swe": {code: "sv", menu: "Menu"}, // Swedish - "tam": {code: "ta", menu: "Menu"}, // Tamil - "tel": {code: "te", menu: "Menu"}, // Telugu - "tha": {code: "th", menu: "Menu"}, // Thai - "tur": {code: "tr", menu: "Menu"}, // Turkish - "ukr": {code: "uk", menu: "Menu"}, // Ukrainian - "urd": {code: "ur", menu: "Menu"}, // Urdu - "uzb": {code: "uz", menu: "Menu"}, // Uzbek - "vie": {code: "vi", menu: "Menu"}, // Vietnamese - "zul": {code: "zu", menu: "Menu"}, // Zulu -} diff --git a/handlers/dialog360/dialog360_test.go b/handlers/dialog360/dialog360_test.go deleted file mode 100644 index 535727f76..000000000 --- a/handlers/dialog360/dialog360_test.go +++ /dev/null @@ -1,682 +0,0 @@ -package dialog360 - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/nyaruka/courier" - . "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/test" - "github.com/nyaruka/gocommon/httpx" - "github.com/stretchr/testify/assert" -) - -var testChannels = []courier.Channel{ - test.NewMockChannel( - "8eb23e93-5ecb-45ba-b726-3b064e0c568c", - "D3C", - "250788383383", - "RW", - map[string]any{ - "auth_token": "the-auth-token", - "base_url": "https://waba-v2.360dialog.io", - }), -} - -var ( - d3CReceiveURL = "/c/d3c/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive" -) - -var testCasesD3C = []IncomingTestCase{ - { - Label: "Receive Message WAC", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/helloWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Hello World"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Duplicate Valid Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/duplicateWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Hello World"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Valid Voice Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/voiceWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp(""), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_voice"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Valid Button Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/buttonWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("No"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Valid Document Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/documentWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("80skaraokesonglistartist"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_document"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Valid Image Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/imageWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Check out my new phone!"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_image"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Valid Sticker Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/stickerWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp(""), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_sticker"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Valid Video Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/videoWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Check out my new phone!"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_video"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Valid Audio Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/audioWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Check out my new phone!"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_audio"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Valid Location Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/locationWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"type":"msg"`, - ExpectedMsgText: Sp(""), - ExpectedAttachments: []string{"geo:0.000000,1.000000"}, - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Invalid JSON", - URL: d3CReceiveURL, - Data: "not json", - ExpectedRespStatus: 400, - ExpectedBodyContains: "unable to parse", - }, - { - Label: "Receive Invalid FROM", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/invalidFrom.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "invalid whatsapp id", - }, - { - Label: "Receive Invalid timestamp JSON", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/invalidTimestamp.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "invalid timestamp", - }, - { - Label: "Receive Message WAC with error message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/errorMsg.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("131051", "Unsupported message type")}, - NoInvalidChannelCheck: true, - }, - { - Label: "Receive error message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/errorErrors.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("0", "We were unable to authenticate the app user")}, - NoInvalidChannelCheck: true, - }, - { - Label: "Receive Valid Status", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/validStatusWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"type":"status"`, - ExpectedMsgStatus: "S", - ExpectedExternalID: "external_id", - }, - { - Label: "Receive Valid Status with error message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/errorStatus.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"type":"status"`, - ExpectedMsgStatus: "F", - ExpectedExternalID: "external_id", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("131014", "Request for url https://URL.jpg failed with error: 404 (Not Found)")}, - }, - { - Label: "Receive Invalid Status", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/invalidStatusWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"unknown status: in_orbit"`, - }, - { - Label: "Receive Ignore Status", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/ignoreStatusWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"ignoring status: deleted"`, - }, - { - Label: "Receive Valid Interactive Button Reply Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/buttonReplyWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Yes"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, - { - Label: "Receive Valid Interactive List Reply Message", - URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/listReplyWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Yes"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - }, -} - -func buildMockD3MediaService(testChannels []courier.Channel, testCases []IncomingTestCase) *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fileURL := "" - - if strings.HasSuffix(r.URL.Path, "id_voice") { - fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_voice" - } - if strings.HasSuffix(r.URL.Path, "id_document") { - fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_document" - } - if strings.HasSuffix(r.URL.Path, "id_image") { - fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_image" - } - if strings.HasSuffix(r.URL.Path, "id_video") { - fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_video" - } - if strings.HasSuffix(r.URL.Path, "id_audio") { - fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_audio" - } - - if strings.HasSuffix(r.URL.Path, "id_sticker") { - fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_sticker" - } - - w.WriteHeader(http.StatusOK) - w.Write([]byte(fmt.Sprintf(`{ "url": "%s" }`, fileURL))) - })) - testChannels[0].(*test.MockChannel).SetConfig("base_url", server.URL) - - // update our tests media urls - for _, tc := range testCases { - for i := range tc.ExpectedAttachments { - if !strings.HasPrefix(tc.ExpectedAttachments[i], "geo:") { - tc.ExpectedAttachments[i] = strings.ReplaceAll(tc.ExpectedAttachments[i], "https://waba-v2.360dialog.io", server.URL) - } - } - } - - return server -} - -func TestIncoming(t *testing.T) { - - d3MediaService := buildMockD3MediaService(testChannels, testCasesD3C) - defer d3MediaService.Close() - - RunIncomingTestCases(t, testChannels, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), testCasesD3C) -} - -func BenchmarkHandler(b *testing.B) { - d3MediaService := buildMockD3MediaService(testChannels, testCasesD3C) - defer d3MediaService.Close() - RunChannelBenchmarks(b, testChannels, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), testCasesD3C) -} - -func TestBuildAttachmentRequest(t *testing.T) { - mb := test.NewMockBackend() - - d3CHandler := &handler{NewBaseHandler(courier.ChannelType("D3C"), "360Dialog")} - req, _ := d3CHandler.BuildAttachmentRequest(context.Background(), mb, testChannels[0], "https://example.org/v1/media/41", nil) - assert.Equal(t, "https://example.org/v1/media/41", req.URL.String()) - assert.Equal(t, "the-auth-token", req.Header.Get("D360-API-KEY")) - -} - -// setSendURL takes care of setting the base_url to our test server host -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { - c.(*test.MockChannel).SetConfig("base_url", s.URL) -} - -var SendTestCasesD3C = []OutgoingTestCase{ - { - Label: "Plain Send", - MsgText: "Simple Message", - MsgURN: "whatsapp:250788123123", - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"Simple Message","preview_url":false}}`, - ExpectedRequestPath: "/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Unicode Send", - MsgText: "☺", - MsgURN: "whatsapp:250788123123", - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"☺","preview_url":false}}`, - ExpectedRequestPath: "/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Audio Send", - MsgText: "audio caption", - MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"audio/mpeg:http://mock.com/3456/test.mp3"}, - MockResponses: map[MockedRequest]*httpx.MockResponse{ - { - Method: "POST", - Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/3456/test.mp3"}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - { - Method: "POST", - Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"audio caption","preview_url":false}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - }, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Document Send", - MsgText: "document caption", - MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"application/pdf:http://mock.com/7890/test.pdf"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"http://mock.com/7890/test.pdf","caption":"document caption","filename":"test.pdf"}}`, - ExpectedRequestPath: "/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Image Send", - MsgText: "image caption", - MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/1234/test.jpg","caption":"image caption"}}`, - ExpectedRequestPath: "/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Sticker Send", - MsgText: "sticker caption", - MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"image/webp:http://mock.com/8901/test.webp"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"sticker","sticker":{"link":"http://mock.com/8901/test.webp","caption":"sticker caption"}}`, - ExpectedRequestPath: "/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Video Send", - MsgText: "video caption", - MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"video/mp4:http://mock.com/5678/test.mp4"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"http://mock.com/5678/test.mp4","caption":"video caption"}}`, - ExpectedRequestPath: "/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Template Send", - MsgText: "templated message", - MsgURN: "whatsapp:250788123123", - MsgLocale: "eng", - MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"]}}`), - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_product":"whatsapp","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", - MsgText: "templated message", - MsgURN: "whatsapp:250788123123", - MsgLocale: "eng-US", - MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"]}}`), - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Template Invalid Language", - MsgText: "templated message", - MsgURN: "whatsapp:250788123123", - MsgLocale: "bnt", - MsgMetadata: json.RawMessage(`{"templating": { "template": { "name": "revive_issue", "uuid": "8ca114b4-bee2-4d3b-aaf1-9aa6b48d41e8" }, "variables": ["Chef", "tomorrow"]}}`), - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive Button Message Send", - MsgText: "Interactive Button Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"BUTTON1"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive List Message Send", - MsgText: "Interactive List Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive List Message Send In Spanish", - MsgText: "Hola", - MsgURN: "whatsapp:250788123123", - MsgLocale: "spa", - MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"list","body":{"text":"Hola"},"action":{"button":"Menú","sections":[{"rows":[{"id":"0","title":"ROW1"},{"id":"1","title":"ROW2"},{"id":"2","title":"ROW3"},{"id":"3","title":"ROW4"}]}]}}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive Button Message Send with image attachment", - MsgText: "Interactive Button Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"http://mock.com/1234/test.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, - ExpectedRequestPath: "/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive Button Message Send with video attachment", - MsgText: "Interactive Button Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"video/mp4:http://mock.com/5678/test.mp4"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"http://mock.com/5678/test.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, - ExpectedRequestPath: "/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive Button Message Send with document attachment", - MsgText: "Interactive Button Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"document/pdf:http://mock.com/7890/test.pdf"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"http://mock.com/7890/test.pdf","filename":"test.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, - ExpectedRequestPath: "/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive Button Message Send with audio attachment", - MsgText: "Interactive Button Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3"}, - MsgAttachments: []string{"audio/mp3:http://mock.com/3456/test.mp3"}, - MockResponses: map[MockedRequest]*httpx.MockResponse{ - { - Method: "POST", - Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/3456/test.mp3"}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - { - Method: "POST", - Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"ROW1"}},{"type":"reply","reply":{"id":"1","title":"ROW2"}},{"type":"reply","reply":{"id":"2","title":"ROW3"}}]}}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - }, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive List Message Send with attachment", - MsgText: "Interactive List Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, - MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, - MockResponses: map[MockedRequest]*httpx.MockResponse{ - { - Method: "POST", - Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/1234/test.jpg"}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - { - Method: "POST", - Path: "/messages", - Body: `{"messaging_product":"whatsapp","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"}]}]}}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - }, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Link Sending", - MsgText: "Link Sending https://link.com", - MsgURN: "whatsapp:250788123123", - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"Link Sending https://link.com","preview_url":true}}`, - ExpectedRequestPath: "/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Error Bad JSON", - MsgText: "Error", - MsgURN: "whatsapp:250788123123", - MockResponseBody: `bad json`, - MockResponseStatus: 403, - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseUnparseable("JSON")}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, - { - Label: "Error", - MsgText: "Error", - MsgURN: "whatsapp:250788123123", - MockResponseBody: `{ "error": {"message": "(#130429) Rate limit hit","code": 130429 }}`, - MockResponseStatus: 403, - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("130429", "(#130429) Rate limit hit")}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, -} - -// setupMedia takes care of having the media files needed to our test server host -func setupMedia(mb *test.MockBackend) { - imageJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/1234/test.jpg", 1024*1024, 640, 480, 0, nil) - - audioM4A := test.NewMockMedia("test.m4a", "audio/mp4", "http://mock.com/2345/test.m4a", 1024*1024, 0, 0, 200, nil) - audioMP3 := test.NewMockMedia("test.mp3", "audio/mpeg", "http://mock.com/3456/test.mp3", 1024*1024, 0, 0, 200, []courier.Media{audioM4A}) - - thumbJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/4567/test.jpg", 1024*1024, 640, 480, 0, nil) - videoMP4 := test.NewMockMedia("test.mp4", "video/mp4", "http://mock.com/5678/test.mp4", 1024*1024, 0, 0, 1000, []courier.Media{thumbJPG}) - - videoMOV := test.NewMockMedia("test.mov", "video/quicktime", "http://mock.com/6789/test.mov", 100*1024*1024, 0, 0, 2000, nil) - - filePDF := test.NewMockMedia("test.pdf", "application/pdf", "http://mock.com/7890/test.pdf", 100*1024*1024, 0, 0, 0, nil) - - stickerWEBP := test.NewMockMedia("test.webp", "image/webp", "http://mock.com/8901/test.webp", 50*1024, 480, 480, 0, nil) - - mb.MockMedia(imageJPG) - mb.MockMedia(audioMP3) - mb.MockMedia(videoMP4) - mb.MockMedia(videoMOV) - mb.MockMedia(filePDF) - mb.MockMedia(stickerWEBP) -} - -func TestOutgoing(t *testing.T) { - // shorter max msg length for testing - maxMsgLength = 100 - - var ChannelWAC = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "D3C", "12345_ID", "", map[string]any{ - "auth_token": "the-auth-token", - "base_url": "https://waba-v2.360dialog.io", - }) - checkRedacted := []string{"the-auth-token"} - - RunOutgoingTestCases(t, ChannelWAC, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), SendTestCasesD3C, checkRedacted, setupMedia) -} -func TestGetSupportedLanguage(t *testing.T) { - assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.NilLocale)) - assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.Locale("eng"))) - assert.Equal(t, languageInfo{"en_US", "Menu"}, getSupportedLanguage(courier.Locale("eng-US"))) - assert.Equal(t, languageInfo{"pt_PT", "Menu"}, getSupportedLanguage(courier.Locale("por"))) - assert.Equal(t, languageInfo{"pt_PT", "Menu"}, getSupportedLanguage(courier.Locale("por-PT"))) - assert.Equal(t, languageInfo{"pt_BR", "Menu"}, getSupportedLanguage(courier.Locale("por-BR"))) - assert.Equal(t, languageInfo{"fil", "Menu"}, getSupportedLanguage(courier.Locale("fil"))) - assert.Equal(t, languageInfo{"fr", "Menu"}, getSupportedLanguage(courier.Locale("fra-CA"))) - assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.Locale("run"))) -} diff --git a/handlers/facebookapp/facebookapp.go b/handlers/facebookapp/facebookapp.go deleted file mode 100644 index 1db3b06b9..000000000 --- a/handlers/facebookapp/facebookapp.go +++ /dev/null @@ -1,1697 +0,0 @@ -package facebookapp - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/buger/jsonparser" - "github.com/nyaruka/courier" - "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/utils" - "github.com/nyaruka/gocommon/urns" - "github.com/pkg/errors" -) - -// Endpoints we hit -var ( - sendURL = "https://graph.facebook.com/v12.0/me/messages" - graphURL = "https://graph.facebook.com/v12.0/" - - signatureHeader = "X-Hub-Signature-256" - - maxRequestBodyBytes int64 = 1024 * 1024 - - // max for the body - maxMsgLength = 1000 - - // Sticker ID substitutions - stickerIDToEmoji = map[int64]string{ - 369239263222822: "👍", // small - 369239343222814: "👍", // medium - 369239383222810: "👍", // big - } - - tagByTopic = map[string]string{ - "event": "CONFIRMED_EVENT_UPDATE", - "purchase": "POST_PURCHASE_UPDATE", - "account": "ACCOUNT_UPDATE", - "agent": "HUMAN_AGENT", - } -) - -// keys for extra in channel events -const ( - referrerIDKey = "referrer_id" - sourceKey = "source" - adIDKey = "ad_id" - typeKey = "type" - titleKey = "title" - payloadKey = "payload" -) - -var waStatusMapping = map[string]courier.MsgStatus{ - "sent": courier.MsgStatusSent, - "delivered": courier.MsgStatusDelivered, - "read": courier.MsgStatusDelivered, - "failed": courier.MsgStatusFailed, -} - -var waIgnoreStatuses = map[string]bool{ - "deleted": true, -} - -// see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types -var wacMediaSupport = map[handlers.MediaType]handlers.MediaTypeSupport{ - handlers.MediaType("image/webp"): {Types: []string{"image/webp"}, MaxBytes: 100 * 1024, MaxWidth: 512, MaxHeight: 512}, - handlers.MediaTypeImage: {Types: []string{"image/jpeg", "image/png"}, MaxBytes: 5 * 1024 * 1024}, - handlers.MediaTypeAudio: {Types: []string{"audio/aac", "audio/mp4", "audio/mpeg", "audio/amr", "audio/ogg"}, MaxBytes: 16 * 1024 * 1024}, - handlers.MediaTypeVideo: {Types: []string{"video/mp4", "video/3gp"}, MaxBytes: 16 * 1024 * 1024}, - handlers.MediaTypeApplication: {MaxBytes: 100 * 1024 * 1024}, -} - -func newHandler(channelType courier.ChannelType, name string, useUUIDRoutes bool) courier.ChannelHandler { - return &handler{handlers.NewBaseHandlerWithParams(channelType, name, useUUIDRoutes, []string{courier.ConfigAuthToken})} -} - -func init() { - courier.RegisterHandler(newHandler("IG", "Instagram", false)) - courier.RegisterHandler(newHandler("FBA", "Facebook", false)) - courier.RegisterHandler(newHandler("WAC", "WhatsApp Cloud", false)) - -} - -type handler struct { - handlers.BaseHandler -} - -// Initialize is called by the engine once everything is loaded -func (h *handler) Initialize(s courier.Server) error { - h.SetServer(s) - s.AddHandlerRoute(h, http.MethodGet, "receive", courier.ChannelLogTypeWebhookVerify, h.receiveVerify) - s.AddHandlerRoute(h, http.MethodPost, "receive", courier.ChannelLogTypeMultiReceive, handlers.JSONPayload(h, h.receiveEvents)) - return nil -} - -type Sender struct { - ID string `json:"id"` - UserRef string `json:"user_ref,omitempty"` -} - -type User struct { - ID string `json:"id"` -} - -// { -// "object":"page", -// "entry":[{ -// "id":"180005062406476", -// "time":1514924367082, -// "messaging":[{ -// "sender": {"id":"1630934236957797"}, -// "recipient":{"id":"180005062406476"}, -// "timestamp":1514924366807, -// "message":{ -// "mid":"mid.$cAAD5QiNHkz1m6cyj11guxokwkhi2", -// "seq":33116, -// "text":"65863634" -// } -// }] -// }] -// } - -type wacMedia struct { - Caption string `json:"caption"` - Filename string `json:"filename"` - ID string `json:"id"` - Mimetype string `json:"mime_type"` - SHA256 string `json:"sha256"` -} - -type wacSticker struct { - Animated bool `json:"animated"` - 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"` - 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 *wacMedia `json:"image"` - Audio *wacMedia `json:"audio"` - Video *wacMedia `json:"video"` - Document *wacMedia `json:"document"` - Voice *wacMedia `json:"voice"` - Sticker *wacSticker `json:"sticker"` - 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"` - Interactive struct { - Type string `json:"type"` - ButtonReply struct { - ID string `json:"id"` - Title string `json:"title"` - } `json:"button_reply,omitempty"` - ListReply struct { - ID string `json:"id"` - Title string `json:"title"` - } `json:"list_reply,omitempty"` - } `json:"interactive,omitempty"` - Errors []struct { - Code int `json:"code"` - Title string `json:"title"` - } `json:"errors"` - } `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"` - } `json:"conversation"` - Pricing *struct { - PricingModel string `json:"pricing_model"` - Billable bool `json:"billable"` - Category string `json:"category"` - } `json:"pricing"` - Errors []struct { - Code int `json:"code"` - Title string `json:"title"` - } `json:"errors"` - } `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"` - Timestamp int64 `json:"timestamp"` - - OptIn *struct { - Ref string `json:"ref"` - UserRef string `json:"user_ref"` - } `json:"optin"` - - Referral *struct { - Ref string `json:"ref"` - Source string `json:"source"` - Type string `json:"type"` - AdID string `json:"ad_id"` - } `json:"referral"` - - Postback *struct { - MID string `json:"mid"` - Title string `json:"title"` - Payload string `json:"payload"` - Referral struct { - Ref string `json:"ref"` - Source string `json:"source"` - Type string `json:"type"` - AdID string `json:"ad_id"` - } `json:"referral"` - } `json:"postback"` - - Message *struct { - IsEcho bool `json:"is_echo"` - MID string `json:"mid"` - Text string `json:"text"` - IsDeleted bool `json:"is_deleted"` - Attachments []struct { - Type string `json:"type"` - Payload *struct { - URL string `json:"url"` - StickerID int64 `json:"sticker_id"` - Coordinates *struct { - Lat float64 `json:"lat"` - Long float64 `json:"long"` - } `json:"coordinates"` - } - } `json:"attachments"` - } `json:"message"` - - Delivery *struct { - MIDs []string `json:"mids"` - Watermark int64 `json:"watermark"` - } `json:"delivery"` - } `json:"messaging"` - } `json:"entry"` -} - -func (h *handler) RedactValues(ch courier.Channel) []string { - vals := h.BaseHandler.RedactValues(ch) - vals = append(vals, h.Server().Config().FacebookApplicationSecret, h.Server().Config().FacebookWebhookSecret, h.Server().Config().WhatsappAdminSystemUserToken) - return vals -} - -// WriteRequestError writes the passed in error to our response writer -func (h *handler) WriteRequestError(ctx context.Context, w http.ResponseWriter, err error) error { - return courier.WriteError(w, http.StatusOK, err) -} - -// GetChannel returns the channel -func (h *handler) GetChannel(ctx context.Context, r *http.Request) (courier.Channel, error) { - if r.Method == http.MethodGet { - return nil, nil - } - - payload := &moPayload{} - err := handlers.DecodeAndValidateJSON(payload, r) - if err != nil { - return nil, err - } - - // is not a 'page' and 'instagram' object? ignore it - 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 - if len(payload.Entry) == 0 { - return nil, fmt.Errorf("no entries found") - } - - var channelAddress string - - //if object is 'page' returns type FBA, if object is 'instagram' returns type IG - if payload.Object == "page" { - 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 { - if len(payload.Entry[0].Changes) == 0 { - return nil, fmt.Errorf("no changes found") - } - - channelAddress = payload.Entry[0].Changes[0].Value.Metadata.PhoneNumberID - if channelAddress == "" { - return nil, fmt.Errorf("no channel address found") - } - return h.Backend().GetChannelByAddress(ctx, courier.ChannelType("WAC"), courier.ChannelAddress(channelAddress)) - } -} - -// receiveVerify handles Facebook's webhook verification callback -func (h *handler) receiveVerify(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, error) { - mode := r.URL.Query().Get("hub.mode") - - // this isn't a subscribe verification, that's an error - if mode != "subscribe" { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("unknown request")) - } - - // verify the token against our server facebook webhook secret, if the same return the challenge FB sent us - secret := r.URL.Query().Get("hub.verify_token") - if secret != h.Server().Config().FacebookWebhookSecret { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("token does not match secret")) - } - // and respond with the challenge token - _, err := fmt.Fprint(w, r.URL.Query().Get("hub.challenge")) - return nil, err -} - -func resolveMediaURL(mediaID string, token string, clog *courier.ChannelLog) (string, error) { - if token == "" { - return "", fmt.Errorf("missing token for WA channel") - } - - base, _ := url.Parse(graphURL) - path, _ := url.Parse(fmt.Sprintf("/%s", mediaID)) - retrieveURL := base.ResolveReference(path) - - // set the access token as the authorization header - req, _ := http.NewRequest(http.MethodGet, retrieveURL.String(), nil) - //req.Header.Set("User-Agent", utils.HTTPUserAgent) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - - resp, respBody, err := handlers.RequestHTTP(req, clog) - if err != nil || resp.StatusCode/100 != 2 { - return "", errors.New("error resolving media URL") - } - - mediaURL, err := jsonparser.GetString(respBody, "url") - return mediaURL, err -} - -// receiveEvents is our HTTP handler function for incoming messages and status updates -func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, payload *moPayload, clog *courier.ChannelLog) ([]courier.Event, error) { - err := h.validateSignature(r) - if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) - } - - // is not a 'page' and 'instagram' object? ignore it - if payload.Object != "page" && payload.Object != "instagram" && payload.Object != "whatsapp_business_account" { - return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request") - } - - // no entries? ignore this request - if len(payload.Entry) == 0 { - return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request, no entries") - } - - var events []courier.Event - var data []any - - if channel.ChannelType() == "FBA" || channel.ChannelType() == "IG" { - events, data, err = h.processFacebookInstagramPayload(ctx, channel, payload, w, r, clog) - } else { - events, data, err = h.processCloudWhatsAppPayload(ctx, channel, payload, w, r, clog) - - } - - if err != nil { - return nil, err - } - - return events, courier.WriteDataResponse(w, http.StatusOK, "Events Handled", data) -} - -func (h *handler) processCloudWhatsAppPayload(ctx context.Context, channel courier.Channel, payload *moPayload, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, []any, 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([]any, 0, 2) - - token := h.Server().Config().WhatsappAdminSystemUserToken - - seenMsgIDs := make(map[string]bool, 2) - 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 { - if seenMsgIDs[msg.ID] { - continue - } - - // 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) - } - - for _, msgError := range msg.Errors { - clog.Error(courier.ErrorExternal(strconv.Itoa(msgError.Code), msgError.Title)) - } - - 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(msg.Audio.ID, token, clog) - } else if msg.Type == "voice" && msg.Voice != nil { - text = msg.Voice.Caption - mediaURL, err = resolveMediaURL(msg.Voice.ID, token, clog) - } 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(msg.Document.ID, token, clog) - } else if msg.Type == "image" && msg.Image != nil { - text = msg.Image.Caption - mediaURL, err = resolveMediaURL(msg.Image.ID, token, clog) - } else if msg.Type == "sticker" && msg.Sticker != nil { - mediaURL, err = resolveMediaURL(msg.Sticker.ID, token, clog) - } else if msg.Type == "video" && msg.Video != nil { - text = msg.Video.Caption - mediaURL, err = resolveMediaURL(msg.Video.ID, token, clog) - } else if msg.Type == "location" && msg.Location != nil { - mediaURL = fmt.Sprintf("geo:%f,%f", msg.Location.Latitude, msg.Location.Longitude) - } else if msg.Type == "interactive" && msg.Interactive.Type == "button_reply" { - text = msg.Interactive.ButtonReply.Title - } else if msg.Type == "interactive" && msg.Interactive.Type == "list_reply" { - text = msg.Interactive.ListReply.Title - } else { - // we received a message type we do not support. - courier.LogRequestError(r, channel, fmt.Errorf("unsupported message type %s", msg.Type)) - continue - } - - // create our message - event := h.Backend().NewIncomingMsg(channel, urn, text, msg.ID, clog).WithReceivedOn(date).WithContactName(contactNames[msg.From]) - - // 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, clog) - if err != nil { - return nil, nil, err - } - - events = append(events, event) - data = append(data, courier.NewMsgReceiveData(event)) - seenMsgIDs[msg.ID] = true - } - - 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 - } - - for _, statusError := range status.Errors { - clog.Error(courier.ErrorExternal(strconv.Itoa(statusError.Code), statusError.Title)) - } - - event := h.Backend().NewStatusUpdateByExternalID(channel, status.ID, msgStatus, clog) - err := h.Backend().WriteStatusUpdate(ctx, event) - if err != nil { - return nil, nil, err - } - - events = append(events, event) - data = append(data, courier.NewStatusData(event)) - - } - - for _, chError := range change.Value.Errors { - clog.Error(courier.ErrorExternal(strconv.Itoa(chError.Code), chError.Title)) - } - - } - - } - return events, data, nil -} - -func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel courier.Channel, payload *moPayload, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, []any, error) { - var err 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([]any, 0, 2) - - seenMsgIDs := make(map[string]bool, 2) - - // for each entry - for _, entry := range payload.Entry { - // no entry, ignore - if len(entry.Messaging) == 0 { - continue - } - - // grab our message, there is always a single one - msg := entry.Messaging[0] - - // ignore this entry if it is to another page - if channel.Address() != msg.Recipient.ID { - continue - } - - // create our date from the timestamp (they give us millis, arg is nanos) - date := time.Unix(0, msg.Timestamp*1000000).UTC() - - sender := msg.Sender.UserRef - if sender == "" { - sender = msg.Sender.ID - } - - var urn urns.URN - - // create our URN - if payload.Object == "instagram" { - urn, err = urns.NewInstagramURN(sender) - if err != nil { - return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) - } - } else { - urn, err = urns.NewFacebookURN(sender) - if err != nil { - return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) - } - } - - if msg.OptIn != nil { - // this is an opt in, if we have a user_ref, use that as our URN (this is a checkbox plugin) - // TODO: - // We need to deal with the case of them responding and remapping the user_ref in that case: - // https://developers.facebook.com/docs/messenger-platform/discovery/checkbox-plugin - // Right now that we even support this isn't documented and I don't think anybody uses it, so leaving that out. - // (things will still work, we just will have dupe contacts, one with user_ref for the first contact, then with the real id when they reply) - if msg.OptIn.UserRef != "" { - urn, err = urns.NewFacebookURN(urns.FacebookRefPrefix + msg.OptIn.UserRef) - if err != nil { - return nil, nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) - } - } - - event := h.Backend().NewChannelEvent(channel, courier.Referral, urn, clog).WithOccurredOn(date) - - // build our extra - extra := map[string]any{ - referrerIDKey: msg.OptIn.Ref, - } - event = event.WithExtra(extra) - - err := h.Backend().WriteChannelEvent(ctx, event, clog) - if err != nil { - return nil, nil, err - } - - events = append(events, event) - data = append(data, courier.NewEventReceiveData(event)) - - } else if msg.Postback != nil { - // by default postbacks are treated as new conversations, unless we have referral information - eventType := courier.NewConversation - if msg.Postback.Referral.Ref != "" { - eventType = courier.Referral - } - event := h.Backend().NewChannelEvent(channel, eventType, urn, clog).WithOccurredOn(date) - - // build our extra - extra := map[string]any{ - titleKey: msg.Postback.Title, - payloadKey: msg.Postback.Payload, - } - - // add in referral information if we have it - if eventType == courier.Referral { - extra[referrerIDKey] = msg.Postback.Referral.Ref - extra[sourceKey] = msg.Postback.Referral.Source - extra[typeKey] = msg.Postback.Referral.Type - - if msg.Postback.Referral.AdID != "" { - extra[adIDKey] = msg.Postback.Referral.AdID - } - } - - event = event.WithExtra(extra) - - err := h.Backend().WriteChannelEvent(ctx, event, clog) - if err != nil { - return nil, nil, err - } - - events = append(events, event) - data = append(data, courier.NewEventReceiveData(event)) - - } else if msg.Referral != nil { - // this is an incoming referral - event := h.Backend().NewChannelEvent(channel, courier.Referral, urn, clog).WithOccurredOn(date) - - // build our extra - extra := map[string]any{ - sourceKey: msg.Referral.Source, - typeKey: msg.Referral.Type, - } - - // add referrer id if present - if msg.Referral.Ref != "" { - extra[referrerIDKey] = msg.Referral.Ref - } - - // add ad id if present - if msg.Referral.AdID != "" { - extra[adIDKey] = msg.Referral.AdID - } - event = event.WithExtra(extra) - - err := h.Backend().WriteChannelEvent(ctx, event, clog) - if err != nil { - return nil, nil, err - } - - events = append(events, event) - data = append(data, courier.NewEventReceiveData(event)) - - } else if msg.Message != nil { - // this is an incoming message - if seenMsgIDs[msg.Message.MID] { - continue - } - - // ignore echos - if msg.Message.IsEcho { - data = append(data, courier.NewInfoData("ignoring echo")) - continue - } - - if msg.Message.IsDeleted { - h.Backend().DeleteMsgByExternalID(ctx, channel, msg.Message.MID) - data = append(data, courier.NewInfoData("msg deleted")) - continue - } - - has_story_mentions := false - - text := msg.Message.Text - - attachmentURLs := make([]string, 0, 2) - - // if we have a sticker ID, use that as our text - for _, att := range msg.Message.Attachments { - if att.Type == "image" && att.Payload != nil && att.Payload.StickerID != 0 { - text = stickerIDToEmoji[att.Payload.StickerID] - } - - if att.Type == "location" { - attachmentURLs = append(attachmentURLs, fmt.Sprintf("geo:%f,%f", att.Payload.Coordinates.Lat, att.Payload.Coordinates.Long)) - } - - if att.Type == "story_mention" { - data = append(data, courier.NewInfoData("ignoring story_mention")) - has_story_mentions = true - continue - } - - if att.Payload != nil && att.Payload.URL != "" { - attachmentURLs = append(attachmentURLs, att.Payload.URL) - } - - } - - // if we have a story mention, skip and do not save any message - if has_story_mentions { - continue - } - - // create our message - event := h.Backend().NewIncomingMsg(channel, urn, text, msg.Message.MID, clog).WithReceivedOn(date) - - // add any attachment URL found - for _, attURL := range attachmentURLs { - event.WithAttachment(attURL) - } - - err := h.Backend().WriteMsg(ctx, event, clog) - if err != nil { - return nil, nil, err - } - - events = append(events, event) - data = append(data, courier.NewMsgReceiveData(event)) - seenMsgIDs[msg.Message.MID] = true - - } else if msg.Delivery != nil { - // this is a delivery report - for _, mid := range msg.Delivery.MIDs { - event := h.Backend().NewStatusUpdateByExternalID(channel, mid, courier.MsgStatusDelivered, clog) - err := h.Backend().WriteStatusUpdate(ctx, event) - if err != nil { - return nil, nil, err - } - - events = append(events, event) - data = append(data, courier.NewStatusData(event)) - } - - } else { - data = append(data, courier.NewInfoData("ignoring unknown entry type")) - } - } - - return events, data, nil -} - -// { -// "messaging_type": "" -// "recipient": { -// "id":"" -// }, -// "message": { -// "text":"hello, world!" -// "attachment":{ -// "type":"image", -// "payload":{ -// "url":"http://www.messenger-rocks.com/image.jpg", -// "is_reusable":true -// } -// } -// } -// } -type mtPayload struct { - MessagingType string `json:"messaging_type"` - Tag string `json:"tag,omitempty"` - Recipient struct { - UserRef string `json:"user_ref,omitempty"` - ID string `json:"id,omitempty"` - } `json:"recipient"` - Message struct { - Text string `json:"text,omitempty"` - QuickReplies []mtQuickReply `json:"quick_replies,omitempty"` - Attachment *mtAttachment `json:"attachment,omitempty"` - } `json:"message"` -} - -type mtAttachment struct { - Type string `json:"type"` - Payload struct { - URL string `json:"url"` - IsReusable bool `json:"is_reusable"` - } `json:"payload"` -} - -type mtQuickReply struct { - Title string `json:"title"` - Payload string `json:"payload"` - ContentType string `json:"content_type"` -} - -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.StatusUpdate, error) { - if msg.Channel().ChannelType() == "FBA" || msg.Channel().ChannelType() == "IG" { - return h.sendFacebookInstagramMsg(ctx, msg, clog) - } else if msg.Channel().ChannelType() == "WAC" { - return h.sendCloudAPIWhatsappMsg(ctx, msg, clog) - } - - return nil, fmt.Errorf("unssuported channel type") -} - -type fbaMTResponse struct { - ExternalID string `json:"message_id"` - RecipientID string `json:"recipient_id"` - Error struct { - Message string `json:"message"` - Code int `json:"code"` - } `json:"error"` -} - -func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.StatusUpdate, error) { - // can't do anything without an access token - accessToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") - if accessToken == "" { - return nil, fmt.Errorf("missing access token") - } - - isHuman := msg.Origin() == courier.MsgOriginChat || msg.Origin() == courier.MsgOriginTicket - payload := mtPayload{} - - if msg.Topic() != "" || isHuman { - payload.MessagingType = "MESSAGE_TAG" - - if msg.Topic() != "" { - payload.Tag = tagByTopic[msg.Topic()] - } else if isHuman { - // this will most likely fail if we're out of the 7 day window.. but user was warned and we try anyway - payload.Tag = "HUMAN_AGENT" - } - } else { - if msg.ResponseToExternalID() != "" { - payload.MessagingType = "RESPONSE" - } else { - payload.MessagingType = "UPDATE" - } - } - - // build our recipient - if msg.URN().IsFacebookRef() { - payload.Recipient.UserRef = msg.URN().FacebookRef() - } else { - payload.Recipient.ID = msg.URN().Path() - } - - msgURL, _ := url.Parse(sendURL) - query := url.Values{} - query.Set("access_token", accessToken) - msgURL.RawQuery = query.Encode() - - status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) - - msgParts := make([]string, 0) - if msg.Text() != "" { - msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) - } - - // send each part and each attachment separately. we send attachments first as otherwise quick replies - // attached to text messages get hidden when images get delivered - for i := 0; i < len(msgParts)+len(msg.Attachments()); i++ { - if i < len(msg.Attachments()) { - // this is an attachment - payload.Message.Attachment = &mtAttachment{} - attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) - attType = strings.Split(attType, "/")[0] - if attType == "application" { - attType = "file" - } - payload.Message.Attachment.Type = attType - payload.Message.Attachment.Payload.URL = attURL - payload.Message.Attachment.Payload.IsReusable = true - payload.Message.Text = "" - } else { - // this is still a msg part - payload.Message.Text = msgParts[i-len(msg.Attachments())] - payload.Message.Attachment = nil - } - - // include any quick replies on the last piece we send - if i == (len(msgParts)+len(msg.Attachments()))-1 { - for _, qr := range msg.QuickReplies() { - payload.Message.QuickReplies = append(payload.Message.QuickReplies, mtQuickReply{qr, qr, "text"}) - } - } else { - payload.Message.QuickReplies = nil - } - - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, err - } - - req, err := http.NewRequest(http.MethodPost, msgURL.String(), bytes.NewReader(jsonBody)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - _, respBody, _ := handlers.RequestHTTP(req, clog) - respPayload := &fbaMTResponse{} - err = json.Unmarshal(respBody, respPayload) - if err != nil { - clog.Error(courier.ErrorResponseUnparseable("JSON")) - return status, nil - } - - if respPayload.Error.Code != 0 { - clog.Error(courier.ErrorExternal(strconv.Itoa(respPayload.Error.Code), respPayload.Error.Message)) - return status, nil - } - - if respPayload.ExternalID == "" { - clog.Error(courier.ErrorResponseValueMissing("message_id")) - return status, nil - } - - // if this is our first message, record the external id - if i == 0 { - status.SetExternalID(respPayload.ExternalID) - if msg.URN().IsFacebookRef() { - recipientID := respPayload.RecipientID - if recipientID == "" { - clog.Error(courier.ErrorResponseValueMissing("recipient_id")) - return status, nil - } - - referralID := msg.URN().FacebookRef() - - realIDURN, err := urns.NewFacebookURN(recipientID) - if err != nil { - clog.RawError(errors.Errorf("unable to make facebook urn from %s", recipientID)) - } - - contact, err := h.Backend().GetContact(ctx, msg.Channel(), msg.URN(), "", "", clog) - if err != nil { - clog.RawError(errors.Errorf("unable to get contact for %s", msg.URN().String())) - } - realURN, err := h.Backend().AddURNtoContact(ctx, msg.Channel(), contact, realIDURN) - if err != nil { - clog.RawError(errors.Errorf("unable to add real facebook URN %s to contact with uuid %s", realURN.String(), contact.UUID())) - } - referralIDExtURN, err := urns.NewURNFromParts(urns.ExternalScheme, referralID, "", "") - if err != nil { - clog.RawError(errors.Errorf("unable to make ext urn from %s", referralID)) - } - extURN, err := h.Backend().AddURNtoContact(ctx, msg.Channel(), contact, referralIDExtURN) - if err != nil { - clog.RawError(errors.Errorf("unable to add URN %s to contact with uuid %s", extURN.String(), contact.UUID())) - } - - referralFacebookURN, err := h.Backend().RemoveURNfromContact(ctx, msg.Channel(), contact, msg.URN()) - if err != nil { - clog.RawError(errors.Errorf("unable to remove referral facebook URN %s from contact with uuid %s", referralFacebookURN.String(), contact.UUID())) - } - - } - - } - - // this was wired successfully - status.SetStatus(courier.MsgStatusWired) - } - - return status, nil -} - -type wacMTMedia struct { - ID string `json:"id,omitempty"` - Link string `json:"link,omitempty"` - Caption string `json:"caption,omitempty"` - Filename string `json:"filename,omitempty"` -} - -type wacMTSection struct { - Title string `json:"title,omitempty"` - Rows []wacMTSectionRow `json:"rows" validate:"required"` -} - -type wacMTSectionRow struct { - ID string `json:"id" validate:"required"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` -} - -type wacMTButton 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 wacParam struct { - Type string `json:"type"` - Text string `json:"text"` -} - -type wacComponent struct { - Type string `json:"type"` - SubType string `json:"sub_type"` - Index string `json:"index"` - Params []*wacParam `json:"parameters"` -} - -type wacText struct { - Body string `json:"body"` - PreviewURL bool `json:"preview_url"` -} - -type wacLanguage struct { - Policy string `json:"policy"` - Code string `json:"code"` -} - -type wacTemplate struct { - Name string `json:"name"` - Language *wacLanguage `json:"language"` - Components []*wacComponent `json:"components"` -} - -type wacInteractive struct { - Type string `json:"type"` - Header *struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - Video *wacMTMedia `json:"video,omitempty"` - Image *wacMTMedia `json:"image,omitempty"` - Document *wacMTMedia `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 []wacMTSection `json:"sections,omitempty"` - Buttons []wacMTButton `json:"buttons,omitempty"` - } `json:"action,omitempty"` -} - -type wacMTPayload struct { - MessagingProduct string `json:"messaging_product"` - RecipientType string `json:"recipient_type"` - To string `json:"to"` - Type string `json:"type"` - - Text *wacText `json:"text,omitempty"` - - Document *wacMTMedia `json:"document,omitempty"` - Image *wacMTMedia `json:"image,omitempty"` - Audio *wacMTMedia `json:"audio,omitempty"` - Video *wacMTMedia `json:"video,omitempty"` - Sticker *wacMTMedia `json:"sticker,omitempty"` - - Interactive *wacInteractive `json:"interactive,omitempty"` - - Template *wacTemplate `json:"template,omitempty"` -} - -type wacMTResponse struct { - Messages []*struct { - ID string `json:"id"` - } `json:"messages"` - Error struct { - Message string `json:"message"` - Code int `json:"code"` - } `json:"error"` -} - -func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.StatusUpdate, error) { - // can't do anything without an access token - accessToken := h.Server().Config().WhatsappAdminSystemUserToken - - base, _ := url.Parse(graphURL) - path, _ := url.Parse(fmt.Sprintf("/%s/messages", msg.Channel().Address())) - wacPhoneURL := base.ResolveReference(path) - - status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) - - hasCaption := false - - msgParts := make([]string, 0) - if msg.Text() != "" { - msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) - } - qrs := msg.QuickReplies() - lang := getSupportedLanguage(msg.Locale()) - - attachments, err := handlers.ResolveAttachments(ctx, h.Backend(), msg.Attachments(), wacMediaSupport, false) - if err != nil { - return nil, errors.Wrap(err, "error resolving attachments") - } - - var payloadAudio wacMTPayload - - for i := 0; i < len(msgParts)+len(attachments); i++ { - payload := wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path()} - - if len(attachments) == 0 { - // do we have a template? - templating, err := h.getTemplating(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 := wacTemplate{Name: templating.Template.Name, Language: &wacLanguage{Policy: "deterministic", Code: lang.code}} - payload.Template = &template - - component := &wacComponent{Type: "body"} - - for _, v := range templating.Variables { - component.Params = append(component.Params, &wacParam{Type: "text", Text: v}) - } - template.Components = append(payload.Template.Components, component) - - } else { - if i < (len(msgParts) + len(attachments) - 1) { - // this is still a msg part - text := &wacText{PreviewURL: false} - payload.Type = "text" - if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { - text.PreviewURL = true - } - text.Body = msgParts[i-len(attachments)] - payload.Text = text - } else { - if len(qrs) > 0 { - payload.Type = "interactive" - // We can use buttons - if len(qrs) <= 3 { - interactive := wacInteractive{Type: "button", Body: struct { - Text string "json:\"text\"" - }{Text: msgParts[i-len(attachments)]}} - - btns := make([]wacMTButton, len(qrs)) - for i, qr := range qrs { - btns[i] = wacMTButton{ - Type: "reply", - } - btns[i].Reply.ID = fmt.Sprint(i) - btns[i].Reply.Title = qr - } - interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" - }{Buttons: btns} - payload.Interactive = &interactive - } else if len(qrs) <= 10 { - interactive := wacInteractive{Type: "list", Body: struct { - Text string "json:\"text\"" - }{Text: msgParts[i-len(attachments)]}} - - section := wacMTSection{ - Rows: make([]wacMTSectionRow, len(qrs)), - } - for i, qr := range qrs { - section.Rows[i] = wacMTSectionRow{ - ID: fmt.Sprint(i), - Title: qr, - } - } - - interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" - }{Button: lang.menu, Sections: []wacMTSection{ - section, - }} - - payload.Interactive = &interactive - } else { - return nil, fmt.Errorf("too many quick replies WAC supports only up to 10 quick replies") - } - } else { - // this is still a msg part - text := &wacText{PreviewURL: false} - payload.Type = "text" - if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { - text.PreviewURL = true - } - text.Body = msgParts[i-len(attachments)] - payload.Text = text - } - } - } - - } else if i < len(attachments) && (len(qrs) == 0 || len(qrs) > 3) { - attURL := attachments[i].Media.URL() - attType := string(attachments[i].Type) - attContentType := attachments[i].Media.ContentType() - - if attType == "application" { - attType = "document" - } - payload.Type = attType - media := wacMTMedia{Link: attURL} - - if len(msgParts) == 1 && attType != "audio" && len(attachments) == 1 && len(msg.QuickReplies()) == 0 { - media.Caption = msgParts[i] - hasCaption = true - } - - if attType == "image" { - if attContentType == "image/webp" { - payload.Type = "sticker" - payload.Sticker = &media - } else { - payload.Image = &media - } - } else if attType == "audio" { - payload.Audio = &media - } else if attType == "video" { - payload.Video = &media - } else if attType == "document" { - filename, err := utils.BasePathForURL(attURL) - if err != nil { - filename = "" - } - if filename != "" { - media.Filename = filename - } - payload.Document = &media - } - } else { - if len(qrs) > 0 { - payload.Type = "interactive" - // We can use buttons - if len(qrs) <= 3 { - interactive := wacInteractive{Type: "button", Body: struct { - Text string "json:\"text\"" - }{Text: msgParts[i]}} - - if len(msg.Attachments()) > 0 { - hasCaption = true - attURL := attachments[i].Media.URL() - attType := string(attachments[i].Type) - if attType == "application" { - attType = "document" - } - if attType == "image" { - image := wacMTMedia{ - Link: attURL, - } - interactive.Header = &struct { - Type string "json:\"type\"" - Text string "json:\"text,omitempty\"" - Video *wacMTMedia "json:\"video,omitempty\"" - Image *wacMTMedia "json:\"image,omitempty\"" - Document *wacMTMedia "json:\"document,omitempty\"" - }{Type: "image", Image: &image} - } else if attType == "video" { - video := wacMTMedia{ - Link: attURL, - } - interactive.Header = &struct { - Type string "json:\"type\"" - Text string "json:\"text,omitempty\"" - Video *wacMTMedia "json:\"video,omitempty\"" - Image *wacMTMedia "json:\"image,omitempty\"" - Document *wacMTMedia "json:\"document,omitempty\"" - }{Type: "video", Video: &video} - } else if attType == "document" { - filename, err := utils.BasePathForURL(attURL) - if err != nil { - return nil, err - } - document := wacMTMedia{ - Link: attURL, - Filename: filename, - } - interactive.Header = &struct { - Type string "json:\"type\"" - Text string "json:\"text,omitempty\"" - Video *wacMTMedia "json:\"video,omitempty\"" - Image *wacMTMedia "json:\"image,omitempty\"" - Document *wacMTMedia "json:\"document,omitempty\"" - }{Type: "document", Document: &document} - } else if attType == "audio" { - var zeroIndex bool - if i == 0 { - zeroIndex = true - } - payloadAudio = wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path(), Type: "audio", Audio: &wacMTMedia{Link: attURL}} - status, err := requestWAC(payloadAudio, accessToken, status, wacPhoneURL, zeroIndex, clog) - if err != nil { - return status, nil - } - } else { - interactive.Type = "button" - interactive.Body.Text = msgParts[i] - } - } - - btns := make([]wacMTButton, len(qrs)) - for i, qr := range qrs { - btns[i] = wacMTButton{ - Type: "reply", - } - btns[i].Reply.ID = fmt.Sprint(i) - btns[i].Reply.Title = qr - } - interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" - }{Buttons: btns} - payload.Interactive = &interactive - - } else if len(qrs) <= 10 { - interactive := wacInteractive{Type: "list", Body: struct { - Text string "json:\"text\"" - }{Text: msgParts[i-len(attachments)]}} - - section := wacMTSection{ - Rows: make([]wacMTSectionRow, len(qrs)), - } - for i, qr := range qrs { - section.Rows[i] = wacMTSectionRow{ - ID: fmt.Sprint(i), - Title: qr, - } - } - - interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" - }{Button: lang.menu, Sections: []wacMTSection{ - section, - }} - - payload.Interactive = &interactive - } else { - return nil, fmt.Errorf("too many quick replies WAC supports only up to 10 quick replies") - } - } else { - // this is still a msg part - text := &wacText{PreviewURL: false} - payload.Type = "text" - if strings.Contains(msgParts[i-len(attachments)], "https://") || strings.Contains(msgParts[i-len(attachments)], "http://") { - text.PreviewURL = true - } - text.Body = msgParts[i-len(attachments)] - payload.Text = text - } - } - - var zeroIndex bool - if i == 0 { - zeroIndex = true - } - - status, err := requestWAC(payload, accessToken, status, wacPhoneURL, zeroIndex, clog) - if err != nil { - return status, err - } - - if hasCaption { - break - } - } - return status, nil -} - -func requestWAC(payload wacMTPayload, accessToken string, status courier.StatusUpdate, wacPhoneURL *url.URL, zeroIndex bool, clog *courier.ChannelLog) (courier.StatusUpdate, error) { - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, err - } - - req, err := http.NewRequest(http.MethodPost, wacPhoneURL.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") - - _, respBody, _ := handlers.RequestHTTP(req, clog) - respPayload := &wacMTResponse{} - err = json.Unmarshal(respBody, respPayload) - if err != nil { - clog.Error(courier.ErrorResponseUnparseable("JSON")) - return status, nil - } - - if respPayload.Error.Code != 0 { - clog.Error(courier.ErrorExternal(strconv.Itoa(respPayload.Error.Code), respPayload.Error.Message)) - return status, nil - } - - externalID := respPayload.Messages[0].ID - if zeroIndex && externalID != "" { - status.SetExternalID(externalID) - } - // this was wired successfully - status.SetStatus(courier.MsgStatusWired) - return status, nil -} - -// DescribeURN looks up URN metadata for new contacts -func (h *handler) DescribeURN(ctx context.Context, channel courier.Channel, urn urns.URN, clog *courier.ChannelLog) (map[string]string, error) { - if channel.ChannelType() == "WAC" { - return map[string]string{}, nil - } - - // can't do anything with facebook refs, ignore them - if urn.IsFacebookRef() { - return map[string]string{}, nil - } - - accessToken := channel.StringConfigForKey(courier.ConfigAuthToken, "") - if accessToken == "" { - return nil, fmt.Errorf("missing access token") - } - - // build a request to lookup the stats for this contact - base, _ := url.Parse(graphURL) - path, _ := url.Parse(fmt.Sprintf("/%s", urn.Path())) - u := base.ResolveReference(path) - query := url.Values{} - var name string - - if fmt.Sprint(channel.ChannelType()) == "FBA" { - query.Set("fields", "first_name,last_name") - } - - query.Set("access_token", accessToken) - u.RawQuery = query.Encode() - req, _ := http.NewRequest(http.MethodGet, u.String(), nil) - - resp, respBody, err := handlers.RequestHTTP(req, clog) - if err != nil || resp.StatusCode/100 != 2 { - return nil, errors.New("unable to look up contact data") - } - - // read our first and last name or complete name - if fmt.Sprint(channel.ChannelType()) == "FBA" { - firstName, _ := jsonparser.GetString(respBody, "first_name") - lastName, _ := jsonparser.GetString(respBody, "last_name") - name = utils.JoinNonEmpty(" ", firstName, lastName) - } else { - name, _ = jsonparser.GetString(respBody, "name") - } - - return map[string]string{"name": name}, nil - -} - -// see https://developers.facebook.com/docs/messenger-platform/webhook#security -func (h *handler) validateSignature(r *http.Request) error { - headerSignature := r.Header.Get(signatureHeader) - if headerSignature == "" { - return fmt.Errorf("missing request signature") - } - appSecret := h.Server().Config().FacebookApplicationSecret - - body, err := handlers.ReadBody(r, maxRequestBodyBytes) - if err != nil { - return fmt.Errorf("unable to read request body: %s", err) - } - - expectedSignature, err := fbCalculateSignature(appSecret, body) - if err != nil { - return err - } - - signature := "" - if len(headerSignature) == 71 && strings.HasPrefix(headerSignature, "sha256=") { - signature = strings.TrimPrefix(headerSignature, "sha256=") - } - - // compare signatures in way that isn't sensitive to a timing attack - if !hmac.Equal([]byte(expectedSignature), []byte(signature)) { - return fmt.Errorf("invalid request signature, expected: %s got: %s for body: '%s'", expectedSignature, signature, string(body)) - } - - return nil -} - -func fbCalculateSignature(appSecret string, body []byte) (string, error) { - var buffer bytes.Buffer - buffer.Write(body) - - // hash with SHA1 - mac := hmac.New(sha256.New, []byte(appSecret)) - mac.Write(buffer.Bytes()) - - return hex.EncodeToString(mac.Sum(nil)), nil -} - -func (h *handler) getTemplating(msg courier.Msg) (*MsgTemplating, error) { - if len(msg.Metadata()) == 0 { - return nil, nil - } - - metadata := &struct { - Templating *MsgTemplating `json:"templating"` - }{} - if err := json.Unmarshal(msg.Metadata(), metadata); err != nil { - return nil, err - } - - if metadata.Templating == nil { - return nil, nil - } - - if err := utils.Validate(metadata.Templating); err != nil { - return nil, errors.Wrapf(err, "invalid templating definition") - } - - return metadata.Templating, nil -} - -// BuildAttachmentRequest to download media for message attachment with Bearer token set -func (h *handler) BuildAttachmentRequest(ctx context.Context, b courier.Backend, channel courier.Channel, attachmentURL string, clog *courier.ChannelLog) (*http.Request, error) { - token := h.Server().Config().WhatsappAdminSystemUserToken - if token == "" { - return nil, fmt.Errorf("missing token for WAC channel") - } - - req, _ := http.NewRequest(http.MethodGet, attachmentURL, nil) - - // set the access token as the authorization header for WAC - if channel.ChannelType() == "WAC" { - req.Header.Set("Authorization", "Bearer "+token) - } - return req, nil -} - -var _ courier.AttachmentRequestBuilder = (*handler)(nil) - -type MsgTemplating struct { - Template struct { - Name string `json:"name" validate:"required"` - UUID string `json:"uuid" validate:"required"` - } `json:"template" validate:"required,dive"` - Namespace string `json:"namespace"` - Variables []string `json:"variables"` -} - -func getSupportedLanguage(lc courier.Locale) languageInfo { - // look for exact match - if lang := supportedLanguages[lc]; lang.code != "" { - return lang - } - - // if we have a country, strip that off and look again for a match - l, c := lc.ToParts() - if c != "" { - if lang := supportedLanguages[courier.Locale(l)]; lang.code != "" { - return lang - } - } - return supportedLanguages["eng"] // fallback to English -} - -type languageInfo struct { - code string - menu string // translation of "Menu" -} - -// Mapping from engine locales to supported languages. Note that these are not all valid BCP47 codes, e.g. fil -// see https://developers.facebook.com/docs/whatsapp/api/messages/message-templates/ -var supportedLanguages = map[courier.Locale]languageInfo{ - "afr": {code: "af", menu: "Kieslys"}, // Afrikaans - "sqi": {code: "sq", menu: "Menu"}, // Albanian - "ara": {code: "ar", menu: "قائمة"}, // Arabic - "aze": {code: "az", menu: "Menu"}, // Azerbaijani - "ben": {code: "bn", menu: "Menu"}, // Bengali - "bul": {code: "bg", menu: "Menu"}, // Bulgarian - "cat": {code: "ca", menu: "Menu"}, // Catalan - "zho": {code: "zh_CN", menu: "菜单"}, // Chinese - "zho-CN": {code: "zh_CN", menu: "菜单"}, // Chinese (CHN) - "zho-HK": {code: "zh_HK", menu: "菜单"}, // Chinese (HKG) - "zho-TW": {code: "zh_TW", menu: "菜单"}, // Chinese (TAI) - "hrv": {code: "hr", menu: "Menu"}, // Croatian - "ces": {code: "cs", menu: "Menu"}, // Czech - "dah": {code: "da", menu: "Menu"}, // Danish - "nld": {code: "nl", menu: "Menu"}, // Dutch - "eng": {code: "en", menu: "Menu"}, // English - "eng-GB": {code: "en_GB", menu: "Menu"}, // English (UK) - "eng-US": {code: "en_US", menu: "Menu"}, // English (US) - "est": {code: "et", menu: "Menu"}, // Estonian - "fil": {code: "fil", menu: "Menu"}, // Filipino - "fin": {code: "fi", menu: "Menu"}, // Finnish - "fra": {code: "fr", menu: "Menu"}, // French - "kat": {code: "ka", menu: "Menu"}, // Georgian - "deu": {code: "de", menu: "Menü"}, // German - "ell": {code: "el", menu: "Menu"}, // Greek - "guj": {code: "gu", menu: "Menu"}, // Gujarati - "hau": {code: "ha", menu: "Menu"}, // Hausa - "enb": {code: "he", menu: "תפריט"}, // Hebrew - "hin": {code: "hi", menu: "Menu"}, // Hindi - "hun": {code: "hu", menu: "Menu"}, // Hungarian - "ind": {code: "id", menu: "Menu"}, // Indonesian - "gle": {code: "ga", menu: "Roghchlár"}, // Irish - "ita": {code: "it", menu: "Menu"}, // Italian - "jpn": {code: "ja", menu: "Menu"}, // Japanese - "kan": {code: "kn", menu: "Menu"}, // Kannada - "kaz": {code: "kk", menu: "Menu"}, // Kazakh - "kin": {code: "rw_RW", menu: "Menu"}, // Kinyarwanda - "kor": {code: "ko", menu: "Menu"}, // Korean - "kir": {code: "ky_KG", menu: "Menu"}, // Kyrgyzstan - "lao": {code: "lo", menu: "Menu"}, // Lao - "lav": {code: "lv", menu: "Menu"}, // Latvian - "lit": {code: "lt", menu: "Menu"}, // Lithuanian - "mal": {code: "ml", menu: "Menu"}, // Malayalam - "mkd": {code: "mk", menu: "Menu"}, // Macedonian - "msa": {code: "ms", menu: "Menu"}, // Malay - "mar": {code: "mr", menu: "Menu"}, // Marathi - "nob": {code: "nb", menu: "Menu"}, // Norwegian - "fas": {code: "fa", menu: "Menu"}, // Persian - "pol": {code: "pl", menu: "Menu"}, // Polish - "por": {code: "pt_PT", menu: "Menu"}, // Portuguese - "por-BR": {code: "pt_BR", menu: "Menu"}, // Portuguese (BR) - "por-PT": {code: "pt_PT", menu: "Menu"}, // Portuguese (POR) - "pan": {code: "pa", menu: "Menu"}, // Punjabi - "ron": {code: "ro", menu: "Menu"}, // Romanian - "rus": {code: "ru", menu: "Menu"}, // Russian - "srp": {code: "sr", menu: "Menu"}, // Serbian - "slk": {code: "sk", menu: "Menu"}, // Slovak - "slv": {code: "sl", menu: "Menu"}, // Slovenian - "spa": {code: "es", menu: "Menú"}, // Spanish - "spa-AR": {code: "es_AR", menu: "Menú"}, // Spanish (ARG) - "spa-ES": {code: "es_ES", menu: "Menú"}, // Spanish (SPA) - "spa-MX": {code: "es_MX", menu: "Menú"}, // Spanish (MEX) - "swa": {code: "sw", menu: "Menyu"}, // Swahili - "swe": {code: "sv", menu: "Menu"}, // Swedish - "tam": {code: "ta", menu: "Menu"}, // Tamil - "tel": {code: "te", menu: "Menu"}, // Telugu - "tha": {code: "th", menu: "Menu"}, // Thai - "tur": {code: "tr", menu: "Menu"}, // Turkish - "ukr": {code: "uk", menu: "Menu"}, // Ukrainian - "urd": {code: "ur", menu: "Menu"}, // Urdu - "uzb": {code: "uz", menu: "Menu"}, // Uzbek - "vie": {code: "vi", menu: "Menu"}, // Vietnamese - "zul": {code: "zu", menu: "Menu"}, // Zulu -} diff --git a/handlers/facebookapp/facebookapp_test.go b/handlers/facebookapp/facebookapp_test.go deleted file mode 100644 index 5f63b6cec..000000000 --- a/handlers/facebookapp/facebookapp_test.go +++ /dev/null @@ -1,1673 +0,0 @@ -package facebookapp - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/nyaruka/courier" - . "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/test" - "github.com/nyaruka/gocommon/httpx" - "github.com/nyaruka/gocommon/urns" - "github.com/stretchr/testify/assert" -) - -var testChannelsFBA = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "FBA", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}), -} - -var testChannelsIG = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "IG", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}), -} - -var testChannelsWAC = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "WAC", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}), -} - -var testCasesFBA = []IncomingTestCase{ - { - Label: "Receive Message FBA", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/helloMsgFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Hello World"), - ExpectedURN: "facebook:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Invalid Signature", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/helloMsgFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "invalid request signature", - PrepRequest: addInvalidSignature, - }, - { - Label: "No Duplicate Receive Message", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/duplicateMsgFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp("Hello World"), - ExpectedURN: "facebook:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Attachment", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/attachmentFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp(""), - ExpectedAttachments: []string{"https://image-url/foo.png"}, - ExpectedURN: "facebook:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Location", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/locationAttachment.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp(""), - ExpectedAttachments: []string{"geo:1.200000,-1.300000"}, - ExpectedURN: "facebook:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Thumbs Up", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/thumbsUp.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp("👍"), - ExpectedURN: "facebook:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive OptIn UserRef", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/optInUserRef.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedURN: "facebook:ref:optin_user_ref", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - ExpectedEvent: courier.Referral, - ExpectedEventExtra: map[string]any{"referrer_id": "optin_ref"}, - PrepRequest: addValidSignature, - }, - { - Label: "Receive OptIn", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/optIn.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedURN: "facebook:5678", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - ExpectedEvent: courier.Referral, - ExpectedEventExtra: map[string]any{"referrer_id": "optin_ref"}, - PrepRequest: addValidSignature, - }, - { - Label: "Receive Get Started", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/postbackGetStarted.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedURN: "facebook:5678", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - ExpectedEvent: courier.NewConversation, - ExpectedEventExtra: map[string]any{"title": "postback title", "payload": "get_started"}, - PrepRequest: addValidSignature, - }, - { - Label: "Receive Referral Postback", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/postback.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedURN: "facebook:5678", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - ExpectedEvent: courier.Referral, - ExpectedEventExtra: map[string]any{"title": "postback title", "payload": "postback payload", "referrer_id": "postback ref", "source": "postback source", "type": "postback type"}, - PrepRequest: addValidSignature, - }, - { - Label: "Receive Referral", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/postbackReferral.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedURN: "facebook:5678", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - ExpectedEvent: courier.Referral, - ExpectedEventExtra: map[string]any{"title": "postback title", "payload": "get_started", "referrer_id": "postback ref", "source": "postback source", "type": "postback type", "ad_id": "ad id"}, - PrepRequest: addValidSignature, - }, - { - Label: "Receive Referral", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/referral.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"referrer_id":"referral id"`, - ExpectedURN: "facebook:5678", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - ExpectedEvent: courier.Referral, - ExpectedEventExtra: map[string]any{"referrer_id": "referral id", "source": "referral source", "type": "referral type", "ad_id": "ad id"}, - PrepRequest: addValidSignature, - }, - { - Label: "Receive DLR", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/dlr.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgStatus: courier.MsgStatusDelivered, - ExpectedExternalID: "mid.1458668856218:ed81099e15d3f4f233", - PrepRequest: addValidSignature, - }, - { - Label: "Different Page", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/differentPageFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"data":[]`, - PrepRequest: addValidSignature, - }, - { - Label: "Echo", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/echoFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `ignoring echo`, - PrepRequest: addValidSignature, - }, - { - Label: "Not Page", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/notPage.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "object expected 'page', 'instagram' or 'whatsapp_business_account', found notpage", - NoLogsExpected: true, - PrepRequest: addValidSignature, - }, - { - Label: "No Entries", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/noEntriesFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "no entries found", - NoLogsExpected: true, - PrepRequest: addValidSignature, - }, - { - Label: "No Messaging Entries", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/noMessagingEntriesFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - PrepRequest: addValidSignature, - }, - { - Label: "Unknown Messaging Entry", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/unknownMessagingEntryFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - PrepRequest: addValidSignature, - }, - { - Label: "Not JSON", - URL: "/c/fba/receive", - Data: "not JSON", - ExpectedRespStatus: 200, - ExpectedBodyContains: "unable to parse request JSON", - NoLogsExpected: true, - PrepRequest: addValidSignature, - }, - { - Label: "Invalid URN", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/invalidURNFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "invalid facebook id", - PrepRequest: addValidSignature, - }, -} - -var testCasesIG = []IncomingTestCase{ - { - Label: "Receive Message", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/helloMsgIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Hello World"), - ExpectedURN: "instagram:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Invalid Signature", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/helloMsgIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "invalid request signature", - PrepRequest: addInvalidSignature, - }, - { - Label: "No Duplicate Receive Message", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/duplicateMsgIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp("Hello World"), - ExpectedURN: "instagram:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Attachment", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/attachmentIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp(""), - ExpectedAttachments: []string{"https://image-url/foo.png"}, - ExpectedURN: "instagram:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Like Heart", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/like_heart.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp(""), - ExpectedURN: "instagram:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Icebreaker Get Started", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/icebreakerGetStarted.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedURN: "instagram:5678", - ExpectedDate: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), - ExpectedEvent: courier.NewConversation, - ExpectedEventExtra: map[string]any{"title": "icebreaker question", "payload": "get_started"}, - PrepRequest: addValidSignature, - }, - { - Label: "Different Page", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/differentPageIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"data":[]`, - PrepRequest: addValidSignature, - }, - { - Label: "Echo", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/echoIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `ignoring echo`, - PrepRequest: addValidSignature, - }, - { - Label: "No Entries", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/noEntriesIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "no entries found", - NoLogsExpected: true, - PrepRequest: addValidSignature, - }, - { - Label: "Not Instagram", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/notInstagram.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "object expected 'page', 'instagram' or 'whatsapp_business_account', found notinstagram", - NoLogsExpected: true, - PrepRequest: addValidSignature, - }, - { - Label: "No Messaging Entries", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/noMessagingEntriesIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - PrepRequest: addValidSignature, - }, - { - Label: "Unknown Messaging Entry", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/unknownMessagingEntryIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - PrepRequest: addValidSignature, - }, - { - Label: "Not JSON", - URL: "/c/ig/receive", - Data: "not JSON", - ExpectedRespStatus: 200, - ExpectedBodyContains: "unable to parse request JSON", - NoLogsExpected: true, - PrepRequest: addValidSignature, - }, - { - Label: "Invalid URN", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/invalidURNIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "invalid instagram id", - PrepRequest: addValidSignature, - }, - { - Label: "Story Mention", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/storyMentionIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `ignoring story_mention`, - PrepRequest: addValidSignature, - }, - { - Label: "Message unsent", - URL: "/c/ig/receive", - Data: string(test.ReadFile("./testdata/ig/unsentMsgIG.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `msg deleted`, - PrepRequest: addValidSignature, - }, -} - -func addValidSignature(r *http.Request) { - body, _ := ReadBody(r, maxRequestBodyBytes) - sig, _ := fbCalculateSignature("fb_app_secret", body) - r.Header.Set(signatureHeader, fmt.Sprintf("sha256=%s", string(sig))) -} - -func addInvalidSignature(r *http.Request) { - r.Header.Set(signatureHeader, "invalidsig") -} - -// mocks the call to the Facebook graph API -func buildMockFBGraphFBA(testCases []IncomingTestCase) *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - accessToken := r.URL.Query().Get("access_token") - defer r.Body.Close() - - // invalid auth token - if accessToken != "a123" { - http.Error(w, "invalid auth token", 403) - } - - // user has a name - if strings.HasSuffix(r.URL.Path, "1337") { - w.Write([]byte(`{ "first_name": "John", "last_name": "Doe"}`)) - return - } - // no name - w.Write([]byte(`{ "first_name": "", "last_name": ""}`)) - })) - graphURL = server.URL - - return server -} - -// mocks the call to the Facebook graph API -func buildMockFBGraphIG(testCases []IncomingTestCase) *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - accessToken := r.URL.Query().Get("access_token") - defer r.Body.Close() - - // invalid auth token - if accessToken != "a123" { - http.Error(w, "invalid auth token", 403) - } - - // user has a name - if strings.HasSuffix(r.URL.Path, "1337") { - w.Write([]byte(`{ "name": "John Doe"}`)) - return - } - - // no name - w.Write([]byte(`{ "name": ""}`)) - })) - graphURL = server.URL - - return server -} - -func TestDescribeURNForFBA(t *testing.T) { - fbGraph := buildMockFBGraphFBA(testCasesFBA) - defer fbGraph.Close() - - channel := testChannelsFBA[0] - handler := newHandler("FBA", "Facebook", false) - handler.Initialize(newServer(nil)) - clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, channel, handler.RedactValues(channel)) - - tcs := []struct { - urn urns.URN - expectedMetadata map[string]string - }{ - {"facebook:1337", map[string]string{"name": "John Doe"}}, - {"facebook:4567", map[string]string{"name": ""}}, - {"facebook:ref:1337", map[string]string{}}, - } - - for _, tc := range tcs { - metadata, _ := handler.(courier.URNDescriber).DescribeURN(context.Background(), channel, tc.urn, clog) - assert.Equal(t, metadata, tc.expectedMetadata) - } - - AssertChannelLogRedaction(t, clog, []string{"a123", "wac_admin_system_user_token"}) -} - -func TestDescribeURNForIG(t *testing.T) { - fbGraph := buildMockFBGraphIG(testCasesIG) - defer fbGraph.Close() - - channel := testChannelsIG[0] - handler := newHandler("IG", "Instagram", false) - handler.Initialize(newServer(nil)) - clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, channel, handler.RedactValues(channel)) - - tcs := []struct { - urn urns.URN - expectedMetadata map[string]string - }{ - {"instagram:1337", map[string]string{"name": "John Doe"}}, - {"instagram:4567", map[string]string{"name": ""}}, - } - - for _, tc := range tcs { - metadata, _ := handler.(courier.URNDescriber).DescribeURN(context.Background(), channel, tc.urn, clog) - assert.Equal(t, metadata, tc.expectedMetadata) - } - - AssertChannelLogRedaction(t, clog, []string{"a123", "wac_admin_system_user_token"}) -} - -func TestDescribeURNForWAC(t *testing.T) { - channel := testChannelsWAC[0] - handler := newHandler("WAC", "Cloud API WhatsApp", false) - handler.Initialize(newServer(nil)) - clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, channel, handler.RedactValues(channel)) - - tcs := []struct { - urn urns.URN - expectedMetadata map[string]string - }{ - {"whatsapp:1337", map[string]string{}}, - {"whatsapp:4567", map[string]string{}}, - } - - for _, tc := range tcs { - metadata, _ := handler.(courier.URNDescriber).DescribeURN(context.Background(), testChannelsWAC[0], tc.urn, clog) - assert.Equal(t, metadata, tc.expectedMetadata) - } - - AssertChannelLogRedaction(t, clog, []string{"a123", "wac_admin_system_user_token"}) -} - -var wacReceiveURL = "/c/wac/receive" - -var testCasesWAC = []IncomingTestCase{ - { - Label: "Receive Message WAC", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/helloWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Hello World"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Duplicate Valid Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/duplicateWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Hello World"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Voice Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/voiceWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp(""), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Voice"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Button Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/buttonWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("No"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Document Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/documentWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("80skaraokesonglistartist"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Document"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Image Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/imageWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Check out my new phone!"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Image"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Sticker Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/stickerWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp(""), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Sticker"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Video Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/videoWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Check out my new phone!"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Video"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Audio Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/audioWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Check out my new phone!"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Audio"}, - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Location Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/locationWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"type":"msg"`, - ExpectedMsgText: Sp(""), - ExpectedAttachments: []string{"geo:0.000000,1.000000"}, - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Invalid JSON", - URL: wacReceiveURL, - Data: "not json", - ExpectedRespStatus: 200, - ExpectedBodyContains: "unable to parse", - NoLogsExpected: true, - PrepRequest: addValidSignature, - }, - { - Label: "Receive Invalid From", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/invalidFrom.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "invalid whatsapp id", - PrepRequest: addValidSignature, - }, - { - Label: "Receive Invalid Timestamp", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/invalidTimestamp.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "invalid timestamp", - PrepRequest: addValidSignature, - }, - { - Label: "Receive Message WAC invalid signature", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/helloWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "invalid request signature", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - PrepRequest: addInvalidSignature, - }, - { - Label: "Receive Message WAC with error message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/errorMsg.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("131051", "Unsupported message type")}, - NoInvalidChannelCheck: true, - PrepRequest: addValidSignature, - }, - { - Label: "Receive error message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/errorErrors.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("0", "We were unable to authenticate the app user")}, - NoInvalidChannelCheck: true, - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Status", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/validStatusWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"type":"status"`, - ExpectedMsgStatus: "S", - ExpectedExternalID: "external_id", - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Status with error message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/errorStatus.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"type":"status"`, - ExpectedMsgStatus: "F", - ExpectedExternalID: "external_id", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("131014", "Request for url https://URL.jpg failed with error: 404 (Not Found)")}, - PrepRequest: addValidSignature, - }, - { - Label: "Receive Invalid Status", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/invalidStatusWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"unknown status: in_orbit"`, - PrepRequest: addValidSignature, - }, - { - Label: "Receive Ignore Status", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/ignoreStatusWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: `"ignoring status: deleted"`, - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Interactive Button Reply Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/buttonReplyWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Yes"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, - { - Label: "Receive Valid Interactive List Reply Message", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/listReplyWAC.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - ExpectedMsgText: Sp("Yes"), - ExpectedURN: "whatsapp:5678", - ExpectedExternalID: "external_id", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), - PrepRequest: addValidSignature, - }, -} - -func TestIncoming(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" && accessToken != "Bearer wac_admin_system_user_token" { - fmt.Printf("Access token: %s\n", accessToken) - http.Error(w, "invalid auth token", http.StatusForbidden) - 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 - } - - if strings.HasSuffix(r.URL.Path, "sticker") { - w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Sticker"}`)) - return - } - - // valid token - w.Write([]byte(`{"url": "https://foo.bar/attachmentURL"}`)) - - })) - graphURL = server.URL - - RunIncomingTestCases(t, testChannelsWAC, newHandler("WAC", "Cloud API WhatsApp", false), testCasesWAC) - RunIncomingTestCases(t, testChannelsFBA, newHandler("FBA", "Facebook", false), testCasesFBA) - RunIncomingTestCases(t, testChannelsIG, newHandler("IG", "Instagram", false), testCasesIG) -} - -func BenchmarkHandler(b *testing.B) { - fbService := buildMockFBGraphFBA(testCasesFBA) - - RunChannelBenchmarks(b, testChannelsFBA, newHandler("FBA", "Facebook", false), testCasesFBA) - fbService.Close() - - fbServiceIG := buildMockFBGraphIG(testCasesIG) - - RunChannelBenchmarks(b, testChannelsIG, newHandler("IG", "Instagram", false), testCasesIG) - fbServiceIG.Close() -} - -func TestVerify(t *testing.T) { - RunIncomingTestCases(t, testChannelsFBA, newHandler("FBA", "Facebook", false), []IncomingTestCase{ - { - Label: "Valid Secret", - URL: "/c/fba/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", - ExpectedRespStatus: 200, - ExpectedBodyContains: "yarchallenge", - NoLogsExpected: true, - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - }, - { - Label: "Verify No Mode", - URL: "/c/fba/receive", - ExpectedRespStatus: 200, - ExpectedBodyContains: "unknown request", - NoLogsExpected: true, - }, - { - Label: "Verify No Secret", - URL: "/c/fba/receive?hub.mode=subscribe", - ExpectedRespStatus: 200, - ExpectedBodyContains: "token does not match secret", - NoLogsExpected: true, - }, - { - Label: "Invalid Secret", - URL: "/c/fba/receive?hub.mode=subscribe&hub.verify_token=blah", - ExpectedRespStatus: 200, - ExpectedBodyContains: "token does not match secret", - NoLogsExpected: true, - }, - { - Label: "Valid Secret", - URL: "/c/fba/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", - ExpectedRespStatus: 200, - ExpectedBodyContains: "yarchallenge", - NoLogsExpected: true, - }, - }) - - RunIncomingTestCases(t, testChannelsIG, newHandler("IG", "Instagram", false), []IncomingTestCase{ - { - Label: "Valid Secret", - URL: "/c/ig/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", - ExpectedRespStatus: 200, - ExpectedBodyContains: "yarchallenge", - NoLogsExpected: true, - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - }, - { - Label: "Verify No Mode", - URL: "/c/ig/receive", - ExpectedRespStatus: 200, - ExpectedBodyContains: "unknown request", - NoLogsExpected: true, - }, - { - Label: "Verify No Secret", - URL: "/c/ig/receive?hub.mode=subscribe", - ExpectedRespStatus: 200, - ExpectedBodyContains: "token does not match secret", - NoLogsExpected: true, - }, - { - Label: "Invalid Secret", - URL: "/c/ig/receive?hub.mode=subscribe&hub.verify_token=blah", - ExpectedRespStatus: 200, - ExpectedBodyContains: "token does not match secret", - NoLogsExpected: true, - }, - { - Label: "Valid Secret", - URL: "/c/ig/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", - ExpectedRespStatus: 200, - ExpectedBodyContains: "yarchallenge", - NoLogsExpected: true, - }, - }) -} - -// 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 = []OutgoingTestCase{ - { - Label: "Text only chat message", - MsgText: "Simple Message", - MsgURN: "facebook:12345", - MsgOrigin: courier.MsgOriginChat, - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"HUMAN_AGENT","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Text only broadcast message", - MsgText: "Simple Message", - MsgURN: "facebook:12345", - MsgOrigin: courier.MsgOriginBroadcast, - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Text only flow response", - MsgText: "Simple Message", - MsgURN: "facebook:12345", - MsgOrigin: courier.MsgOriginFlow, - MsgResponseToExternalID: "23526", - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"RESPONSE","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Text only flow response using referal URN", - MsgText: "Simple Message", - MsgURN: "facebook:ref:67890", - MsgOrigin: courier.MsgOriginFlow, - MsgResponseToExternalID: "23526", - MockResponseBody: `{"message_id": "mid.133", "recipient_id": "12345"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"RESPONSE","recipient":{"user_ref":"67890"},"message":{"text":"Simple Message"}}`, - ExpectedContactURNs: map[string]bool{"facebook:12345": true, "ext:67890": true, "facebook:ref:67890": false}, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Quick replies on a broadcast message", - MsgText: "Are you happy?", - MsgURN: "facebook:12345", - MsgOrigin: courier.MsgOriginBroadcast, - MsgQuickReplies: []string{"Yes", "No"}, - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"text":"Are you happy?","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Message that exceeds max text length", - MsgText: "This is a long message which spans more than one part, what will actually be sent in the end if we exceed the max length?", - MsgURN: "facebook:12345", - MsgQuickReplies: []string{"Yes", "No"}, - MsgTopic: "account", - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"ACCOUNT_UPDATE","recipient":{"id":"12345"},"message":{"text":"we exceed the max length?","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Image attachment", - MsgURN: "facebook:12345", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"attachment":{"type":"image","payload":{"url":"https://foo.bar/image.jpg","is_reusable":true}}}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Text, image attachment, quick replies and explicit message topic", - MsgText: "This is some text.", - MsgURN: "facebook:12345", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, - MsgQuickReplies: []string{"Yes", "No"}, - MsgTopic: "event", - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"CONFIRMED_EVENT_UPDATE","recipient":{"id":"12345"},"message":{"text":"This is some text.","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Document attachment", - MsgURN: "facebook:12345", - MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"attachment":{"type":"file","payload":{"url":"https://foo.bar/document.pdf","is_reusable":true}}}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Response doesn't contain message id", - MsgText: "ID Error", - MsgURN: "facebook:12345", - MockResponseBody: `{ "is_error": true }`, - MockResponseStatus: 200, - ExpectedMsgStatus: "E", - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("message_id")}, - SendPrep: setSendURL, - }, - { - Label: "Response status code is non-200", - MsgText: "Error", - MsgURN: "facebook:12345", - MockResponseBody: `{ "is_error": true }`, - MockResponseStatus: 403, - ExpectedMsgStatus: "E", - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("message_id")}, - SendPrep: setSendURL, - }, - { - Label: "Response is invalid JSON", - MsgText: "Error", - MsgURN: "facebook:12345", - MockResponseBody: `bad json`, - MockResponseStatus: 200, - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseUnparseable("JSON")}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, - { - Label: "Response is channel specific error", - MsgText: "Error", - MsgURN: "facebook:12345", - MockResponseBody: `{ "error": {"message": "The image size is too large.","code": 36000 }}`, - MockResponseStatus: 400, - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("36000", "The image size is too large.")}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, -} - -var SendTestCasesIG = []OutgoingTestCase{ - { - Label: "Text only chat message", - MsgText: "Simple Message", - MsgURN: "instagram:12345", - MsgOrigin: courier.MsgOriginChat, - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"HUMAN_AGENT","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Text only broadcast message", - MsgText: "Simple Message", - MsgURN: "instagram:12345", - MsgOrigin: courier.MsgOriginBroadcast, - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Text only flow response", - MsgText: "Simple Message", - MsgURN: "instagram:12345", - MsgOrigin: courier.MsgOriginFlow, - MsgResponseToExternalID: "23526", - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"RESPONSE","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Quick replies on a broadcast message", - MsgText: "Are you happy?", - MsgURN: "instagram:12345", - MsgOrigin: courier.MsgOriginBroadcast, - MsgQuickReplies: []string{"Yes", "No"}, - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"text":"Are you happy?","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Message that exceeds max text length", - MsgText: "This is a long message which spans more than one part, what will actually be sent in the end if we exceed the max length?", - MsgURN: "instagram:12345", - MsgQuickReplies: []string{"Yes", "No"}, - MsgTopic: "account", - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"ACCOUNT_UPDATE","recipient":{"id":"12345"},"message":{"text":"we exceed the max length?","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Image attachment", - MsgURN: "instagram:12345", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"attachment":{"type":"image","payload":{"url":"https://foo.bar/image.jpg","is_reusable":true}}}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Text, image attachment, quick replies and explicit message topic", - MsgText: "This is some text.", - MsgURN: "instagram:12345", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, - MsgQuickReplies: []string{"Yes", "No"}, - MsgTopic: "event", - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"CONFIRMED_EVENT_UPDATE","recipient":{"id":"12345"},"message":{"text":"This is some text.","quick_replies":[{"title":"Yes","payload":"Yes","content_type":"text"},{"title":"No","payload":"No","content_type":"text"}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Explicit human agent tag", - MsgText: "Simple Message", - MsgURN: "instagram:12345", - MsgTopic: "agent", - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"MESSAGE_TAG","tag":"HUMAN_AGENT","recipient":{"id":"12345"},"message":{"text":"Simple Message"}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Document attachment", - MsgURN: "instagram:12345", - MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, - MockResponseBody: `{"message_id": "mid.133"}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"attachment":{"type":"file","payload":{"url":"https://foo.bar/document.pdf","is_reusable":true}}}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "mid.133", - SendPrep: setSendURL, - }, - { - Label: "Response doesn't contain message id", - MsgText: "ID Error", - MsgURN: "instagram:12345", - MockResponseBody: `{ "is_error": true }`, - MockResponseStatus: 200, - ExpectedMsgStatus: "E", - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("message_id")}, - SendPrep: setSendURL, - }, - { - Label: "Response status code is non-200", - MsgText: "Error", - MsgURN: "instagram:12345", - MockResponseBody: `{ "is_error": true }`, - MockResponseStatus: 403, - ExpectedMsgStatus: "E", - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("message_id")}, - SendPrep: setSendURL, - }, - { - Label: "Response is invalid JSON", - MsgText: "Error", - MsgURN: "instagram:12345", - MockResponseBody: `bad json`, - MockResponseStatus: 200, - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseUnparseable("JSON")}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, - { - Label: "Response is channel specific error", - MsgText: "Error", - MsgURN: "instagram:12345", - MockResponseBody: `{ "error": {"message": "The image size is too large.","code": 36000 }}`, - MockResponseStatus: 400, - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("36000", "The image size is too large.")}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, -} - -var SendTestCasesWAC = []OutgoingTestCase{ - { - Label: "Plain Send", - MsgText: "Simple Message", - MsgURN: "whatsapp:250788123123", - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"Simple Message","preview_url":false}}`, - ExpectedRequestPath: "/12345_ID/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Unicode Send", - MsgText: "☺", - MsgURN: "whatsapp:250788123123", - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"☺","preview_url":false}}`, - ExpectedRequestPath: "/12345_ID/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Audio Send", - MsgText: "audio caption", - MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"audio/mpeg:http://mock.com/3456/test.mp3"}, - MockResponses: map[MockedRequest]*httpx.MockResponse{ - { - Method: "POST", - Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/3456/test.mp3"}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - { - Method: "POST", - Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"audio caption","preview_url":false}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - }, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Document Send", - MsgText: "document caption", - MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"application/pdf:http://mock.com/7890/test.pdf"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"http://mock.com/7890/test.pdf","caption":"document caption","filename":"test.pdf"}}`, - ExpectedRequestPath: "/12345_ID/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Image Send", - MsgText: "image caption", - MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/1234/test.jpg","caption":"image caption"}}`, - ExpectedRequestPath: "/12345_ID/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Sticker Send", - MsgText: "sticker caption", - MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"image/webp:http://mock.com/8901/test.webp"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"sticker","sticker":{"link":"http://mock.com/8901/test.webp","caption":"sticker caption"}}`, - ExpectedRequestPath: "/12345_ID/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Video Send", - MsgText: "video caption", - MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"video/mp4:http://mock.com/5678/test.mp4"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"http://mock.com/5678/test.mp4","caption":"video caption"}}`, - ExpectedRequestPath: "/12345_ID/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Template Send", - MsgText: "templated message", - MsgURN: "whatsapp:250788123123", - MsgLocale: "eng", - MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"]}}`), - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_product":"whatsapp","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", - MsgText: "templated message", - MsgURN: "whatsapp:250788123123", - MsgLocale: "eng-US", - MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"]}}`), - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Template Invalid Language", - MsgText: "templated message", - MsgURN: "whatsapp:250788123123", - MsgLocale: "bnt", - MsgMetadata: json.RawMessage(`{"templating": { "template": { "name": "revive_issue", "uuid": "8ca114b4-bee2-4d3b-aaf1-9aa6b48d41e8" }, "variables": ["Chef", "tomorrow"]}}`), - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive Button Message Send", - MsgText: "Interactive Button Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"BUTTON1"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive List Message Send", - MsgText: "Interactive List Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","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"}]}]}}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive List Message Send In Spanish", - MsgText: "Hola", - MsgURN: "whatsapp:250788123123", - MsgLocale: "spa", - MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"list","body":{"text":"Hola"},"action":{"button":"Menú","sections":[{"rows":[{"id":"0","title":"ROW1"},{"id":"1","title":"ROW2"},{"id":"2","title":"ROW3"},{"id":"3","title":"ROW4"}]}]}}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive Button Message Send with image attachment", - MsgText: "Interactive Button Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"http://mock.com/1234/test.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, - ExpectedRequestPath: "/12345_ID/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive Button Message Send with video attachment", - MsgText: "Interactive Button Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"video/mp4:http://mock.com/5678/test.mp4"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"http://mock.com/5678/test.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, - ExpectedRequestPath: "/12345_ID/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive Button Message Send with document attachment", - MsgText: "Interactive Button Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"document/pdf:http://mock.com/7890/test.pdf"}, - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"http://mock.com/7890/test.pdf","filename":"test.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, - ExpectedRequestPath: "/12345_ID/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive Button Message Send with audio attachment", - MsgText: "Interactive Button Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3"}, - MsgAttachments: []string{"audio/mp3:http://mock.com/3456/test.mp3"}, - MockResponses: map[MockedRequest]*httpx.MockResponse{ - { - Method: "POST", - Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/3456/test.mp3"}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - { - Method: "POST", - Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"ROW1"}},{"type":"reply","reply":{"id":"1","title":"ROW2"}},{"type":"reply","reply":{"id":"2","title":"ROW3"}}]}}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - }, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Interactive List Message Send with attachment", - MsgText: "Interactive List Msg", - MsgURN: "whatsapp:250788123123", - MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, - MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, - MockResponses: map[MockedRequest]*httpx.MockResponse{ - { - Method: "POST", - Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/1234/test.jpg"}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - { - Method: "POST", - Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","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"}]}]}}}`, - }: httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), - }, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Link Sending", - MsgText: "Link Sending https://link.com", - MsgURN: "whatsapp:250788123123", - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 201, - ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"Link Sending https://link.com","preview_url":true}}`, - ExpectedRequestPath: "/12345_ID/messages", - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, - { - Label: "Error Bad JSON", - MsgText: "Error", - MsgURN: "whatsapp:250788123123", - MockResponseBody: `bad json`, - MockResponseStatus: 403, - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseUnparseable("JSON")}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, - { - Label: "Error", - MsgText: "Error", - MsgURN: "whatsapp:250788123123", - MockResponseBody: `{ "error": {"message": "(#130429) Rate limit hit","code": 130429 }}`, - MockResponseStatus: 403, - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("130429", "(#130429) Rate limit hit")}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, -} - -// setupMedia takes care of having the media files needed to our test server host -func setupMedia(mb *test.MockBackend) { - imageJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/1234/test.jpg", 1024*1024, 640, 480, 0, nil) - - audioM4A := test.NewMockMedia("test.m4a", "audio/mp4", "http://mock.com/2345/test.m4a", 1024*1024, 0, 0, 200, nil) - audioMP3 := test.NewMockMedia("test.mp3", "audio/mpeg", "http://mock.com/3456/test.mp3", 1024*1024, 0, 0, 200, []courier.Media{audioM4A}) - - thumbJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/4567/test.jpg", 1024*1024, 640, 480, 0, nil) - videoMP4 := test.NewMockMedia("test.mp4", "video/mp4", "http://mock.com/5678/test.mp4", 1024*1024, 0, 0, 1000, []courier.Media{thumbJPG}) - - videoMOV := test.NewMockMedia("test.mov", "video/quicktime", "http://mock.com/6789/test.mov", 100*1024*1024, 0, 0, 2000, nil) - - filePDF := test.NewMockMedia("test.pdf", "application/pdf", "http://mock.com/7890/test.pdf", 100*1024*1024, 0, 0, 0, nil) - - stickerWEBP := test.NewMockMedia("test.webp", "image/webp", "http://mock.com/8901/test.webp", 50*1024, 480, 480, 0, nil) - - mb.MockMedia(imageJPG) - mb.MockMedia(audioMP3) - mb.MockMedia(videoMP4) - mb.MockMedia(videoMOV) - mb.MockMedia(filePDF) - mb.MockMedia(stickerWEBP) -} - -func TestOutgoing(t *testing.T) { - - // shorter max msg length for testing - maxMsgLength = 100 - - var ChannelFBA = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FBA", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}) - var ChannelIG = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "IG", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}) - var ChannelWAC = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "WAC", "12345_ID", "", map[string]any{courier.ConfigAuthToken: "a123"}) - - checkRedacted := []string{"wac_admin_system_user_token", "missing_facebook_app_secret", "missing_facebook_webhook_secret", "a123"} - - RunOutgoingTestCases(t, ChannelFBA, newHandler("FBA", "Facebook", false), SendTestCasesFBA, checkRedacted, nil) - RunOutgoingTestCases(t, ChannelIG, newHandler("IG", "Instagram", false), SendTestCasesIG, checkRedacted, nil) - RunOutgoingTestCases(t, ChannelWAC, newHandler("WAC", "Cloud API WhatsApp", false), SendTestCasesWAC, checkRedacted, setupMedia) -} - -func TestSigning(t *testing.T) { - tcs := []struct { - Body string - Signature string - }{ - { - "hello world", - "f39034b29165ec6a5104d9aef27266484ab26c8caa7bca8bcb2dd02e8be61b17", - }, - { - "hello world2", - "60905fdf409d0b4f721e99f6f25b31567a68a6b45e933d814e17a246be4c5a53", - }, - } - - for i, tc := range tcs { - sig, err := fbCalculateSignature("sesame", []byte(tc.Body)) - assert.NoError(t, err) - assert.Equal(t, tc.Signature, sig, "%d: mismatched signature", i) - } -} - -func newServer(backend courier.Backend) courier.Server { - config := courier.NewConfig() - config.WhatsappAdminSystemUserToken = "wac_admin_system_user_token" - return courier.NewServer(config, backend) -} - -func TestBuildAttachmentRequest(t *testing.T) { - mb := test.NewMockBackend() - s := newServer(mb) - wacHandler := &handler{NewBaseHandlerWithParams(courier.ChannelType("WAC"), "WhatsApp Cloud", false, nil)} - wacHandler.Initialize(s) - req, _ := wacHandler.BuildAttachmentRequest(context.Background(), mb, testChannelsWAC[0], "https://example.org/v1/media/41", nil) - assert.Equal(t, "https://example.org/v1/media/41", req.URL.String()) - assert.Equal(t, "Bearer wac_admin_system_user_token", req.Header.Get("Authorization")) - - fbaHandler := &handler{NewBaseHandlerWithParams(courier.ChannelType("FBA"), "Facebook", false, nil)} - fbaHandler.Initialize(s) - req, _ = fbaHandler.BuildAttachmentRequest(context.Background(), mb, testChannelsFBA[0], "https://example.org/v1/media/41", nil) - assert.Equal(t, "https://example.org/v1/media/41", req.URL.String()) - assert.Equal(t, http.Header{}, req.Header) - - igHandler := &handler{NewBaseHandlerWithParams(courier.ChannelType("IG"), "Instagram", false, nil)} - igHandler.Initialize(s) - req, _ = igHandler.BuildAttachmentRequest(context.Background(), mb, testChannelsFBA[0], "https://example.org/v1/media/41", nil) - assert.Equal(t, "https://example.org/v1/media/41", req.URL.String()) - assert.Equal(t, http.Header{}, req.Header) -} - -func TestGetSupportedLanguage(t *testing.T) { - assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.NilLocale)) - assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.Locale("eng"))) - assert.Equal(t, languageInfo{"en_US", "Menu"}, getSupportedLanguage(courier.Locale("eng-US"))) - assert.Equal(t, languageInfo{"pt_PT", "Menu"}, getSupportedLanguage(courier.Locale("por"))) - assert.Equal(t, languageInfo{"pt_PT", "Menu"}, getSupportedLanguage(courier.Locale("por-PT"))) - assert.Equal(t, languageInfo{"pt_BR", "Menu"}, getSupportedLanguage(courier.Locale("por-BR"))) - assert.Equal(t, languageInfo{"fil", "Menu"}, getSupportedLanguage(courier.Locale("fil"))) - assert.Equal(t, languageInfo{"fr", "Menu"}, getSupportedLanguage(courier.Locale("fra-CA"))) - assert.Equal(t, languageInfo{"en", "Menu"}, getSupportedLanguage(courier.Locale("run"))) -} diff --git a/handlers/facebookapp/testdata/wac/stickerWAC.json b/handlers/facebookapp/testdata/wac/stickerWAC.json deleted file mode 100644 index 481319f1e..000000000 --- a/handlers/facebookapp/testdata/wac/stickerWAC.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "object": "whatsapp_business_account", - "entry": [ - { - "id": "8856996819413533", - "changes": [ - { - "value": { - "messaging_product": "whatsapp", - "metadata": { - "display_phone_number": "+250 788 123 200", - "phone_number_id": "12345" - }, - "contacts": [ - { - "profile": { - "name": "Kerry Fisher" - }, - "wa_id": "5678" - } - ], - "messages": [ - { - "from": "5678", - "id": "external_id", - "sticker": { - "file": "/usr/local/wamedia/shared/b1cf38-8734-4ad3-b4a1-ef0c10d0d683", - "id": "id_sticker", - "mime_type": "image/webp", - "sha256": "29ed500fa64eb55fc19dc4124acb300e5dcc54a0f822a301ae99944db" - }, - "timestamp": "1454119029", - "type": "sticker" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file From 7d3c8544438a8e3d09c55fa14c80a20b9079be42 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 23 Apr 2024 13:37:47 +0200 Subject: [PATCH 7/8] Update tests --- handlers/dialog360/handler_test.go | 62 +++++++++++++++++++++--------- handlers/meta/whataspp_test.go | 62 +++++++++++++++++++++--------- 2 files changed, 86 insertions(+), 38 deletions(-) diff --git a/handlers/dialog360/handler_test.go b/handlers/dialog360/handler_test.go index 4eca75dea..3e29b1ab2 100644 --- a/handlers/dialog360/handler_test.go +++ b/handlers/dialog360/handler_test.go @@ -352,7 +352,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ Label: "Audio Send", MsgText: "audio caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"audio/mpeg:https://foo.bar/audio.mp3"}, + MsgAttachments: []string{"audio/mpeg:http://mock.com/1234/test.mp3"}, MockResponses: map[string][]*httpx.MockResponse{ "*/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -360,7 +360,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ }, }, ExpectedRequests: []ExpectedRequest{ - {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`}, + {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/1234/test.mp3"}}`}, {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"audio caption","preview_url":false}}`}, }, ExpectedExtIDs: []string{"157b5e14568e8", "157b5e14568e8"}, @@ -369,7 +369,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ Label: "Document Send", MsgText: "document caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, + MsgAttachments: []string{"application/pdf:http://mock.com/1234/test.pdf"}, MockResponses: map[string][]*httpx.MockResponse{ "https://waba-v2.360dialog.io/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -378,7 +378,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"https://foo.bar/document.pdf","caption":"document caption","filename":"document.pdf"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"http://mock.com/1234/test.pdf","caption":"document caption","filename":"test.pdf"}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -387,7 +387,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ Label: "Image Send", MsgText: "image caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, MockResponses: map[string][]*httpx.MockResponse{ "https://waba-v2.360dialog.io/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -396,7 +396,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg","caption":"image caption"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/1234/test.jpg","caption":"image caption"}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -405,7 +405,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ Label: "Video Send", MsgText: "video caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MsgAttachments: []string{"video/mp4:http://mock.com/1234/test.mp4"}, MockResponses: map[string][]*httpx.MockResponse{ "https://waba-v2.360dialog.io/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -414,7 +414,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"https://foo.bar/video.mp4","caption":"video caption"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"http://mock.com/1234/test.mp4","caption":"video caption"}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -507,7 +507,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, MockResponses: map[string][]*httpx.MockResponse{ "https://waba-v2.360dialog.io/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -517,7 +517,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"https://foo.bar/image.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"http://mock.com/1234/test.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -527,7 +527,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MsgAttachments: []string{"video/mp4:http://mock.com/1234/test.mp4"}, MockResponses: map[string][]*httpx.MockResponse{ "https://waba-v2.360dialog.io/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -537,7 +537,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"https://foo.bar/video.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"http://mock.com/1234/test.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -547,7 +547,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"document/pdf:https://foo.bar/document.pdf"}, + MsgAttachments: []string{"document/pdf:http://mock.com/1234/test.pdf"}, MockResponses: map[string][]*httpx.MockResponse{ "https://waba-v2.360dialog.io/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -557,7 +557,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"https://foo.bar/document.pdf","filename":"document.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"http://mock.com/1234/test.pdf","filename":"test.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -567,7 +567,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3"}, - MsgAttachments: []string{"audio/mp3:https://foo.bar/audio.mp3"}, + MsgAttachments: []string{"audio/mp3:http://mock.com/1234/test.mp3"}, MockResponses: map[string][]*httpx.MockResponse{ "*/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -575,7 +575,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ }, }, ExpectedRequests: []ExpectedRequest{ - {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`}, + {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/1234/test.mp3"}}`}, {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"ROW1"}},{"type":"reply","reply":{"id":"1","title":"ROW2"}},{"type":"reply","reply":{"id":"2","title":"ROW3"}}]}}}`}, }, ExpectedExtIDs: []string{"157b5e14568e8", "157b5e14568e8"}, @@ -585,7 +585,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ MsgText: "Interactive List Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/1234/test.jpg"}, MockResponses: map[string][]*httpx.MockResponse{ "*/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -593,7 +593,7 @@ var SendTestCasesD3C = []OutgoingTestCase{ }, }, ExpectedRequests: []ExpectedRequest{ - {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg"}}`}, + {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/1234/test.jpg"}}`}, {Body: `{"messaging_product":"whatsapp","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"}]}]}}}`}, }, ExpectedExtIDs: []string{"157b5e14568e8", "157b5e14568e8"}, @@ -669,6 +669,30 @@ var SendTestCasesD3C = []OutgoingTestCase{ }, } +// setupMedia takes care of having the media files needed to our test server host +func setupMedia(mb *test.MockBackend) { + imageJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/1234/test.jpg", 1024*1024, 640, 480, 0, nil) + + audioM4A := test.NewMockMedia("test.m4a", "audio/mp4", "http://mock.com/2345/test.m4a", 1024*1024, 0, 0, 200, nil) + audioMP3 := test.NewMockMedia("test.mp3", "audio/mpeg", "http://mock.com/3456/test.mp3", 1024*1024, 0, 0, 200, []courier.Media{audioM4A}) + + thumbJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/4567/test.jpg", 1024*1024, 640, 480, 0, nil) + videoMP4 := test.NewMockMedia("test.mp4", "video/mp4", "http://mock.com/5678/test.mp4", 1024*1024, 0, 0, 1000, []courier.Media{thumbJPG}) + + videoMOV := test.NewMockMedia("test.mov", "video/quicktime", "http://mock.com/6789/test.mov", 100*1024*1024, 0, 0, 2000, nil) + + filePDF := test.NewMockMedia("test.pdf", "application/pdf", "http://mock.com/7890/test.pdf", 100*1024*1024, 0, 0, 0, nil) + + stickerWEBP := test.NewMockMedia("test.webp", "image/webp", "http://mock.com/8901/test.webp", 50*1024, 480, 480, 0, nil) + + mb.MockMedia(imageJPG) + mb.MockMedia(audioMP3) + mb.MockMedia(videoMP4) + mb.MockMedia(videoMOV) + mb.MockMedia(filePDF) + mb.MockMedia(stickerWEBP) +} + func TestOutgoing(t *testing.T) { // shorter max msg length for testing maxMsgLength = 100 @@ -679,5 +703,5 @@ func TestOutgoing(t *testing.T) { }) checkRedacted := []string{"the-auth-token"} - RunOutgoingTestCases(t, ChannelWAC, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), SendTestCasesD3C, checkRedacted, nil) + RunOutgoingTestCases(t, ChannelWAC, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), SendTestCasesD3C, checkRedacted, setupMedia) } diff --git a/handlers/meta/whataspp_test.go b/handlers/meta/whataspp_test.go index cef934d16..2508122e1 100644 --- a/handlers/meta/whataspp_test.go +++ b/handlers/meta/whataspp_test.go @@ -321,7 +321,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ Label: "Audio Send", MsgText: "audio caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"audio/mpeg:https://foo.bar/audio.mp3"}, + MsgAttachments: []string{"audio/mpeg:http://mock.com/3456/test.mp3"}, MockResponses: map[string][]*httpx.MockResponse{ "*/12345_ID/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -329,7 +329,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ }, }, ExpectedRequests: []ExpectedRequest{ - {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`}, + {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/3456/test.mp3"}}`}, {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"text","text":{"body":"audio caption","preview_url":false}}`}, }, ExpectedExtIDs: []string{"157b5e14568e8", "157b5e14568e8"}, @@ -338,7 +338,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ Label: "Document Send", MsgText: "document caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, + MsgAttachments: []string{"application/pdf:http://mock.com/3456/test.pdf"}, MockResponses: map[string][]*httpx.MockResponse{ "*/12345_ID/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -347,7 +347,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"https://foo.bar/document.pdf","caption":"document caption","filename":"document.pdf"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"document","document":{"link":"http://mock.com/3456/test.pdf","caption":"document caption","filename":"test.pdf"}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -356,7 +356,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ Label: "Image Send", MsgText: "image caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/3456/test.jpg"}, MockResponses: map[string][]*httpx.MockResponse{ "*/12345_ID/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -365,7 +365,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg","caption":"image caption"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/3456/test.jpg","caption":"image caption"}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -374,7 +374,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ Label: "Video Send", MsgText: "video caption", MsgURN: "whatsapp:250788123123", - MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MsgAttachments: []string{"video/mp4:http://mock.com/3456/test.mp4"}, MockResponses: map[string][]*httpx.MockResponse{ "*/12345_ID/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -383,7 +383,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"https://foo.bar/video.mp4","caption":"video caption"}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"video","video":{"link":"http://mock.com/3456/test.mp4","caption":"video caption"}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -530,7 +530,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/3456/test.jpg"}, MockResponses: map[string][]*httpx.MockResponse{ "*/12345_ID/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -539,7 +539,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"https://foo.bar/image.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"image","image":{"link":"http://mock.com/3456/test.jpg"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -549,7 +549,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, + MsgAttachments: []string{"video/mp4:http://mock.com/3456/test.mp4"}, MockResponses: map[string][]*httpx.MockResponse{ "*/12345_ID/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -558,7 +558,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"https://foo.bar/video.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"video","video":{"link":"http://mock.com/3456/test.mp4"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -568,7 +568,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"BUTTON1"}, - MsgAttachments: []string{"document/pdf:https://foo.bar/document.pdf"}, + MsgAttachments: []string{"document/pdf:http://mock.com/3456/test.pdf"}, MockResponses: map[string][]*httpx.MockResponse{ "*/12345_ID/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -577,7 +577,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ ExpectedRequests: []ExpectedRequest{ { Path: "/12345_ID/messages", - Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"https://foo.bar/document.pdf","filename":"document.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, + Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","header":{"type":"document","document":{"link":"http://mock.com/3456/test.pdf","filename":"test.pdf"}},"body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"BUTTON1"}}]}}}`, }, }, ExpectedExtIDs: []string{"157b5e14568e8"}, @@ -587,7 +587,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ MsgText: "Interactive Button Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3"}, - MsgAttachments: []string{"audio/mp3:https://foo.bar/audio.mp3"}, + MsgAttachments: []string{"audio/mp3:http://mock.com/3456/test.mp3"}, MockResponses: map[string][]*httpx.MockResponse{ "*/12345_ID/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -595,7 +595,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ }, }, ExpectedRequests: []ExpectedRequest{ - {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"https://foo.bar/audio.mp3"}}`}, + {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"audio","audio":{"link":"http://mock.com/3456/test.mp3"}}`}, {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"interactive","interactive":{"type":"button","body":{"text":"Interactive Button Msg"},"action":{"buttons":[{"type":"reply","reply":{"id":"0","title":"ROW1"}},{"type":"reply","reply":{"id":"1","title":"ROW2"}},{"type":"reply","reply":{"id":"2","title":"ROW3"}}]}}}`}, }, ExpectedExtIDs: []string{"157b5e14568e8", "157b5e14568e8"}, @@ -605,7 +605,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ MsgText: "Interactive List Msg", MsgURN: "whatsapp:250788123123", MsgQuickReplies: []string{"ROW1", "ROW2", "ROW3", "ROW4"}, - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MsgAttachments: []string{"image/jpeg:http://mock.com/3456/test.jpg"}, MockResponses: map[string][]*httpx.MockResponse{ "*/12345_ID/messages": { httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)), @@ -613,7 +613,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{ }, }, ExpectedRequests: []ExpectedRequest{ - {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"https://foo.bar/image.jpg"}}`}, + {Body: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"image","image":{"link":"http://mock.com/3456/test.jpg"}}`}, {Body: `{"messaging_product":"whatsapp","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"}]}]}}}`}, }, ExpectedExtIDs: []string{"157b5e14568e8", "157b5e14568e8"}, @@ -670,6 +670,30 @@ var whatsappOutgoingTests = []OutgoingTestCase{ }, } +// setupMedia takes care of having the media files needed to our test server host +func setupMedia(mb *test.MockBackend) { + imageJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/1234/test.jpg", 1024*1024, 640, 480, 0, nil) + + audioM4A := test.NewMockMedia("test.m4a", "audio/mp4", "http://mock.com/2345/test.m4a", 1024*1024, 0, 0, 200, nil) + audioMP3 := test.NewMockMedia("test.mp3", "audio/mpeg", "http://mock.com/3456/test.mp3", 1024*1024, 0, 0, 200, []courier.Media{audioM4A}) + + thumbJPG := test.NewMockMedia("test.jpg", "image/jpeg", "http://mock.com/4567/test.jpg", 1024*1024, 640, 480, 0, nil) + videoMP4 := test.NewMockMedia("test.mp4", "video/mp4", "http://mock.com/5678/test.mp4", 1024*1024, 0, 0, 1000, []courier.Media{thumbJPG}) + + videoMOV := test.NewMockMedia("test.mov", "video/quicktime", "http://mock.com/6789/test.mov", 100*1024*1024, 0, 0, 2000, nil) + + filePDF := test.NewMockMedia("test.pdf", "application/pdf", "http://mock.com/7890/test.pdf", 100*1024*1024, 0, 0, 0, nil) + + stickerWEBP := test.NewMockMedia("test.webp", "image/webp", "http://mock.com/8901/test.webp", 50*1024, 480, 480, 0, nil) + + mb.MockMedia(imageJPG) + mb.MockMedia(audioMP3) + mb.MockMedia(videoMP4) + mb.MockMedia(videoMOV) + mb.MockMedia(filePDF) + mb.MockMedia(stickerWEBP) +} + func TestWhatsAppOutgoing(t *testing.T) { // shorter max msg length for testing maxMsgLength = 100 @@ -678,7 +702,7 @@ func TestWhatsAppOutgoing(t *testing.T) { checkRedacted := []string{"wac_admin_system_user_token", "missing_facebook_app_secret", "missing_facebook_webhook_secret", "a123"} - RunOutgoingTestCases(t, channel, newHandler("WAC", "Cloud API WhatsApp"), whatsappOutgoingTests, checkRedacted, nil) + RunOutgoingTestCases(t, channel, newHandler("WAC", "Cloud API WhatsApp"), whatsappOutgoingTests, checkRedacted, setupMedia) } func TestWhatsAppDescribeURN(t *testing.T) { From ba7fa36532962c5e2eb8b300af44da4ea319fe5a Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Tue, 23 Apr 2024 14:48:19 +0200 Subject: [PATCH 8/8] Support receiving stickers --- handlers/dialog360/handler.go | 2 + handlers/dialog360/handler_test.go | 18 ++++++++ handlers/meta/handlers.go | 2 + handlers/meta/whataspp_test.go | 67 +++++++++++++++++++++++++++++- handlers/meta/whatsapp/api.go | 18 +++++--- 5 files changed, 101 insertions(+), 6 deletions(-) diff --git a/handlers/dialog360/handler.go b/handlers/dialog360/handler.go index 4001e917b..dc78e264e 100644 --- a/handlers/dialog360/handler.go +++ b/handlers/dialog360/handler.go @@ -160,6 +160,8 @@ func (h *handler) processWhatsAppPayload(ctx context.Context, channel courier.Ch } else if msg.Type == "image" && msg.Image != nil { text = msg.Image.Caption mediaURL, err = h.resolveMediaURL(channel, msg.Image.ID, clog) + } else if msg.Type == "sticker" && msg.Sticker != nil { + mediaURL, err = h.resolveMediaURL(channel, msg.Sticker.ID, clog) } else if msg.Type == "video" && msg.Video != nil { text = msg.Video.Caption mediaURL, err = h.resolveMediaURL(channel, msg.Video.ID, clog) diff --git a/handlers/dialog360/handler_test.go b/handlers/dialog360/handler_test.go index 3e29b1ab2..86a26a08e 100644 --- a/handlers/dialog360/handler_test.go +++ b/handlers/dialog360/handler_test.go @@ -114,6 +114,20 @@ var testCasesD3C = []IncomingTestCase{ ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_image"}, ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), }, + { + Label: "Receive Valid Sticker Message", + URL: d3CReceiveURL, + Data: string(test.ReadFile("../meta/testdata/wac/stickerWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp(""), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://waba-v2.360dialog.io/whatsapp_business/attachments/?mid=id_sticker"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + }, { Label: "Receive Valid Video Message", URL: d3CReceiveURL, @@ -272,6 +286,10 @@ func buildMockD3MediaService(testChannels []courier.Channel, testCases []Incomin fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_audio" } + if strings.HasSuffix(r.URL.Path, "id_sticker") { + fileURL = "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=id_sticker" + } + w.WriteHeader(http.StatusOK) w.Write([]byte(fmt.Sprintf(`{ "url": "%s" }`, fileURL))) })) diff --git a/handlers/meta/handlers.go b/handlers/meta/handlers.go index 4005b6010..27b157dfb 100644 --- a/handlers/meta/handlers.go +++ b/handlers/meta/handlers.go @@ -309,6 +309,8 @@ func (h *handler) processWhatsAppPayload(ctx context.Context, channel courier.Ch } else if msg.Type == "image" && msg.Image != nil { text = msg.Image.Caption mediaURL, err = h.resolveMediaURL(msg.Image.ID, token, clog) + } else if msg.Type == "sticker" && msg.Sticker != nil { + mediaURL, err = h.resolveMediaURL(msg.Sticker.ID, token, clog) } else if msg.Type == "video" && msg.Video != nil { text = msg.Video.Caption mediaURL, err = h.resolveMediaURL(msg.Video.ID, token, clog) diff --git a/handlers/meta/whataspp_test.go b/handlers/meta/whataspp_test.go index 2508122e1..13b4e84f6 100644 --- a/handlers/meta/whataspp_test.go +++ b/handlers/meta/whataspp_test.go @@ -2,6 +2,10 @@ package meta import ( "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" "time" @@ -107,6 +111,21 @@ var whatsappIncomingTests = []IncomingTestCase{ ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), PrepRequest: addValidSignature, }, + { + Label: "Receive Valid Sticker Message", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/stickerWAC.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + ExpectedMsgText: Sp(""), + ExpectedURN: "whatsapp:5678", + ExpectedExternalID: "external_id", + ExpectedAttachments: []string{"https://foo.bar/attachmentURL_Sticker"}, + ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + PrepRequest: addValidSignature, + }, { Label: "Receive Valid Video Message", URL: whatappReceiveURL, @@ -277,7 +296,53 @@ var whatsappIncomingTests = []IncomingTestCase{ } func TestWhatsAppIncoming(t *testing.T) { - graphURL = createMockGraphAPI().URL + + 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" && accessToken != "Bearer wac_admin_system_user_token" { + fmt.Printf("Access token: %s\n", accessToken) + http.Error(w, "invalid auth token", http.StatusForbidden) + 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 + } + + if strings.HasSuffix(r.URL.Path, "sticker") { + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Sticker"}`)) + return + } + + // valid token + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL"}`)) + + })) + graphURL = server.URL RunIncomingTestCases(t, whatsappTestChannels, newHandler("WAC", "Cloud API WhatsApp"), whatsappIncomingTests) } diff --git a/handlers/meta/whatsapp/api.go b/handlers/meta/whatsapp/api.go index 5a36d008b..bfeb7c1eb 100644 --- a/handlers/meta/whatsapp/api.go +++ b/handlers/meta/whatsapp/api.go @@ -23,6 +23,13 @@ type MOMedia struct { SHA256 string `json:"sha256"` } +type wacSticker struct { + Animated bool `json:"animated"` + ID string `json:"id"` + Mimetype string `json:"mime_type"` + SHA256 string `json:"sha256"` +} + type Change struct { Field string `json:"field"` Value struct { @@ -51,11 +58,12 @@ type Change struct { Text struct { Body string `json:"body"` } `json:"text"` - Image *MOMedia `json:"image"` - Audio *MOMedia `json:"audio"` - Video *MOMedia `json:"video"` - Document *MOMedia `json:"document"` - Voice *MOMedia `json:"voice"` + Image *MOMedia `json:"image"` + Audio *MOMedia `json:"audio"` + Video *MOMedia `json:"video"` + Document *MOMedia `json:"document"` + Voice *MOMedia `json:"voice"` + Sticker *wacSticker `json:"sticker"` Location *struct { Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"`