diff --git a/.github/workflows/build-courier-push-tag-sp-india-ire.yaml b/.github/workflows/build-courier-push-tag-sp-india-ire.yaml index 5803bedcf..671e23b19 100644 --- a/.github/workflows/build-courier-push-tag-sp-india-ire.yaml +++ b/.github/workflows/build-courier-push-tag-sp-india-ire.yaml @@ -16,7 +16,6 @@ jobs: if grep -qs -e '^.*.*-develop' <<< "${TAG}" ; then echo "Found environment: DEVELOP - ${TAG}" echo "ENVIRONMENT=develop" | tee -a "${GITHUB_ENV}" - exit 1 # stop action elif grep -qs -e '^.*.*-staging' <<< "${TAG}" ; then echo "Found environment: STAGING - ${TAG}" echo "ENVIRONMENT=staging" | tee -a "${GITHUB_ENV}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20c12225d..d359379ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: [push, pull_request] env: - go-version: "1.19.x" + go-version: "1.21.x" jobs: test: name: Test @@ -13,7 +13,7 @@ jobs: ports: - 6379:6379 postgres: - image: postgres:14-alpine + image: postgres:15-alpine env: POSTGRES_DB: courier_test POSTGRES_USER: courier_test diff --git a/CHANGELOG.md b/CHANGELOG.md index 130ee8a8a..48800d3f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,171 @@ +v9.0.1 (2024-01-08) +------------------------- + * Fix sending bandwidth MMS without text + +v9.0.0 (2024-01-05) +------------------------- + * Bump golang.org/x/crypto from 0.16.0 to 0.17.0 + +v8.3.32 (2023-12-12) +------------------------- + * Update deps + +v8.3.31 (2023-12-06) +------------------------- + * Use language value in templating metadata instead of trying to match + +v8.3.30 (2023-12-04) +------------------------- + * Change channel events so that created_on is db time and is included in queued task payload + +v8.3.29 (2023-12-04) +------------------------- + * Fix FBA timestamps that sometimes are in seconds instead of milliseconds + * Remove support for HSM template support + * Update to latest gocommon and phonenumbers + +v8.3.28 (2023-11-23) +------------------------- + * Logging tweak + +v8.3.27 (2023-10-31) +------------------------- + * Prevent all courier HTTP requests from accessing local networks + +v8.3.26 (2023-10-30) +------------------------- + * Update to latest gocommon + +v8.3.25 (2023-10-25) +------------------------- + * Update docker image to go 1.21 + * Remove use of logrus and use slog with sentry + * Bump golang.org/x/net from 0.14.0 to 0.17.0 + +v8.3.24 (2023-10-10) +------------------------- + * Fix handling IG like hearts + * Ignore attachments of type fallback on FBA channels + * More logrus replacement to use slog + +v8.3.23 (2023-10-04) +------------------------- + * Switch channelevent.extra to always be strings + * Add optin_id to channels_channelevent + * Allow outgoing tests to check multiple requests + +v8.3.22 (2023-09-27) +------------------------- + * Use Facebook API v17.0 + +v8.3.21 (2023-09-25) +------------------------- + * Support sending facebook message with opt-in auth token + +v8.3.20 (2023-09-21) +------------------------- + * Switch to using optin ids instead of uuids + +v8.3.19 (2023-09-20) +------------------------- + * Fix queueing of optin/optout events to mailroom + * Implement sending opt-in requests for FBA channels + * Simplfy handlers splitting up messages + +v8.3.18 (2023-09-18) +------------------------- + * Add separate MsgIn and MsgOut interface types + * Use functional options pattern to create base handlers + * Improve testing of status updates from handlers and allow testing of multiple status updates per request + * Split up Meta notification payload into whatsapp and messenger specific parts + +v8.3.17 (2023-09-14) +------------------------- + * Fix stop contact event task names + * Add support for FB notificaiton messages optin and optout events + +v8.3.16 (2023-09-13) +------------------------- + * Simplify interfaces that handlers have access to + * Allow handlers to create arbitrary auth tokens with messages and channel events + * Rename legacy FB and WA handlers + * Refactor whatsapp handlers to be more DRY + +v8.3.15 (2023-09-12) +------------------------- + * Stop reading from ContactURN.auth and remove from model + +v8.3.14 (2023-09-11) +------------------------- + * Move whatsapp language matching into own util package and use i18n.BCP47Matcher + * Update to latest gocommon and use i18n.Locale + * Read from ContactURN.auth_tokens instead of .auth + +v8.3.13 (2023-09-06) +------------------------- + * Start writing ContactURN.auth_tokens + * Update to latest null library and use Map[string] for channel event extra + +v8.3.12 (2023-09-06) +------------------------- + * Do more debug logging and less info logging + +v8.3.11 (2023-09-06) +------------------------- + * Add logging of requests with no associated channel + * No need to try making DB queries when all msg IDs got resolved from redis + +v8.3.10 (2023-09-05) +------------------------- + * Don't rely on knowing msg id to determine if a log is attached + * Rework handler tests so that test cases must explicitly say if they don't generate a channel log + +v8.3.9 (2023-09-05) +------------------------- + * Try to resolve sent external ids from redis + * For received messages without external id, de-dupe by hash of text+attachments instead of just text + +v8.3.8 (2023-08-31) +------------------------- + * Update to latest redisx which fixes accuracy for sub-minute interval hashes + * Update to new batchers in gocommon which are more efficient + +v8.3.7 (2023-08-30) +------------------------- + * Sender deletion handled by mailroom task + +v8.3.6 (2023-08-30) +------------------------- + * Rework writing msg statuses to always use id resolving + +v8.3.5 (2023-08-30) +------------------------- + * Rework writing status updates so that updates by external id also use the batcher + +v8.3.4 (2023-08-24) +------------------------- + * Update channel type to save external ID for MO messages if we can, so we can dedupe by that + * Test with PostgreSQL 15 + +v8.3.3 (2023-08-17) +------------------------- + * Remove Legacy Twitter (TT) type registration + * Remove Blackmyna, Junebug, old Zenvia channel type handlers + +v8.3.2 (2023-08-16) +------------------------- + * Fix retrieve media files for D3C + +v8.3.1 (2023-08-09) +------------------------- + * Revert validator dep upgrade + +v8.3.0 (2023-08-09) +------------------------- + * Update to go 1.20 + * Update deps + * Add Messagebird channel type + v8.2.1 (2023-08-03) ------------------------- * Always save http_logs as [] rather than null diff --git a/Dockerfile b/Dockerfile index a2cf86075..b8738962b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.20 +FROM golang:1.21 WORKDIR /usr/src/app diff --git a/README.md b/README.md index eb23ef1c8..c95d4b803 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ environment variables and parameters and for more details on each option. ### RapidPro -For use with RapidPro, you will want to configure these settings: +For use with RapidPro/TextIt, you will want to configure these settings: * `COURIER_DOMAIN`: The root domain which courier is exposed as (ex `textit.in`) * `COURIER_SPOOL_DIR`: A local path where courier can spool files if the database is down, should be writable. (ex: `/home/courier/spool`) diff --git a/WENI-CHANGELOG.md b/WENI-CHANGELOG.md index 03faf81b5..ec45ccd30 100644 --- a/WENI-CHANGELOG.md +++ b/WENI-CHANGELOG.md @@ -1,3 +1,8 @@ +1.5.9-courier-9.0.1 +---------- + * Update to v9.0.1 + * Update dockerfile + 1.5.8-courier-8.2.1 ---------- * Remove menu button name mapping diff --git a/attachments.go b/attachments.go index 06fd9bb35..caa5935c4 100644 --- a/attachments.go +++ b/attachments.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "log/slog" "mime" "net/http" "net/url" @@ -12,7 +13,6 @@ import ( "github.com/nyaruka/courier/utils" "github.com/nyaruka/gocommon/httpx" "github.com/pkg/errors" - "github.com/sirupsen/logrus" "gopkg.in/h2non/filetype.v1" ) @@ -57,14 +57,14 @@ func fetchAttachment(ctx context.Context, b Backend, r *http.Request) (*fetchAtt return nil, errors.Wrap(err, "error getting channel") } - clog := NewChannelLogForAttachmentFetch(ch, fa.MsgID, GetHandler(ch.ChannelType()).RedactValues(ch)) + clog := NewChannelLogForAttachmentFetch(ch, GetHandler(ch.ChannelType()).RedactValues(ch)) attachment, err := FetchAndStoreAttachment(ctx, b, ch, fa.URL, clog) // try to write channel log even if we have an error clog.End() if err := b.WriteChannelLog(ctx, clog); err != nil { - logrus.WithError(err).Error() + slog.Error("error writing log", "error", err) } if err != nil { @@ -93,13 +93,13 @@ func FetchAndStoreAttachment(ctx context.Context, b Backend, channel Channel, at return nil, errors.Wrap(err, "unable to create attachment request") } - trace, err := httpx.DoTrace(utils.GetHTTPClient(), attRequest, nil, nil, maxAttBodyReadBytes) + trace, err := httpx.DoTrace(b.HttpClient(true), attRequest, nil, b.HttpAccess(), maxAttBodyReadBytes) if trace != nil { clog.HTTP(trace) // if we got a non-200 response, return the attachment with a pseudo content type which tells the caller // to continue without the attachment - if trace.Response == nil || trace.Response.StatusCode/100 != 2 { + if trace.Response == nil || trace.Response.StatusCode/100 != 2 || err == httpx.ErrResponseSize || err == httpx.ErrAccessConfig { return &Attachment{ContentType: "unavailable", URL: attURL}, nil } } diff --git a/attachments_test.go b/attachments_test.go index 2bca70fc0..6f2f9f5fa 100644 --- a/attachments_test.go +++ b/attachments_test.go @@ -37,10 +37,10 @@ func TestFetchAndStoreAttachment(t *testing.T) { ctx := context.Background() mb := test.NewMockBackend() - mockChannel := test.NewMockChannel("e4bb1578-29da-4fa5-a214-9da19dd24230", "MCK", "2020", "US", map[string]interface{}{}) + mockChannel := test.NewMockChannel("e4bb1578-29da-4fa5-a214-9da19dd24230", "MCK", "2020", "US", map[string]any{}) mb.AddChannel(mockChannel) - clog := courier.NewChannelLogForAttachmentFetch(mockChannel, courier.MsgID(123), []string{"sesame"}) + clog := courier.NewChannelLogForAttachmentFetch(mockChannel, []string{"sesame"}) att, err := courier.FetchAndStoreAttachment(ctx, mb, mockChannel, "http://mock.com/media/hello.jpg", clog) assert.NoError(t, err) diff --git a/backend.go b/backend.go index 0a4da6906..0479ffb0a 100644 --- a/backend.go +++ b/backend.go @@ -3,9 +3,11 @@ package courier import ( "context" "fmt" + "net/http" "strings" "github.com/gomodule/redigo/redis" + "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/urns" ) @@ -30,31 +32,31 @@ type Backend interface { GetChannelByAddress(context.Context, ChannelType, ChannelAddress) (Channel, error) // GetContact returns (or creates) the contact for the passed in channel and URN - GetContact(context.Context, Channel, urns.URN, string, string, *ChannelLog) (Contact, error) + GetContact(context.Context, Channel, urns.URN, map[string]string, string, *ChannelLog) (Contact, error) // AddURNtoContact adds a URN to the passed in contact - AddURNtoContact(context context.Context, channel Channel, contact Contact, urn urns.URN) (urns.URN, error) + AddURNtoContact(context context.Context, channel Channel, contact Contact, urn urns.URN, authTokens map[string]string) (urns.URN, error) // RemoveURNFromcontact removes a URN from the passed in contact RemoveURNfromContact(context context.Context, channel Channel, contact Contact, urn urns.URN) (urns.URN, error) - // DeleteMsgWithExternalID delete a message we receive an event that it should be deleted - DeleteMsgWithExternalID(ctx context.Context, channel Channel, externalID string) error + // DeleteMsgByExternalID deletes a message that has been deleted on the channel side + DeleteMsgByExternalID(ctx context.Context, channel Channel, externalID string) error // NewIncomingMsg creates a new message from the given params - NewIncomingMsg(Channel, urns.URN, string, string, *ChannelLog) Msg + NewIncomingMsg(Channel, urns.URN, string, string, *ChannelLog) MsgIn // WriteMsg writes the passed in message to our backend - WriteMsg(context.Context, Msg, *ChannelLog) error + WriteMsg(context.Context, MsgIn, *ChannelLog) error - // NewMsgStatusForID creates a new Status object for the given message id - NewMsgStatusForID(Channel, MsgID, MsgStatusValue, *ChannelLog) MsgStatus + // NewStatusUpdate creates a new status update for the given message id + NewStatusUpdate(Channel, MsgID, MsgStatus, *ChannelLog) StatusUpdate - // NewMsgStatusForExternalID creates a new Status object for the given external id - NewMsgStatusForExternalID(Channel, string, MsgStatusValue, *ChannelLog) MsgStatus + // NewStatusUpdateByExternalID creates a new status update for the given external id + NewStatusUpdateByExternalID(Channel, string, MsgStatus, *ChannelLog) StatusUpdate - // WriteMsgStatus writes the passed in status update to our backend - WriteMsgStatus(context.Context, MsgStatus) error + // WriteStatusUpdate writes the passed in status update to our backend + WriteStatusUpdate(context.Context, StatusUpdate) error // NewChannelEvent creates a new channel event for the given channel and event type NewChannelEvent(Channel, ChannelEventType, urns.URN, *ChannelLog) ChannelEvent @@ -67,7 +69,7 @@ type Backend interface { // PopNextOutgoingMsg returns the next message that needs to be sent, callers should call MarkOutgoingMsgComplete with the // returned message when they have dealt with the message (regardless of whether it was sent or not) - PopNextOutgoingMsg(context.Context) (Msg, error) + PopNextOutgoingMsg(context.Context) (MsgOut, error) // WasMsgSent returns whether the backend thinks the passed in message was already sent. This can be used in cases where // a backend wants to implement a failsafe against double sending messages (say if they were double queued) @@ -80,7 +82,7 @@ type Backend interface { // MarkOutgoingMsgComplete marks the passed in message as having been processed. Note this should be called even in the case // of errors during sending as it will manage the number of active workers per channel. The optional status parameter can be // used to determine any sort of deduping of msg sends - MarkOutgoingMsgComplete(context.Context, Msg, MsgStatus) + MarkOutgoingMsgComplete(context.Context, MsgOut, StatusUpdate) // SaveAttachment saves an attachment to backend storage SaveAttachment(context.Context, Channel, string, []byte, string) (string, error) @@ -88,6 +90,10 @@ type Backend interface { // ResolveMedia resolves an outgoing attachment URL to a media object ResolveMedia(context.Context, string) (Media, error) + // HttpClient returns an HTTP client for making external requests + HttpClient(bool) *http.Client + HttpAccess() *httpx.AccessConfig + // Health returns a string describing any health problems the backend has, or empty string if all is well Health() string diff --git a/backends/rapidpro/backend.go b/backends/rapidpro/backend.go index 701fc1ba7..3a8f0a5eb 100644 --- a/backends/rapidpro/backend.go +++ b/backends/rapidpro/backend.go @@ -3,9 +3,12 @@ package rapidpro import ( "bytes" "context" + "crypto/tls" "database/sql" "encoding/json" "fmt" + "log/slog" + "net/http" "net/url" "path" "path/filepath" @@ -22,6 +25,7 @@ import ( "github.com/nyaruka/courier/queue" "github.com/nyaruka/gocommon/analytics" "github.com/nyaruka/gocommon/dbutil" + "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/storage" "github.com/nyaruka/gocommon/syncx" @@ -29,7 +33,6 @@ import ( "github.com/nyaruka/gocommon/uuids" "github.com/nyaruka/redisx" "github.com/pkg/errors" - "github.com/sirupsen/logrus" ) // the name for our message queue @@ -50,6 +53,273 @@ func init() { courier.RegisterBackend("rapidpro", newBackend) } +type backend struct { + config *courier.Config + + statusWriter *StatusWriter + dbLogWriter *DBLogWriter // unattached logs being written to the database + stLogWriter *StorageLogWriter // attached logs being written to storage + writerWG *sync.WaitGroup + + db *sqlx.DB + redisPool *redis.Pool + attachmentStorage storage.Storage + logStorage storage.Storage + + stopChan chan bool + waitGroup *sync.WaitGroup + + httpClient *http.Client + httpClientInsecure *http.Client + httpAccess *httpx.AccessConfig + + mediaCache *redisx.IntervalHash + mediaMutexes syncx.HashMutex + + // tracking of recent messages received to avoid creating duplicates + receivedExternalIDs *redisx.IntervalHash // using external id + receivedMsgs *redisx.IntervalHash // using content hash + + // tracking of external ids of messages we've sent in case we need one before its status update has been written + sentExternalIDs *redisx.IntervalHash + + // both sqlx and redis provide wait stats which are cummulative that we need to convert into increments + dbWaitDuration time.Duration + dbWaitCount int64 + redisWaitDuration time.Duration + redisWaitCount int64 +} + +// NewBackend creates a new RapidPro backend +func newBackend(cfg *courier.Config) courier.Backend { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 64 + transport.MaxIdleConnsPerHost = 8 + transport.IdleConnTimeout = 15 * time.Second + + insecureTransport := http.DefaultTransport.(*http.Transport).Clone() + insecureTransport.MaxIdleConns = 64 + insecureTransport.MaxIdleConnsPerHost = 8 + insecureTransport.IdleConnTimeout = 15 * time.Second + insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + disallowedIPs, disallowedNets, _ := cfg.ParseDisallowedNetworks() + + return &backend{ + config: cfg, + + httpClient: &http.Client{Transport: transport, Timeout: 30 * time.Second}, + httpClientInsecure: &http.Client{Transport: insecureTransport, Timeout: 30 * time.Second}, + httpAccess: httpx.NewAccessConfig(10*time.Second, disallowedIPs, disallowedNets), + + stopChan: make(chan bool), + waitGroup: &sync.WaitGroup{}, + + writerWG: &sync.WaitGroup{}, + + mediaCache: redisx.NewIntervalHash("media-lookups", time.Hour*24, 2), + mediaMutexes: *syncx.NewHashMutex(8), + + receivedMsgs: redisx.NewIntervalHash("seen-msgs", time.Second*2, 2), // 2 - 4 seconds + receivedExternalIDs: redisx.NewIntervalHash("seen-external-ids", time.Hour*24, 2), // 24 - 48 hours + sentExternalIDs: redisx.NewIntervalHash("sent-external-ids", time.Hour, 2), // 1 - 2 hours + } +} + +// Start starts our RapidPro backend, this tests our various connections and starts our spool flushers +func (b *backend) Start() error { + // parse and test our redis config + log := slog.With( + "comp", "backend", + "state", "starting", + ) + log.Info("starting backend") + + // parse and test our db config + dbURL, err := url.Parse(b.config.DB) + if err != nil { + return fmt.Errorf("unable to parse DB URL '%s': %s", b.config.DB, err) + } + + if dbURL.Scheme != "postgres" { + return fmt.Errorf("invalid DB URL: '%s', only postgres is supported", b.config.DB) + } + + // build our db + db, err := sqlx.Open("postgres", b.config.DB) + if err != nil { + return fmt.Errorf("unable to open DB with config: '%s': %s", b.config.DB, err) + } + + // configure our pool + b.db = db + b.db.SetMaxIdleConns(4) + b.db.SetMaxOpenConns(16) + + // try connecting + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + err = b.db.PingContext(ctx) + cancel() + if err != nil { + log.Error("db not reachable", "error", err) + } else { + log.Info("db ok") + } + + // parse and test our redis config + redisURL, err := url.Parse(b.config.Redis) + if err != nil { + return fmt.Errorf("unable to parse Redis URL '%s': %s", b.config.Redis, err) + } + + // create our pool + redisPool := &redis.Pool{ + Wait: true, // makes callers wait for a connection + MaxActive: 36, // only open this many concurrent connections at once + MaxIdle: 4, // only keep up to this many idle + IdleTimeout: 240 * time.Second, // how long to wait before reaping a connection + Dial: func() (redis.Conn, error) { + conn, err := redis.Dial("tcp", redisURL.Host) + if err != nil { + return nil, err + } + + // send auth if required + if redisURL.User != nil { + pass, authRequired := redisURL.User.Password() + if authRequired { + if _, err := conn.Do("AUTH", pass); err != nil { + conn.Close() + return nil, err + } + } + } + + // switch to the right DB + _, err = conn.Do("SELECT", strings.TrimLeft(redisURL.Path, "/")) + return conn, err + }, + } + b.redisPool = redisPool + + // test our redis connection + conn := redisPool.Get() + defer conn.Close() + _, err = conn.Do("PING") + if err != nil { + log.Error("redis not reachable", "error", err) + } else { + log.Info("redis ok") + } + + // start our dethrottler if we are going to be doing some sending + if b.config.MaxWorkers > 0 { + queue.StartDethrottler(redisPool, b.stopChan, b.waitGroup, msgQueueName) + } + + // create our storage (S3 or file system) + if b.config.AWSAccessKeyID != "" || b.config.AWSUseCredChain { + s3config := &storage.S3Options{ + AWSAccessKeyID: b.config.AWSAccessKeyID, + AWSSecretAccessKey: b.config.AWSSecretAccessKey, + Endpoint: b.config.S3Endpoint, + Region: b.config.S3Region, + DisableSSL: b.config.S3DisableSSL, + ForcePathStyle: b.config.S3ForcePathStyle, + MaxRetries: 3, + } + if b.config.AWSAccessKeyID != "" && !b.config.AWSUseCredChain { + s3config.AWSAccessKeyID = b.config.AWSAccessKeyID + s3config.AWSSecretAccessKey = b.config.AWSSecretAccessKey + } + s3Client, err := storage.NewS3Client(s3config) + if err != nil { + return err + } + b.attachmentStorage = storage.NewS3(s3Client, b.config.S3AttachmentsBucket, b.config.S3Region, s3.BucketCannedACLPublicRead, 32) + b.logStorage = storage.NewS3(s3Client, b.config.S3LogsBucket, b.config.S3Region, s3.BucketCannedACLPrivate, 32) + } else { + b.attachmentStorage = storage.NewFS(storageDir+"/attachments", 0766) + b.logStorage = storage.NewFS(storageDir+"/logs", 0766) + } + + // check our storages + if err := checkStorage(b.attachmentStorage); err != nil { + log.Error(b.attachmentStorage.Name()+" attachment storage not available", "error", err) + } else { + log.Info(b.attachmentStorage.Name() + " attachment storage ok") + } + if err := checkStorage(b.logStorage); err != nil { + log.Error(b.logStorage.Name()+" log storage not available", "error", err) + } else { + log.Info(b.logStorage.Name() + " log storage ok") + } + + // make sure our spool dirs are writable + err = courier.EnsureSpoolDirPresent(b.config.SpoolDir, "msgs") + if err == nil { + err = courier.EnsureSpoolDirPresent(b.config.SpoolDir, "statuses") + } + if err == nil { + err = courier.EnsureSpoolDirPresent(b.config.SpoolDir, "events") + } + if err != nil { + log.Error("spool directories not writable", "error", err) + } else { + log.Info("spool directories ok") + } + + // create our batched writers and start them + b.statusWriter = NewStatusWriter(b, b.config.SpoolDir, b.writerWG) + b.statusWriter.Start() + + b.dbLogWriter = NewDBLogWriter(b.db, b.writerWG) + b.dbLogWriter.Start() + + b.stLogWriter = NewStorageLogWriter(b.logStorage, b.writerWG) + b.stLogWriter.Start() + + // register and start our spool flushers + courier.RegisterFlusher(path.Join(b.config.SpoolDir, "msgs"), b.flushMsgFile) + courier.RegisterFlusher(path.Join(b.config.SpoolDir, "statuses"), b.flushStatusFile) + courier.RegisterFlusher(path.Join(b.config.SpoolDir, "events"), b.flushChannelEventFile) + + slog.Info("backend started", "comp", "backend", "state", "started") + return nil +} + +// Stop stops our RapidPro backend, closing our db and redis connections +func (b *backend) Stop() error { + // close our stop channel + close(b.stopChan) + + // wait for our threads to exit + b.waitGroup.Wait() + return nil +} + +func (b *backend) Cleanup() error { + // stop our batched writers + if b.statusWriter != nil { + b.statusWriter.Stop() + } + if b.dbLogWriter != nil { + b.dbLogWriter.Stop() + } + if b.stLogWriter != nil { + b.stLogWriter.Stop() + } + + // wait for them to flush fully + b.writerWG.Wait() + + // close our db and redis pool + if b.db != nil { + b.db.Close() + } + return b.redisPool.Close() +} + // GetChannel returns the channel for the passed in type and UUID func (b *backend) GetChannel(ctx context.Context, ct courier.ChannelType, uuid courier.ChannelUUID) (courier.Channel, error) { timeout, cancel := context.WithTimeout(ctx, backendTimeout) @@ -75,20 +345,20 @@ func (b *backend) GetChannelByAddress(ctx context.Context, ct courier.ChannelTyp } // GetContact returns the contact for the passed in channel and URN -func (b *backend) GetContact(ctx context.Context, c courier.Channel, urn urns.URN, auth string, name string, clog *courier.ChannelLog) (courier.Contact, error) { - dbChannel := c.(*DBChannel) - return contactForURN(ctx, b, dbChannel.OrgID_, dbChannel, urn, auth, name, clog) +func (b *backend) GetContact(ctx context.Context, c courier.Channel, urn urns.URN, authTokens map[string]string, name string, clog *courier.ChannelLog) (courier.Contact, error) { + dbChannel := c.(*Channel) + return contactForURN(ctx, b, dbChannel.OrgID_, dbChannel, urn, authTokens, name, clog) } // AddURNtoContact adds a URN to the passed in contact -func (b *backend) AddURNtoContact(ctx context.Context, c courier.Channel, contact courier.Contact, urn urns.URN) (urns.URN, error) { +func (b *backend) AddURNtoContact(ctx context.Context, c courier.Channel, contact courier.Contact, urn urns.URN, authTokens map[string]string) (urns.URN, error) { tx, err := b.db.BeginTxx(ctx, nil) if err != nil { return urns.NilURN, err } - dbChannel := c.(*DBChannel) - dbContact := contact.(*DBContact) - _, err = contactURNForURN(tx, dbChannel, dbContact.ID_, urn, "") + dbChannel := c.(*Channel) + dbContact := contact.(*Contact) + _, err = getOrCreateContactURN(tx, dbChannel, dbContact.ID_, urn, authTokens) if err != nil { return urns.NilURN, err } @@ -100,50 +370,41 @@ func (b *backend) AddURNtoContact(ctx context.Context, c courier.Channel, contac return urn, nil } -const removeURNFromContact = ` -UPDATE - contacts_contacturn -SET - contact_id = NULL -WHERE - contact_id = $1 AND - identity = $2 -` - // RemoveURNFromcontact removes a URN from the passed in contact func (b *backend) RemoveURNfromContact(ctx context.Context, c courier.Channel, contact courier.Contact, urn urns.URN) (urns.URN, error) { - dbContact := contact.(*DBContact) - _, err := b.db.ExecContext(ctx, removeURNFromContact, dbContact.ID_, urn.Identity().String()) + dbContact := contact.(*Contact) + _, err := b.db.ExecContext(ctx, `UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1 AND identity = $2`, dbContact.ID_, urn.Identity().String()) if err != nil { return urns.NilURN, err } return urn, nil } -const updateMsgVisibilityDeletedBySender = ` -UPDATE - msgs_msg -SET - visibility = 'X', - text = '', - attachments = '{}' -WHERE - msgs_msg.id = (SELECT m."id" FROM "msgs_msg" m INNER JOIN "channels_channel" c ON (m."channel_id" = c."id") WHERE (c."uuid" = $1 AND m."external_id" = $2 AND m."direction" = 'I')) -RETURNING - msgs_msg.id -` - -// DeleteMsgWithExternalID delete a message we receive an event that it should be deleted -func (b *backend) DeleteMsgWithExternalID(ctx context.Context, channel courier.Channel, externalID string) error { - _, err := b.db.ExecContext(ctx, updateMsgVisibilityDeletedBySender, string(channel.UUID()), externalID) - if err != nil { - return err +// DeleteMsgByExternalID resolves a message external id and quees a task to mailroom to delete it +func (b *backend) DeleteMsgByExternalID(ctx context.Context, channel courier.Channel, externalID string) error { + ch := channel.(*Channel) + row := b.db.QueryRowContext(ctx, `SELECT id, contact_id FROM msgs_msg WHERE channel_id = $1 AND external_id = $2 AND direction = 'I'`, ch.ID(), externalID) + + var msgID courier.MsgID + var contactID ContactID + if err := row.Scan(&msgID, &contactID); err != nil && err != sql.ErrNoRows { + return errors.Wrap(err, "error querying deleted msg") } + + if msgID != courier.NilMsgID && contactID != NilContactID { + rc := b.redisPool.Get() + defer rc.Close() + + if err := queueMsgDeleted(rc, ch, msgID, contactID); err != nil { + return errors.Wrap(err, "error queuing message deleted task") + } + } + return nil } // NewIncomingMsg creates a new message from the given params -func (b *backend) NewIncomingMsg(channel courier.Channel, urn urns.URN, text string, extID string, clog *courier.ChannelLog) courier.Msg { +func (b *backend) NewIncomingMsg(channel courier.Channel, urn urns.URN, text string, extID string, clog *courier.ChannelLog) courier.MsgIn { // strip out invalid UTF8 and NULL chars urn = urns.URN(dbutil.ToValidUTF8(string(urn))) text = dbutil.ToValidUTF8(text) @@ -153,7 +414,7 @@ func (b *backend) NewIncomingMsg(channel courier.Channel, urn urns.URN, text str msg.WithReceivedOn(time.Now().UTC()) // check if this message could be a duplicate and if so use the original's UUID - if prevUUID := b.checkMsgSeen(msg); prevUUID != courier.NilMsgUUID { + if prevUUID := b.checkMsgAlreadyReceived(msg); prevUUID != courier.NilMsgUUID { msg.UUID_ = prevUUID msg.alreadyWritten = true } @@ -162,7 +423,7 @@ func (b *backend) NewIncomingMsg(channel courier.Channel, urn urns.URN, text str } // PopNextOutgoingMsg pops the next message that needs to be sent -func (b *backend) PopNextOutgoingMsg(ctx context.Context) (courier.Msg, error) { +func (b *backend) PopNextOutgoingMsg(ctx context.Context) (courier.MsgOut, error) { // pop the next message off our queue rc := b.redisPool.Get() defer rc.Close() @@ -180,7 +441,7 @@ func (b *backend) PopNextOutgoingMsg(ctx context.Context) (courier.Msg, error) { } if msgJSON != "" { - dbMsg := &DBMsg{} + dbMsg := &Msg{} err = json.Unmarshal([]byte(msgJSON), dbMsg) if err != nil { queue.MarkComplete(rc, msgQueueName, token) @@ -195,7 +456,7 @@ func (b *backend) PopNextOutgoingMsg(ctx context.Context) (courier.Msg, error) { } dbMsg.Direction_ = MsgOutgoing - dbMsg.channel = channel.(*DBChannel) + dbMsg.channel = channel.(*Channel) dbMsg.workerToken = token // clear out our seen incoming messages @@ -244,36 +505,36 @@ func (b *backend) ClearMsgSent(ctx context.Context, id courier.MsgID) error { } // MarkOutgoingMsgComplete marks the passed in message as having completed processing, freeing up a worker for that channel -func (b *backend) MarkOutgoingMsgComplete(ctx context.Context, msg courier.Msg, status courier.MsgStatus) { +func (b *backend) MarkOutgoingMsgComplete(ctx context.Context, msg courier.MsgOut, status courier.StatusUpdate) { rc := b.redisPool.Get() defer rc.Close() - dbMsg := msg.(*DBMsg) + dbMsg := msg.(*Msg) queue.MarkComplete(rc, msgQueueName, dbMsg.workerToken) // mark as sent in redis as well if this was actually wired or sent - if status != nil && (status.Status() == courier.MsgSent || status.Status() == courier.MsgWired) { + if status != nil && (status.Status() == courier.MsgStatusSent || status.Status() == courier.MsgStatusWired) { dateKey := fmt.Sprintf(sentSetName, time.Now().UTC().Format("2006_01_02")) rc.Send("sadd", dateKey, msg.ID().String()) rc.Send("expire", dateKey, 60*60*24*2) _, err := rc.Do("") if err != nil { - logrus.WithError(err).WithField("sent_msgs_key", dateKey).Error("unable to add new unsent message") + slog.Error("unable to add new unsent message", "error", err, "sent_msgs_key", dateKey) } // if our msg has an associated session and timeout, update that if dbMsg.SessionWaitStartedOn_ != nil { err = updateSessionTimeout(ctx, b, dbMsg.SessionID_, *dbMsg.SessionWaitStartedOn_, dbMsg.SessionTimeout_) if err != nil { - logrus.WithError(err).WithField("session_id", dbMsg.SessionID_).Error("unable to update session timeout") + slog.Error("unable to update session timeout", "error", err, "session_id", dbMsg.SessionID_) } } } } // WriteMsg writes the passed in message to our store -func (b *backend) WriteMsg(ctx context.Context, m courier.Msg, clog *courier.ChannelLog) error { +func (b *backend) WriteMsg(ctx context.Context, m courier.MsgIn, clog *courier.ChannelLog) error { timeout, cancel := context.WithTimeout(ctx, backendTimeout) defer cancel() @@ -281,69 +542,82 @@ func (b *backend) WriteMsg(ctx context.Context, m courier.Msg, clog *courier.Cha } // NewStatusUpdateForID creates a new Status object for the given message id -func (b *backend) NewMsgStatusForID(channel courier.Channel, id courier.MsgID, status courier.MsgStatusValue, clog *courier.ChannelLog) courier.MsgStatus { - return newMsgStatus(channel, id, "", status, clog) +func (b *backend) NewStatusUpdate(channel courier.Channel, id courier.MsgID, status courier.MsgStatus, clog *courier.ChannelLog) courier.StatusUpdate { + return newStatusUpdate(channel, id, "", status, clog) } // NewStatusUpdateForID creates a new Status object for the given message id -func (b *backend) NewMsgStatusForExternalID(channel courier.Channel, externalID string, status courier.MsgStatusValue, clog *courier.ChannelLog) courier.MsgStatus { - return newMsgStatus(channel, courier.NilMsgID, externalID, status, clog) +func (b *backend) NewStatusUpdateByExternalID(channel courier.Channel, externalID string, status courier.MsgStatus, clog *courier.ChannelLog) courier.StatusUpdate { + return newStatusUpdate(channel, courier.NilMsgID, externalID, status, clog) } -// WriteMsgStatus writes the passed in MsgStatus to our store -func (b *backend) WriteMsgStatus(ctx context.Context, status courier.MsgStatus) error { - timeout, cancel := context.WithTimeout(ctx, backendTimeout) - defer cancel() +// WriteStatusUpdate writes the passed in MsgStatus to our store +func (b *backend) WriteStatusUpdate(ctx context.Context, status courier.StatusUpdate) error { + log := slog.With("msg_id", status.MsgID(), "msg_external_id", status.ExternalID(), "status", status.Status()) + su := status.(*StatusUpdate) + + if status.MsgID() == courier.NilMsgID && status.ExternalID() == "" { + return errors.New("message status with no id or external id") + } - if status.HasUpdatedURN() { + // if we have a URN update, do that + oldURN, newURN := status.URNUpdate() + if oldURN != urns.NilURN && newURN != urns.NilURN { err := b.updateContactURN(ctx, status) if err != nil { return errors.Wrap(err, "error updating contact URN") } } - // if we have an ID, we can have our batch commit for us - if status.ID() != courier.NilMsgID { - b.statusWriter.Queue(status.(*DBMsgStatus)) - } else { - // otherwise, write normally (synchronously) - err := writeMsgStatus(timeout, b, status) - if err != nil { - return err + + if status.MsgID() != courier.NilMsgID { + // this is a message we've just sent and were given an external id for + if status.ExternalID() != "" { + rc := b.redisPool.Get() + defer rc.Close() + + err := b.sentExternalIDs.Set(rc, fmt.Sprintf("%d|%s", su.ChannelID_, su.ExternalID_), fmt.Sprintf("%d", status.MsgID())) + if err != nil { + log.Error("error recording external id", "error", err) + } } - } - // if we have an id and are marking an outgoing msg as errored, then clear our sent flag - if status.ID() != courier.NilMsgID && status.Status() == courier.MsgErrored { - err := b.ClearMsgSent(ctx, status.ID()) - if err != nil { - logrus.WithError(err).WithField("msg", status.ID()).Error("error clearing sent flags") + // we sent a message that errored so clear our sent flag to allow it to be retried + if status.Status() == courier.MsgStatusErrored { + err := b.ClearMsgSent(ctx, status.MsgID()) + if err != nil { + log.Error("error clearing sent flags", "error", err) + } } } + // queue the status to written by the batch writer + b.statusWriter.Queue(status.(*StatusUpdate)) + log.Debug("status update queued") + return nil } // updateContactURN updates contact URN according to the old/new URNs from status -func (b *backend) updateContactURN(ctx context.Context, status courier.MsgStatus) error { - old, new := status.UpdatedURN() +func (b *backend) updateContactURN(ctx context.Context, status courier.StatusUpdate) error { + old, new := status.URNUpdate() // retrieve channel channel, err := b.GetChannel(ctx, courier.AnyChannelType, status.ChannelUUID()) if err != nil { return errors.Wrap(err, "error retrieving channel") } - dbChannel := channel.(*DBChannel) + dbChannel := channel.(*Channel) tx, err := b.db.BeginTxx(ctx, nil) if err != nil { return err } // retrieve the old URN - oldContactURN, err := selectContactURN(tx, dbChannel.OrgID(), old) + oldContactURN, err := getContactURNByIdentity(tx, dbChannel.OrgID(), old) if err != nil { return errors.Wrap(err, "error retrieving old contact URN") } // retrieve the new URN - newContactURN, err := selectContactURN(tx, dbChannel.OrgID(), new) + newContactURN, err := getContactURNByIdentity(tx, dbChannel.OrgID(), new) if err != nil { // only update the old URN path if the new URN doesn't exist if err == sql.ErrNoRows { @@ -412,7 +686,7 @@ func (b *backend) SaveAttachment(ctx context.Context, ch courier.Channel, conten filename = fmt.Sprintf("%s.%s", filename, extension) } - orgID := ch.(*DBChannel).OrgID() + orgID := ch.(*Channel).OrgID() path := filepath.Join(b.config.S3AttachmentsPrefix, strconv.FormatInt(int64(orgID), 10), filename[:4], filename[4:8], filename) @@ -444,7 +718,7 @@ func (b *backend) ResolveMedia(ctx context.Context, mediaUrl string) (courier.Me rc := b.redisPool.Get() defer rc.Close() - var media *DBMedia + var media *Media mediaJSON, err := b.mediaCache.Get(rc, mediaUUID) if err != nil { return nil, errors.Wrap(err, "error looking up cached media") @@ -470,6 +744,17 @@ func (b *backend) ResolveMedia(ctx context.Context, mediaUrl string) (courier.Me return media, nil } +func (b *backend) HttpClient(secure bool) *http.Client { + if secure { + return b.httpClient + } + return b.httpClientInsecure +} + +func (b *backend) HttpAccess() *httpx.AccessConfig { + return b.httpAccess +} + // Health returns the health of this backend as a string, returning "" if all is well func (b *backend) Health() string { // test redis @@ -549,16 +834,14 @@ func (b *backend) Heartbeat() error { analytics.Gauge("courier.bulk_queue", float64(bulkSize)) analytics.Gauge("courier.priority_queue", float64(prioritySize)) - logrus.WithFields(logrus.Fields{ - "db_busy": dbStats.InUse, - "db_idle": dbStats.Idle, - "db_wait_time": dbWaitDurationInPeriod, - "db_wait_count": dbWaitCountInPeriod, - "redis_wait_time": dbWaitDurationInPeriod, - "redis_wait_count": dbWaitCountInPeriod, - "priority_size": prioritySize, - "bulk_size": bulkSize, - }).Info("current analytics") + slog.Info("current analytics", "db_busy", dbStats.InUse, + "db_idle", dbStats.Idle, + "db_wait_time", dbWaitDurationInPeriod, + "db_wait_count", dbWaitCountInPeriod, + "redis_wait_time", dbWaitDurationInPeriod, + "redis_wait_count", dbWaitCountInPeriod, + "priority_size", prioritySize, + "bulk_size", bulkSize) return nil } @@ -611,7 +894,7 @@ func (b *backend) Status() string { channel, err := getChannel(context.Background(), b.db, courier.AnyChannelType, channelUUID) channelType := "!!" if err == nil { - channelType = channel.ChannelType().String() + channelType = string(channel.ChannelType()) } // get # of items in our normal queue @@ -632,252 +915,11 @@ func (b *backend) Status() string { return status.String() } -// Start starts our RapidPro backend, this tests our various connections and starts our spool flushers -func (b *backend) Start() error { - // parse and test our redis config - log := logrus.WithFields(logrus.Fields{ - "comp": "backend", - "state": "starting", - }) - log.Info("starting backend") - - // parse and test our db config - dbURL, err := url.Parse(b.config.DB) - if err != nil { - return fmt.Errorf("unable to parse DB URL '%s': %s", b.config.DB, err) - } - - if dbURL.Scheme != "postgres" { - return fmt.Errorf("invalid DB URL: '%s', only postgres is supported", b.config.DB) - } - - // build our db - db, err := sqlx.Open("postgres", b.config.DB) - if err != nil { - return fmt.Errorf("unable to open DB with config: '%s': %s", b.config.DB, err) - } - - // configure our pool - b.db = db - b.db.SetMaxIdleConns(4) - b.db.SetMaxOpenConns(16) - - // try connecting - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - err = b.db.PingContext(ctx) - cancel() - if err != nil { - log.WithError(err).Error("db not reachable") - } else { - log.Info("db ok") - } - - // parse and test our redis config - redisURL, err := url.Parse(b.config.Redis) - if err != nil { - return fmt.Errorf("unable to parse Redis URL '%s': %s", b.config.Redis, err) - } - - // create our pool - redisPool := &redis.Pool{ - Wait: true, // makes callers wait for a connection - MaxActive: 36, // only open this many concurrent connections at once - MaxIdle: 4, // only keep up to this many idle - IdleTimeout: 240 * time.Second, // how long to wait before reaping a connection - Dial: func() (redis.Conn, error) { - conn, err := redis.Dial("tcp", redisURL.Host) - if err != nil { - return nil, err - } - - // send auth if required - if redisURL.User != nil { - pass, authRequired := redisURL.User.Password() - if authRequired { - if _, err := conn.Do("AUTH", pass); err != nil { - conn.Close() - return nil, err - } - } - } - - // switch to the right DB - _, err = conn.Do("SELECT", strings.TrimLeft(redisURL.Path, "/")) - return conn, err - }, - } - b.redisPool = redisPool - - // test our redis connection - conn := redisPool.Get() - defer conn.Close() - _, err = conn.Do("PING") - if err != nil { - log.WithError(err).Error("redis not reachable") - } else { - log.Info("redis ok") - } - - // start our dethrottler if we are going to be doing some sending - if b.config.MaxWorkers > 0 { - queue.StartDethrottler(redisPool, b.stopChan, b.waitGroup, msgQueueName) - } - - // create our storage (S3 or file system) - if b.config.AWSAccessKeyID != "" || b.config.AWSUseCredChain { - s3config := &storage.S3Options{ - AWSAccessKeyID: b.config.AWSAccessKeyID, - AWSSecretAccessKey: b.config.AWSSecretAccessKey, - Endpoint: b.config.S3Endpoint, - Region: b.config.S3Region, - DisableSSL: b.config.S3DisableSSL, - ForcePathStyle: b.config.S3ForcePathStyle, - MaxRetries: 3, - } - if b.config.AWSAccessKeyID != "" && !b.config.AWSUseCredChain { - s3config.AWSAccessKeyID = b.config.AWSAccessKeyID - s3config.AWSSecretAccessKey = b.config.AWSSecretAccessKey - } - s3Client, err := storage.NewS3Client(s3config) - if err != nil { - return err - } - b.attachmentStorage = storage.NewS3(s3Client, b.config.S3AttachmentsBucket, b.config.S3Region, s3.BucketCannedACLPublicRead, 32) - b.logStorage = storage.NewS3(s3Client, b.config.S3LogsBucket, b.config.S3Region, s3.BucketCannedACLPrivate, 32) - } else { - b.attachmentStorage = storage.NewFS(storageDir+"/attachments", 0766) - b.logStorage = storage.NewFS(storageDir+"/logs", 0766) - } - - // check our storages - if err := checkStorage(b.attachmentStorage); err != nil { - log.WithError(err).Error(b.attachmentStorage.Name() + " attachment storage not available") - } else { - log.Info(b.attachmentStorage.Name() + " attachment storage ok") - } - if err := checkStorage(b.logStorage); err != nil { - log.WithError(err).Error(b.logStorage.Name() + " log storage not available") - } else { - log.Info(b.logStorage.Name() + " log storage ok") - } - - // make sure our spool dirs are writable - err = courier.EnsureSpoolDirPresent(b.config.SpoolDir, "msgs") - if err == nil { - err = courier.EnsureSpoolDirPresent(b.config.SpoolDir, "statuses") - } - if err == nil { - err = courier.EnsureSpoolDirPresent(b.config.SpoolDir, "events") - } - if err != nil { - log.WithError(err).Error("spool directories not writable") - } else { - log.Info("spool directories ok") - } - - // create our batched writers and start them - b.statusWriter = NewStatusWriter(b.db, b.config.SpoolDir, b.writerWG) - b.statusWriter.Start() - - b.dbLogWriter = NewDBLogWriter(b.db, b.writerWG) - b.dbLogWriter.Start() - - b.stLogWriter = NewStorageLogWriter(b.logStorage, b.writerWG) - b.stLogWriter.Start() - - // register and start our spool flushers - courier.RegisterFlusher(path.Join(b.config.SpoolDir, "msgs"), b.flushMsgFile) - courier.RegisterFlusher(path.Join(b.config.SpoolDir, "statuses"), b.flushStatusFile) - courier.RegisterFlusher(path.Join(b.config.SpoolDir, "events"), b.flushChannelEventFile) - - logrus.WithFields(logrus.Fields{"comp": "backend", "state": "started"}).Info("backend started") - return nil -} - -// Stop stops our RapidPro backend, closing our db and redis connections -func (b *backend) Stop() error { - // close our stop channel - close(b.stopChan) - - // wait for our threads to exit - b.waitGroup.Wait() - return nil -} - -func (b *backend) Cleanup() error { - // stop our batched writers - if b.statusWriter != nil { - b.statusWriter.Stop() - } - if b.dbLogWriter != nil { - b.dbLogWriter.Stop() - } - if b.stLogWriter != nil { - b.stLogWriter.Stop() - } - - // wait for them to flush fully - b.writerWG.Wait() - - // close our db and redis pool - if b.db != nil { - b.db.Close() - } - return b.redisPool.Close() -} - // RedisPool returns the redisPool for this backend func (b *backend) RedisPool() *redis.Pool { return b.redisPool } -// NewBackend creates a new RapidPro backend -func newBackend(cfg *courier.Config) courier.Backend { - return &backend{ - config: cfg, - - stopChan: make(chan bool), - waitGroup: &sync.WaitGroup{}, - - writerWG: &sync.WaitGroup{}, - - mediaCache: redisx.NewIntervalHash("media-lookups", time.Hour*24, 2), - mediaMutexes: *syncx.NewHashMutex(8), - - seenMsgs: redisx.NewIntervalHash("seen-msgs", time.Second*2, 2), - seenExternalIDs: redisx.NewIntervalHash("seen-external-ids", time.Hour*24, 2), - } -} - -type backend struct { - config *courier.Config - - statusWriter *StatusWriter - dbLogWriter *DBLogWriter // unattached logs being written to the database - stLogWriter *StorageLogWriter // attached logs being written to storage - writerWG *sync.WaitGroup - - db *sqlx.DB - redisPool *redis.Pool - attachmentStorage storage.Storage - logStorage storage.Storage - - stopChan chan bool - waitGroup *sync.WaitGroup - - mediaCache *redisx.IntervalHash - mediaMutexes syncx.HashMutex - - seenMsgs *redisx.IntervalHash - seenExternalIDs *redisx.IntervalHash - - // both sqlx and redis provide wait stats which are cummulative that we need to convert into increments - dbWaitDuration time.Duration - dbWaitCount int64 - redisWaitDuration time.Duration - redisWaitCount int64 -} - func checkStorage(s storage.Storage) error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) err := s.Test(ctx) diff --git a/backends/rapidpro/backend_test.go b/backends/rapidpro/backend_test.go index cf1110b37..2ddf5c777 100644 --- a/backends/rapidpro/backend_test.go +++ b/backends/rapidpro/backend_test.go @@ -2,7 +2,6 @@ package rapidpro import ( "context" - "database/sql" "encoding/base64" "encoding/json" "fmt" @@ -16,6 +15,7 @@ import ( "testing" "time" + "github.com/buger/jsonparser" "github.com/gomodule/redigo/redis" "github.com/lib/pq" "github.com/nyaruka/courier" @@ -23,11 +23,11 @@ import ( "github.com/nyaruka/courier/test" "github.com/nyaruka/gocommon/dbutil/assertdb" "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/null/v2" + "github.com/nyaruka/null/v3" "github.com/nyaruka/redisx/assertredis" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/suite" ) @@ -48,7 +48,7 @@ func (ts *BackendTestSuite) SetupSuite() { storageDir = "_test_storage" // turn off logging - logrus.SetOutput(io.Discard) + log.SetOutput(io.Discard) b, err := courier.NewBackend(testConfig()) if err != nil { @@ -75,11 +75,7 @@ func (ts *BackendTestSuite) SetupSuite() { } ts.b.db.MustExec(string(sql)) - // clear redis - r := ts.b.redisPool.Get() - defer r.Close() - _, err = r.Do("FLUSHDB") - ts.Require().NoError(err) + ts.clearRedis() } func (ts *BackendTestSuite) TearDownSuite() { @@ -91,14 +87,22 @@ func (ts *BackendTestSuite) TearDownSuite() { } } -func (ts *BackendTestSuite) getChannel(cType string, cUUID string) *DBChannel { +func (ts *BackendTestSuite) clearRedis() { + // clear redis + r := ts.b.redisPool.Get() + defer r.Close() + _, err := r.Do("FLUSHDB") + ts.Require().NoError(err) +} + +func (ts *BackendTestSuite) getChannel(cType string, cUUID string) *Channel { channelUUID := courier.ChannelUUID(cUUID) channel, err := ts.b.GetChannel(context.Background(), courier.ChannelType(cType), channelUUID) ts.Require().NoError(err, "error getting channel") ts.Require().NotNil(channel) - return channel.(*DBChannel) + return channel.(*Channel) } func (ts *BackendTestSuite) TestMsgUnmarshal() { @@ -123,7 +127,7 @@ func (ts *BackendTestSuite) TestMsgUnmarshal() { "metadata": {"topic": "event"} }` - msg := DBMsg{} + msg := Msg{} err := json.Unmarshal([]byte(msgJSON), &msg) ts.NoError(err) ts.Equal(courier.ChannelUUID("f3ad3eb6-d00d-4dc3-92e9-9f34f32940ba"), msg.ChannelUUID_) @@ -137,8 +141,6 @@ func (ts *BackendTestSuite) TestMsgUnmarshal() { ts.True(msg.IsResend()) flow_ref := courier.FlowReference{UUID: "9de3663f-c5c5-4c92-9f45-ecbc09abcc85", Name: "Favorites"} ts.Equal(&flow_ref, msg.Flow()) - ts.Equal("Favorites", msg.FlowName()) - ts.Equal("9de3663f-c5c5-4c92-9f45-ecbc09abcc85", msg.FlowUUID()) msgJSONNoQR := `{ "text": "Test message 21", @@ -156,7 +158,7 @@ func (ts *BackendTestSuite) TestMsgUnmarshal() { "metadata": null }` - msg = DBMsg{} + msg = Msg{} err = json.Unmarshal([]byte(msgJSONNoQR), &msg) ts.NoError(err) ts.Nil(msg.Attachments()) @@ -165,67 +167,29 @@ func (ts *BackendTestSuite) TestMsgUnmarshal() { ts.Equal("", msg.ResponseToExternalID()) ts.False(msg.IsResend()) ts.Nil(msg.Flow()) - ts.Equal("", msg.FlowName()) - ts.Equal("", msg.FlowUUID()) -} - -func (ts *BackendTestSuite) TestCheckMsgExists() { - knChannel := ts.getChannel("KN", "dbc126ed-66bc-4e28-b67b-81dc3327c95d") - clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, knChannel, nil) - - // check with invalid message id - err := checkMsgExists(ts.b, ts.b.NewMsgStatusForID(knChannel, -1, courier.MsgStatusValue("S"), clog)) - ts.Equal(err, courier.ErrMsgNotFound) - - // check with valid message id - err = checkMsgExists(ts.b, ts.b.NewMsgStatusForID(knChannel, 10000, courier.MsgStatusValue("S"), clog)) - ts.Nil(err) - - // only outgoing messages are matched - err = checkMsgExists(ts.b, ts.b.NewMsgStatusForID(knChannel, 10002, courier.MsgStatusValue("S"), clog)) - ts.Equal(err, courier.ErrMsgNotFound) - - // check with invalid external id - err = checkMsgExists(ts.b, ts.b.NewMsgStatusForExternalID(knChannel, "ext-invalid", courier.MsgStatusValue("S"), clog)) - ts.Equal(err, courier.ErrMsgNotFound) - - // only outgoing messages are matched - err = checkMsgExists(ts.b, ts.b.NewMsgStatusForExternalID(knChannel, "ext2", courier.MsgStatusValue("S"), clog)) - ts.Equal(err, courier.ErrMsgNotFound) - - // check with valid external id - status := ts.b.NewMsgStatusForExternalID(knChannel, "ext1", courier.MsgStatusValue("S"), clog) - err = checkMsgExists(ts.b, status) - ts.Nil(err) } -func (ts *BackendTestSuite) TestDeleteMsgWithExternalID() { +func (ts *BackendTestSuite) TestDeleteMsgByExternalID() { knChannel := ts.getChannel("KN", "dbc126ed-66bc-4e28-b67b-81dc3327c95d") - ctx := context.Background() - // no error for invalid external ID - err := ts.b.DeleteMsgWithExternalID(ctx, knChannel, "ext-invalid") + ts.clearRedis() + + // noop for invalid external ID + err := ts.b.DeleteMsgByExternalID(ctx, knChannel, "ext-invalid") ts.Nil(err) - // cannot change out going messages - err = ts.b.DeleteMsgWithExternalID(ctx, knChannel, "ext1") + // noop for external ID of outgoing message + err = ts.b.DeleteMsgByExternalID(ctx, knChannel, "ext1") ts.Nil(err) - m := readMsgFromDB(ts.b, 10000) - ts.Equal(m.Text_, "test message") - ts.Equal(len(m.Attachments()), 0) - ts.Equal(m.Visibility_, MsgVisibility("V")) + ts.assertNoQueuedContactTask(ContactID(100)) - // for incoming messages mark them deleted by sender and readact their text and clear their attachments - err = ts.b.DeleteMsgWithExternalID(ctx, knChannel, "ext2") + // a valid external id becomes a queued task + err = ts.b.DeleteMsgByExternalID(ctx, knChannel, "ext2") ts.Nil(err) - m = readMsgFromDB(ts.b, 10002) - ts.Equal(m.Text_, "") - ts.Equal(len(m.Attachments()), 0) - ts.Equal(m.Visibility_, MsgVisibility("X")) - + ts.assertQueuedContactTask(ContactID(100), "msg_deleted", map[string]any{"org_id": float64(1), "msg_id": float64(10002)}) } func (ts *BackendTestSuite) TestContact() { @@ -237,13 +201,13 @@ func (ts *BackendTestSuite) TestContact() { now := time.Now() // create our new contact - contact, err := contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn, "", "Ryan Lewis", clog) + contact, err := contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn, nil, "Ryan Lewis", clog) ts.NoError(err) now2 := time.Now() // load this contact again by URN, should be same contact, name unchanged - contact2, err := contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn, "", "Other Name", clog) + contact2, err := contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn, nil, "Other Name", clog) ts.NoError(err) ts.Equal(contact.UUID_, contact2.UUID_) @@ -257,7 +221,7 @@ func (ts *BackendTestSuite) TestContact() { // load a contact by URN instead (this one is in our testdata) cURN, _ := urns.NewTelURNForCountry("+12067799192", "US") - contact, err = contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, cURN, "", "", clog) + contact, err = contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, cURN, nil, "", clog) ts.NoError(err) ts.NotNil(contact) @@ -269,7 +233,7 @@ func (ts *BackendTestSuite) TestContact() { // long name are truncated longName := "LongRandomNameHPGBRDjZvkz7y58jI2UPkio56IKGaMvaeDTvF74Q5SUkIHozFn1MLELfjX7vRrFto8YG2KPVaWzekgmFbkuxujIotFAgfhHqoHKW5c177FUtKf5YK9KbY8hp0x7PxIFY3MS5lMyMA5ELlqIgikThpr" - contact3, err := contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn, "", longName, clog) + contact3, err := contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn, nil, longName, clog) ts.NoError(err) ts.Equal(null.String(longName[0:127]), contact3.Name_) @@ -287,14 +251,14 @@ func (ts *BackendTestSuite) TestContactRace() { ctx := context.Background() // create our contact twice - var contact1, contact2 *DBContact + var contact1, contact2 *Contact var err1, err2 error go func() { - contact1, err1 = contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn, "", "Ryan Lewis", clog) + contact1, err1 = contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn, nil, "Ryan Lewis", clog) }() go func() { - contact2, err2 = contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn, "", "Ryan Lewis", clog) + contact2, err2 = contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn, nil, "Ryan Lewis", clog) }() time.Sleep(time.Second) @@ -312,26 +276,26 @@ func (ts *BackendTestSuite) TestAddAndRemoveContactURN() { cURN, err := urns.NewTelURNForCountry("+12067799192", "US") ts.NoError(err) - contact, err := contactForURN(ctx, ts.b, knChannel.OrgID_, knChannel, cURN, "", "", clog) + contact, err := contactForURN(ctx, ts.b, knChannel.OrgID_, knChannel, cURN, nil, "", clog) ts.NoError(err) ts.NotNil(contact) tx, err := ts.b.db.Beginx() ts.NoError(err) - contactURNs, err := contactURNsForContact(tx, contact.ID_) + contactURNs, err := getURNsForContact(tx, contact.ID_) ts.NoError(err) ts.Equal(len(contactURNs), 1) urn, _ := urns.NewTelURNForCountry("12065551518", "US") - addedURN, err := ts.b.AddURNtoContact(ctx, knChannel, contact, urn) + addedURN, err := ts.b.AddURNtoContact(ctx, knChannel, contact, urn, nil) ts.NoError(err) ts.NotNil(addedURN) tx, err = ts.b.db.Beginx() ts.NoError(err) - contactURNs, err = contactURNsForContact(tx, contact.ID_) + contactURNs, err = getURNsForContact(tx, contact.ID_) ts.NoError(err) ts.Equal(len(contactURNs), 2) @@ -341,7 +305,7 @@ func (ts *BackendTestSuite) TestAddAndRemoveContactURN() { tx, err = ts.b.db.Beginx() ts.NoError(err) - contactURNs, err = contactURNsForContact(tx, contact.ID_) + contactURNs, err = getURNsForContact(tx, contact.ID_) ts.NoError(err) ts.Equal(len(contactURNs), 1) } @@ -354,33 +318,33 @@ func (ts *BackendTestSuite) TestContactURN() { ctx := context.Background() - contact, err := contactForURN(ctx, ts.b, knChannel.OrgID_, knChannel, urn, "", "", clog) + contact, err := contactForURN(ctx, ts.b, knChannel.OrgID_, knChannel, urn, nil, "", clog) ts.NoError(err) ts.NotNil(contact) tx, err := ts.b.db.Beginx() ts.NoError(err) - contact, err = contactForURN(ctx, ts.b, twChannel.OrgID_, twChannel, urn, "chestnut", "", clog) + contact, err = contactForURN(ctx, ts.b, twChannel.OrgID_, twChannel, urn, map[string]string{"token1": "chestnut"}, "", clog) ts.NoError(err) ts.NotNil(contact) - contactURNs, err := contactURNsForContact(tx, contact.ID_) + contactURNs, err := getURNsForContact(tx, contact.ID_) ts.NoError(err) - ts.Equal(null.String("chestnut"), contactURNs[0].Auth) + ts.Equal(null.Map[string]{"token1": "chestnut"}, contactURNs[0].AuthTokens) // now build a URN for our number with the kannel channel - knURN, err := contactURNForURN(tx, knChannel, contact.ID_, urn, "sesame") + knURN, err := getOrCreateContactURN(tx, knChannel, contact.ID_, urn, map[string]string{"token2": "sesame"}) ts.NoError(err) ts.NoError(tx.Commit()) ts.Equal(knURN.OrgID, knChannel.OrgID_) - ts.Equal(null.String("sesame"), knURN.Auth) + ts.Equal(null.Map[string]{"token1": "chestnut", "token2": "sesame"}, knURN.AuthTokens) tx, err = ts.b.db.Beginx() ts.NoError(err) // then with our twilio channel - twURN, err := contactURNForURN(tx, twChannel, contact.ID_, urn, "") + twURN, err := getOrCreateContactURN(tx, twChannel, contact.ID_, urn, nil) ts.NoError(err) ts.NoError(tx.Commit()) @@ -394,26 +358,26 @@ func (ts *BackendTestSuite) TestContactURN() { ts.Equal(twURN.ChannelID, twChannel.ID()) // auth should be unchanged - ts.Equal(null.String("sesame"), twURN.Auth) + ts.Equal(null.Map[string]{"token1": "chestnut", "token2": "sesame"}, twURN.AuthTokens) tx, err = ts.b.db.Beginx() ts.NoError(err) // again with different auth - twURN, err = contactURNForURN(tx, twChannel, contact.ID_, urn, "peanut") + twURN, err = getOrCreateContactURN(tx, twChannel, contact.ID_, urn, map[string]string{"token3": "peanut"}) ts.NoError(err) ts.NoError(tx.Commit()) - ts.Equal(null.String("peanut"), twURN.Auth) + ts.Equal(null.Map[string]{"token1": "chestnut", "token2": "sesame", "token3": "peanut"}, twURN.AuthTokens) // test that we don't use display when looking up URNs tgChannel := ts.getChannel("TG", "dbc126ed-66bc-4e28-b67b-81dc3327c98a") tgURN, _ := urns.NewTelegramURN(12345, "") - tgContact, err := contactForURN(ctx, ts.b, tgChannel.OrgID_, tgChannel, tgURN, "", "", clog) + tgContact, err := contactForURN(ctx, ts.b, tgChannel.OrgID_, tgChannel, tgURN, nil, "", clog) ts.NoError(err) tgURNDisplay, _ := urns.NewTelegramURN(12345, "Jane") - displayContact, err := contactForURN(ctx, ts.b, tgChannel.OrgID_, tgChannel, tgURNDisplay, "", "", clog) + displayContact, err := contactForURN(ctx, ts.b, tgChannel.OrgID_, tgChannel, tgURNDisplay, nil, "", clog) ts.NoError(err) ts.Equal(tgContact.URNID_, displayContact.URNID_) @@ -422,7 +386,7 @@ func (ts *BackendTestSuite) TestContactURN() { tx, err = ts.b.db.Beginx() ts.NoError(err) - tgContactURN, err := contactURNForURN(tx, tgChannel, tgContact.ID_, tgURNDisplay, "") + tgContactURN, err := getOrCreateContactURN(tx, tgChannel, tgContact.ID_, tgURNDisplay, nil) ts.NoError(err) ts.NoError(tx.Commit()) ts.Equal(tgContact.URNID_, tgContactURN.ID) @@ -431,17 +395,17 @@ func (ts *BackendTestSuite) TestContactURN() { // try to create two contacts at the same time in goroutines, this tests our transaction rollbacks urn2, _ := urns.NewTelURNForCountry("12065551616", "US") var wait sync.WaitGroup - var contact2, contact3 *DBContact + var contact2, contact3 *Contact wait.Add(2) go func() { var err2 error - contact2, err2 = contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn2, "", "", clog) + contact2, err2 = contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn2, nil, "", clog) ts.NoError(err2) wait.Done() }() go func() { var err3 error - contact3, err3 = contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn2, "", "", clog) + contact3, err3 = contactForURN(ctx, ts.b, knChannel.OrgID(), knChannel, urn2, nil, "", clog) ts.NoError(err3) wait.Done() }() @@ -461,19 +425,19 @@ func (ts *BackendTestSuite) TestContactURNPriority() { ctx := context.Background() - knContact, err := contactForURN(ctx, ts.b, knChannel.OrgID_, knChannel, knURN, "", "", clog) + knContact, err := contactForURN(ctx, ts.b, knChannel.OrgID_, knChannel, knURN, nil, "", clog) ts.NoError(err) tx, err := ts.b.db.Beginx() ts.NoError(err) - _, err = contactURNForURN(tx, twChannel, knContact.ID_, twURN, "") + _, err = getOrCreateContactURN(tx, twChannel, knContact.ID_, twURN, nil) ts.NoError(err) ts.NoError(tx.Commit()) // ok, now looking up our contact should reset our URNs and their affinity.. // TwitterURN should be first all all URNs should now use Twitter channel - twContact, err := contactForURN(ctx, ts.b, twChannel.OrgID_, twChannel, twURN, "", "", clog) + twContact, err := contactForURN(ctx, ts.b, twChannel.OrgID_, twChannel, twURN, nil, "", clog) ts.NoError(err) ts.Equal(twContact.ID_, knContact.ID_) @@ -482,7 +446,7 @@ func (ts *BackendTestSuite) TestContactURNPriority() { tx, err = ts.b.db.Beginx() ts.NoError(err) - urns, err := contactURNsForContact(tx, twContact.ID_) + urns, err := getURNsForContact(tx, twContact.ID_) ts.NoError(err) ts.NoError(tx.Commit()) @@ -498,24 +462,24 @@ func (ts *BackendTestSuite) TestMsgStatus() { channel := ts.getChannel("KN", "dbc126ed-66bc-4e28-b67b-81dc3327c95d") now := time.Now().In(time.UTC) - updateStatusByID := func(id courier.MsgID, status courier.MsgStatusValue, newExtID string) *courier.ChannelLog { + updateStatusByID := func(id courier.MsgID, status courier.MsgStatus, newExtID string) *courier.ChannelLog { clog := courier.NewChannelLog(courier.ChannelLogTypeMsgStatus, channel, nil) - statusObj := ts.b.NewMsgStatusForID(channel, id, status, clog) + statusObj := ts.b.NewStatusUpdate(channel, id, status, clog) if newExtID != "" { statusObj.SetExternalID(newExtID) } - err := ts.b.WriteMsgStatus(ctx, statusObj) + err := ts.b.WriteStatusUpdate(ctx, statusObj) ts.NoError(err) - time.Sleep(500 * time.Millisecond) // give committer time to write this + time.Sleep(600 * time.Millisecond) // give committer time to write this return clog } - updateStatusByExtID := func(extID string, status courier.MsgStatusValue) *courier.ChannelLog { + updateStatusByExtID := func(extID string, status courier.MsgStatus) *courier.ChannelLog { clog := courier.NewChannelLog(courier.ChannelLogTypeMsgStatus, channel, nil) - statusObj := ts.b.NewMsgStatusForExternalID(channel, extID, status, clog) - err := ts.b.WriteMsgStatus(ctx, statusObj) + statusObj := ts.b.NewStatusUpdateByExternalID(channel, extID, status, clog) + err := ts.b.WriteStatusUpdate(ctx, statusObj) ts.NoError(err) - time.Sleep(500 * time.Millisecond) // give committer time to write this + time.Sleep(600 * time.Millisecond) // give committer time to write this return clog } @@ -523,10 +487,10 @@ func (ts *BackendTestSuite) TestMsgStatus() { ts.b.db.MustExec(`UPDATE msgs_msg SET status = 'Q', sent_on = NULL WHERE id = $1`, 10001) // update to WIRED using id and provide new external ID - clog1 := updateStatusByID(10001, courier.MsgWired, "ext0") + clog1 := updateStatusByID(10001, courier.MsgStatusWired, "ext0") m := readMsgFromDB(ts.b, 10001) - ts.Equal(courier.MsgWired, m.Status_) + ts.Equal(courier.MsgStatusWired, m.Status_) ts.Equal(null.String("ext0"), m.ExternalID_) ts.True(m.ModifiedOn_.After(now)) ts.True(m.SentOn_.After(now)) @@ -536,37 +500,37 @@ func (ts *BackendTestSuite) TestMsgStatus() { sentOn := *m.SentOn_ // update to SENT using id - clog2 := updateStatusByID(10001, courier.MsgSent, "") + clog2 := updateStatusByID(10001, courier.MsgStatusSent, "") m = readMsgFromDB(ts.b, 10001) - ts.Equal(courier.MsgSent, m.Status_) + ts.Equal(courier.MsgStatusSent, m.Status_) ts.Equal(null.String("ext0"), m.ExternalID_) // no change ts.True(m.ModifiedOn_.After(now)) ts.True(m.SentOn_.Equal(sentOn)) // no change ts.Equal(pq.StringArray([]string{string(clog1.UUID()), string(clog2.UUID())}), m.LogUUIDs) // update to DELIVERED using id - clog3 := updateStatusByID(10001, courier.MsgDelivered, "") + clog3 := updateStatusByID(10001, courier.MsgStatusDelivered, "") m = readMsgFromDB(ts.b, 10001) - ts.Equal(m.Status_, courier.MsgDelivered) + ts.Equal(m.Status_, courier.MsgStatusDelivered) ts.True(m.ModifiedOn_.After(now)) ts.True(m.SentOn_.Equal(sentOn)) // no change ts.Equal(pq.StringArray([]string{string(clog1.UUID()), string(clog2.UUID()), string(clog3.UUID())}), m.LogUUIDs) // no change for incoming messages - updateStatusByID(10002, courier.MsgSent, "") + updateStatusByID(10002, courier.MsgStatusSent, "") m = readMsgFromDB(ts.b, 10002) - ts.Equal(courier.MsgPending, m.Status_) + ts.Equal(courier.MsgStatusPending, m.Status_) ts.Equal(m.ExternalID_, null.String("ext2")) ts.Equal(pq.StringArray(nil), m.LogUUIDs) // update to FAILED using external id - clog5 := updateStatusByExtID("ext1", courier.MsgFailed) + clog5 := updateStatusByExtID("ext1", courier.MsgStatusFailed) m = readMsgFromDB(ts.b, 10000) - ts.Equal(courier.MsgFailed, m.Status_) + ts.Equal(courier.MsgStatusFailed, m.Status_) ts.True(m.ModifiedOn_.After(now)) ts.Nil(m.SentOn_) ts.Equal(pq.StringArray([]string{string(clog5.UUID())}), m.LogUUIDs) @@ -575,20 +539,20 @@ func (ts *BackendTestSuite) TestMsgStatus() { time.Sleep(2 * time.Millisecond) // update to WIRED using external id - clog6 := updateStatusByExtID("ext1", courier.MsgWired) + clog6 := updateStatusByExtID("ext1", courier.MsgStatusWired) m = readMsgFromDB(ts.b, 10000) - ts.Equal(courier.MsgWired, m.Status_) + ts.Equal(courier.MsgStatusWired, m.Status_) ts.True(m.ModifiedOn_.After(now)) ts.True(m.SentOn_.After(now)) sentOn = *m.SentOn_ // update to SENT using external id - updateStatusByExtID("ext1", courier.MsgSent) + updateStatusByExtID("ext1", courier.MsgStatusSent) m = readMsgFromDB(ts.b, 10000) - ts.Equal(courier.MsgSent, m.Status_) + ts.Equal(courier.MsgStatusSent, m.Status_) ts.True(m.ModifiedOn_.After(now)) ts.True(m.SentOn_.Equal(sentOn)) // no change @@ -596,87 +560,77 @@ func (ts *BackendTestSuite) TestMsgStatus() { ts.b.db.MustExec(`UPDATE msgs_msg SET status = 'Q', sent_on = NULL WHERE id IN ($1, $2)`, 10002, 10001) // can skip WIRED and go straight to SENT or DELIVERED - updateStatusByExtID("ext1", courier.MsgSent) - updateStatusByID(10001, courier.MsgDelivered, "") + updateStatusByExtID("ext1", courier.MsgStatusSent) + updateStatusByID(10001, courier.MsgStatusDelivered, "") m = readMsgFromDB(ts.b, 10000) - ts.Equal(courier.MsgSent, m.Status_) + ts.Equal(courier.MsgStatusSent, m.Status_) ts.NotNil(m.SentOn_) m = readMsgFromDB(ts.b, 10001) - ts.Equal(courier.MsgDelivered, m.Status_) + ts.Equal(courier.MsgStatusDelivered, m.Status_) ts.NotNil(m.SentOn_) - // no such external id for outgoing message - status := ts.b.NewMsgStatusForExternalID(channel, "ext2", courier.MsgSent, clog6) - err := ts.b.WriteMsgStatus(ctx, status) - ts.Error(err) - - // no such external id - status = ts.b.NewMsgStatusForExternalID(channel, "ext3", courier.MsgSent, clog6) - err = ts.b.WriteMsgStatus(ctx, status) - ts.Error(err) - // reset our status to sent - status = ts.b.NewMsgStatusForExternalID(channel, "ext1", courier.MsgSent, clog6) - err = ts.b.WriteMsgStatus(ctx, status) + status := ts.b.NewStatusUpdateByExternalID(channel, "ext1", courier.MsgStatusSent, clog6) + err := ts.b.WriteStatusUpdate(ctx, status) ts.NoError(err) time.Sleep(time.Second) // error our msg now = time.Now().In(time.UTC) time.Sleep(2 * time.Millisecond) - status = ts.b.NewMsgStatusForExternalID(channel, "ext1", courier.MsgErrored, clog6) - err = ts.b.WriteMsgStatus(ctx, status) + status = ts.b.NewStatusUpdateByExternalID(channel, "ext1", courier.MsgStatusErrored, clog6) + err = ts.b.WriteStatusUpdate(ctx, status) ts.NoError(err) time.Sleep(time.Second) // give committer time to write this m = readMsgFromDB(ts.b, 10000) - ts.Equal(m.Status_, courier.MsgErrored) + ts.Equal(m.Status_, courier.MsgStatusErrored) ts.Equal(m.ErrorCount_, 1) ts.True(m.ModifiedOn_.After(now)) ts.True(m.NextAttempt_.After(now)) ts.Equal(null.NullString, m.FailedReason_) // second go - status = ts.b.NewMsgStatusForExternalID(channel, "ext1", courier.MsgErrored, clog6) - err = ts.b.WriteMsgStatus(ctx, status) + status = ts.b.NewStatusUpdateByExternalID(channel, "ext1", courier.MsgStatusErrored, clog6) + err = ts.b.WriteStatusUpdate(ctx, status) ts.NoError(err) time.Sleep(time.Second) // give committer time to write this m = readMsgFromDB(ts.b, 10000) - ts.Equal(m.Status_, courier.MsgErrored) + ts.Equal(m.Status_, courier.MsgStatusErrored) ts.Equal(m.ErrorCount_, 2) ts.Equal(null.NullString, m.FailedReason_) // third go - status = ts.b.NewMsgStatusForExternalID(channel, "ext1", courier.MsgErrored, clog6) - err = ts.b.WriteMsgStatus(ctx, status) + status = ts.b.NewStatusUpdateByExternalID(channel, "ext1", courier.MsgStatusErrored, clog6) + err = ts.b.WriteStatusUpdate(ctx, status) time.Sleep(time.Second) // give committer time to write this ts.NoError(err) m = readMsgFromDB(ts.b, 10000) - ts.Equal(m.Status_, courier.MsgFailed) + ts.Equal(m.Status_, courier.MsgStatusFailed) ts.Equal(m.ErrorCount_, 3) ts.Equal(null.String("E"), m.FailedReason_) // update URN when the new doesn't exist tx, _ := ts.b.db.BeginTxx(ctx, nil) oldURN, _ := urns.NewWhatsAppURN("55988776655") - _ = insertContactURN(tx, newDBContactURN(channel.OrgID_, channel.ID_, NilContactID, oldURN, "")) + _ = insertContactURN(tx, newContactURN(channel.OrgID_, channel.ID_, NilContactID, oldURN, nil)) ts.NoError(tx.Commit()) newURN, _ := urns.NewWhatsAppURN("5588776655") - status = ts.b.NewMsgStatusForID(channel, courier.MsgID(10000), courier.MsgSent, clog6) - status.SetUpdatedURN(oldURN, newURN) + status = ts.b.NewStatusUpdate(channel, courier.MsgID(10000), courier.MsgStatusSent, clog6) + status.SetURNUpdate(oldURN, newURN) - ts.NoError(ts.b.WriteMsgStatus(ctx, status)) + ts.NoError(ts.b.WriteStatusUpdate(ctx, status)) tx, _ = ts.b.db.BeginTxx(ctx, nil) - contactURN, err := selectContactURN(tx, channel.OrgID_, newURN) + contactURN, err := getContactURNByIdentity(tx, channel.OrgID_, newURN) ts.NoError(err) ts.Equal(contactURN.Identity, newURN.Identity().String()) @@ -686,19 +640,19 @@ func (ts *BackendTestSuite) TestMsgStatus() { oldURN, _ = urns.NewWhatsAppURN("55999887766") newURN, _ = urns.NewWhatsAppURN("5599887766") tx, _ = ts.b.db.BeginTxx(ctx, nil) - contact, _ := contactForURN(ctx, ts.b, channel.OrgID_, channel, oldURN, "", "", clog6) - _ = insertContactURN(tx, newDBContactURN(channel.OrgID_, channel.ID_, NilContactID, newURN, "")) + contact, _ := contactForURN(ctx, ts.b, channel.OrgID_, channel, oldURN, nil, "", clog6) + _ = insertContactURN(tx, newContactURN(channel.OrgID_, channel.ID_, NilContactID, newURN, nil)) ts.NoError(tx.Commit()) - status = ts.b.NewMsgStatusForID(channel, courier.MsgID(10007), courier.MsgSent, clog6) - status.SetUpdatedURN(oldURN, newURN) + status = ts.b.NewStatusUpdate(channel, courier.MsgID(10007), courier.MsgStatusSent, clog6) + status.SetURNUpdate(oldURN, newURN) - ts.NoError(ts.b.WriteMsgStatus(ctx, status)) + ts.NoError(ts.b.WriteStatusUpdate(ctx, status)) tx, _ = ts.b.db.BeginTxx(ctx, nil) - newContactURN, _ := selectContactURN(tx, channel.OrgID_, newURN) - oldContactURN, _ := selectContactURN(tx, channel.OrgID_, oldURN) + newContactURN, _ := getContactURNByIdentity(tx, channel.OrgID_, newURN) + oldContactURN, _ := getContactURNByIdentity(tx, channel.OrgID_, oldURN) ts.Equal(newContactURN.ContactID, contact.ID_) ts.Equal(oldContactURN.ContactID, NilContactID) @@ -708,25 +662,65 @@ func (ts *BackendTestSuite) TestMsgStatus() { oldURN, _ = urns.NewWhatsAppURN("55988776655") newURN, _ = urns.NewWhatsAppURN("5588776655") tx, _ = ts.b.db.BeginTxx(ctx, nil) - _, _ = contactForURN(ctx, ts.b, channel.OrgID_, channel, oldURN, "", "", clog6) - otherContact, _ := contactForURN(ctx, ts.b, channel.OrgID_, channel, newURN, "", "", clog6) + _, _ = contactForURN(ctx, ts.b, channel.OrgID_, channel, oldURN, nil, "", clog6) + otherContact, _ := contactForURN(ctx, ts.b, channel.OrgID_, channel, newURN, nil, "", clog6) ts.NoError(tx.Commit()) - status = ts.b.NewMsgStatusForID(channel, courier.MsgID(10007), courier.MsgSent, clog6) - status.SetUpdatedURN(oldURN, newURN) + status = ts.b.NewStatusUpdate(channel, courier.MsgID(10007), courier.MsgStatusSent, clog6) + status.SetURNUpdate(oldURN, newURN) - ts.NoError(ts.b.WriteMsgStatus(ctx, status)) + ts.NoError(ts.b.WriteStatusUpdate(ctx, status)) tx, _ = ts.b.db.BeginTxx(ctx, nil) - oldContactURN, _ = selectContactURN(tx, channel.OrgID_, oldURN) - newContactURN, _ = selectContactURN(tx, channel.OrgID_, newURN) + oldContactURN, _ = getContactURNByIdentity(tx, channel.OrgID_, oldURN) + newContactURN, _ = getContactURNByIdentity(tx, channel.OrgID_, newURN) ts.Equal(oldContactURN.ContactID, NilContactID) ts.Equal(newContactURN.ContactID, otherContact.ID_) ts.NoError(tx.Commit()) } +func (ts *BackendTestSuite) TestSentExternalIDCaching() { + r := ts.b.redisPool.Get() + defer r.Close() + + ctx := context.Background() + channel := ts.getChannel("KN", "dbc126ed-66bc-4e28-b67b-81dc3327c95d") + clog := courier.NewChannelLog(courier.ChannelLogTypeMsgSend, channel, nil) + + ts.clearRedis() + + // create a status update from a send which will have id and external id + status1 := ts.b.NewStatusUpdate(channel, 10000, courier.MsgStatusSent, clog) + status1.SetExternalID("ex457") + err := ts.b.WriteStatusUpdate(ctx, status1) + ts.NoError(err) + + // give batcher time to write it + time.Sleep(time.Millisecond * 600) + + keys, err := redis.Strings(r.Do("KEYS", "sent-external-ids:*")) + ts.NoError(err) + ts.Len(keys, 1) + assertredis.HGetAll(ts.T(), ts.b.redisPool, keys[0], map[string]string{"10|ex457": "10000"}) + + // mimic a delay in that status being written by reverting the db changes + ts.b.db.MustExec(`UPDATE msgs_msg SET status = 'W', external_id = NULL WHERE id = 10000`) + + // create a callback status update which only has external id + status2 := ts.b.NewStatusUpdateByExternalID(channel, "ex457", courier.MsgStatusDelivered, clog) + + err = ts.b.WriteStatusUpdate(ctx, status2) + ts.NoError(err) + + // give batcher time to write it + time.Sleep(time.Millisecond * 700) + + // msg status successfully updated in the database + assertdb.Query(ts.T(), ts.b.db, `SELECT status FROM msgs_msg WHERE id = 10000`).Returns("D") +} + func (ts *BackendTestSuite) TestHealth() { // all should be well in test land ts.Equal(ts.b.Health(), "") @@ -747,9 +741,9 @@ func (ts *BackendTestSuite) TestCheckForDuplicate() { urn, _ := urns.NewTelURNForCountry("12065551215", knChannel.Country()) urn2, _ := urns.NewTelURNForCountry("12065551277", knChannel.Country()) - createAndWriteMsg := func(ch courier.Channel, u urns.URN, text, extID string) *DBMsg { + createAndWriteMsg := func(ch courier.Channel, u urns.URN, text, extID string) *Msg { clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, knChannel, nil) - m := ts.b.NewIncomingMsg(ch, u, text, extID, clog).(*DBMsg) + m := ts.b.NewIncomingMsg(ch, u, text, extID, clog).(*Msg) err := ts.b.WriteMsg(ctx, m, clog) ts.NoError(err) return m @@ -758,6 +752,13 @@ func (ts *BackendTestSuite) TestCheckForDuplicate() { msg1 := createAndWriteMsg(knChannel, urn, "ping", "") ts.False(msg1.alreadyWritten) + keys, err := redis.Strings(r.Do("KEYS", "seen-msgs:*")) + ts.NoError(err) + ts.Len(keys, 1) + assertredis.HGetAll(ts.T(), ts.b.redisPool, keys[0], map[string]string{ + "dbc126ed-66bc-4e28-b67b-81dc3327c95d|tel:+12065551215": string(msg1.UUID()) + "|fb826459f96c6e3ee563238d158a24702afbdd78", + }) + // trying again should lead to same UUID msg2 := createAndWriteMsg(knChannel, urn, "ping", "") ts.Equal(msg1.UUID(), msg2.UUID()) @@ -775,7 +776,7 @@ func (ts *BackendTestSuite) TestCheckForDuplicate() { dbMsg.ChannelUUID_ = knChannel.UUID() dbMsg.Text_ = "test" - msgJSON, err := json.Marshal([]interface{}{dbMsg}) + msgJSON, err := json.Marshal([]any{dbMsg}) ts.NoError(err) err = queue.PushOntoQueue(r, msgQueueName, "dbc126ed-66bc-4e28-b67b-81dc3327c95d", 10, string(msgJSON), queue.HighPriority) ts.NoError(err) @@ -819,7 +820,7 @@ func (ts *BackendTestSuite) TestStatus() { ts.NotNil(dbMsg) // serialize our message - msgJSON, err := json.Marshal([]interface{}{dbMsg}) + msgJSON, err := json.Marshal([]any{dbMsg}) ts.NoError(err) err = queue.PushOntoQueue(r, msgQueueName, "dbc126ed-66bc-4e28-b67b-81dc3327c95d", 10, string(msgJSON), queue.HighPriority) @@ -840,7 +841,7 @@ func (ts *BackendTestSuite) TestOutgoingQueue() { ts.NotNil(dbMsg) // serialize our message - msgJSON, err := json.Marshal([]interface{}{dbMsg}) + msgJSON, err := json.Marshal([]any{dbMsg}) ts.NoError(err) err = queue.PushOntoQueue(r, msgQueueName, "dbc126ed-66bc-4e28-b67b-81dc3327c95d", 10, string(msgJSON), queue.HighPriority) @@ -860,7 +861,7 @@ func (ts *BackendTestSuite) TestOutgoingQueue() { ts.Equal(msg.Text(), "test message") // mark this message as dealt with - ts.b.MarkOutgoingMsgComplete(ctx, msg, ts.b.NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgWired, clog)) + ts.b.MarkOutgoingMsgComplete(ctx, msg, ts.b.NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusWired, clog)) // this message should now be marked as sent sent, err := ts.b.WasMsgSent(ctx, msg.ID()) @@ -879,7 +880,7 @@ func (ts *BackendTestSuite) TestOutgoingQueue() { ts.False(sent) // write an error for our original message - err = ts.b.WriteMsgStatus(ctx, ts.b.NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog)) + err = ts.b.WriteStatusUpdate(ctx, ts.b.NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog)) ts.NoError(err) // message should no longer be considered sent @@ -1013,11 +1014,11 @@ func (ts *BackendTestSuite) TestWriteChanneLog() { assertdb.Query(ts.T(), ts.b.db, `SELECT count(*) FROM channels_channellog`).Returns(1) assertdb.Query(ts.T(), ts.b.db, `SELECT channel_id, http_logs->0->>'url' AS url, errors->0->>'message' AS err FROM channels_channellog`). - Columns(map[string]interface{}{"channel_id": int64(channel.ID()), "url": "https://api.messages.com/send.json", "err": "Unexpected response status code."}) + Columns(map[string]any{"channel_id": int64(channel.ID()), "url": "https://api.messages.com/send.json", "err": "Unexpected response status code."}) clog2 := courier.NewChannelLog(courier.ChannelLogTypeMsgSend, channel, nil) clog2.HTTP(trace) - clog2.SetMsgID(1234) + clog2.SetAttached(true) // log is attached to a message so will be written to storage err = ts.b.WriteChannelLog(ctx, clog2) @@ -1089,7 +1090,7 @@ func (ts *BackendTestSuite) TestWriteMsg() { // create a new courier msg urn, _ := urns.NewTelURNForCountry("12065551212", knChannel.Country()) - msg := ts.b.NewIncomingMsg(knChannel, urn, "test123", "ext123", clog).WithReceivedOn(now).WithContactName("test contact").(*DBMsg) + msg := ts.b.NewIncomingMsg(knChannel, urn, "test123", "ext123", clog).WithReceivedOn(now).WithContactName("test contact").(*Msg) // try to write it to our db err := ts.b.WriteMsg(ctx, msg, clog) @@ -1097,7 +1098,7 @@ func (ts *BackendTestSuite) TestWriteMsg() { // creating the incoming msg again should give us the same UUID and have the msg set as not to write time.Sleep(1 * time.Second) - msg2 := ts.b.NewIncomingMsg(knChannel, urn, "test123", "ext123", clog).(*DBMsg) + msg2 := ts.b.NewIncomingMsg(knChannel, urn, "test123", "ext123", clog).(*Msg) ts.Equal(msg2.UUID(), msg.UUID()) ts.True(msg2.alreadyWritten) @@ -1111,7 +1112,7 @@ func (ts *BackendTestSuite) TestWriteMsg() { ts.NoError(err) // load our URN - contactURN, err := contactURNForURN(tx, m.channel, m.ContactID_, urn, "") + contactURN, err := getOrCreateContactURN(tx, m.channel, m.ContactID_, urn, nil) if !ts.NoError(err) || !ts.NoError(tx.Commit()) { ts.FailNow("failed writing contact urn") } @@ -1123,7 +1124,7 @@ func (ts *BackendTestSuite) TestWriteMsg() { ts.Equal(contactURN.ContactID, m.ContactID_) ts.Equal(contactURN.ID, m.ContactURNID_) ts.Equal(MsgIncoming, m.Direction_) - ts.Equal(courier.MsgPending, m.Status_) + ts.Equal(courier.MsgStatusPending, m.Status_) ts.False(m.HighPriority_) ts.Equal("ext123", m.ExternalID()) ts.Equal("test123", m.Text_) @@ -1136,7 +1137,7 @@ func (ts *BackendTestSuite) TestWriteMsg() { ts.NotNil(m.ModifiedOn_) ts.NotNil(m.QueuedOn_) - contact, err := contactForURN(ctx, ts.b, m.OrgID_, knChannel, urn, "", "", clog) + contact, err := contactForURN(ctx, ts.b, m.OrgID_, knChannel, urn, nil, "", clog) ts.NoError(err) ts.Equal(null.String("test contact"), contact.Name_) ts.Equal(m.OrgID_, contact.OrgID_) @@ -1146,49 +1147,28 @@ func (ts *BackendTestSuite) TestWriteMsg() { // waiting 5 seconds should let us write it successfully time.Sleep(5 * time.Second) - msg3 := ts.b.NewIncomingMsg(knChannel, urn, "test123", "", clog).(*DBMsg) + msg3 := ts.b.NewIncomingMsg(knChannel, urn, "test123", "", clog).(*Msg) ts.NotEqual(msg3.UUID(), msg.UUID()) // msg with null bytes in it, that's fine for a request body - msg = ts.b.NewIncomingMsg(knChannel, urn, "test456\x00456", "ext456", clog).(*DBMsg) + msg = ts.b.NewIncomingMsg(knChannel, urn, "test456\x00456", "ext456", clog).(*Msg) err = writeMsgToDB(ctx, ts.b, msg, clog) ts.NoError(err) // more null bytes text, _ := url.PathUnescape("%1C%00%00%00%00%00%07%E0%00") - msg = ts.b.NewIncomingMsg(knChannel, urn, text, "", clog).(*DBMsg) + msg = ts.b.NewIncomingMsg(knChannel, urn, text, "", clog).(*Msg) err = writeMsgToDB(ctx, ts.b, msg, clog) ts.NoError(err) - // check that our mailroom queue has an item - rc := ts.b.redisPool.Get() - defer rc.Close() - rc.Do("DEL", "handler:1", "handler:active", fmt.Sprintf("c:1:%d", msg.ContactID_)) + ts.clearRedis() - msg = ts.b.NewIncomingMsg(knChannel, urn, "hello 1 2 3", "", clog).(*DBMsg) + // check that our mailroom queue has an item + msg = ts.b.NewIncomingMsg(knChannel, urn, "hello 1 2 3", "", clog).(*Msg) err = writeMsgToDB(ctx, ts.b, msg, clog) ts.NoError(err) - count, err := redis.Int(rc.Do("ZCARD", "handler:1")) - ts.NoError(err) - ts.Equal(1, count) - - count, err = redis.Int(rc.Do("ZCARD", "handler:active")) - ts.NoError(err) - ts.Equal(1, count) - - count, err = redis.Int(rc.Do("LLEN", fmt.Sprintf("c:1:%d", msg.ContactID_))) - ts.NoError(err) - ts.Equal(1, count) - - data, err := redis.Bytes(rc.Do("LPOP", fmt.Sprintf("c:1:%d", contact.ID_))) - ts.NoError(err) - - var body map[string]interface{} - err = json.Unmarshal(data, &body) - ts.NoError(err) - ts.Equal("msg_event", body["type"]) - ts.Equal(map[string]interface{}{ + ts.assertQueuedContactTask(msg.ContactID_, "msg_event", map[string]any{ "contact_id": float64(contact.ID_), "org_id": float64(1), "channel_id": float64(10), @@ -1200,7 +1180,7 @@ func (ts *BackendTestSuite) TestWriteMsg() { "text": msg.Text(), "attachments": nil, "new_contact": contact.IsNew_, - }, body["task"]) + }) } func (ts *BackendTestSuite) TestWriteMsgWithAttachments() { @@ -1213,7 +1193,7 @@ func (ts *BackendTestSuite) TestWriteMsgWithAttachments() { clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, knChannel, nil) urn, _ := urns.NewTelURNForCountry("12065551218", knChannel.Country()) - msg := ts.b.NewIncomingMsg(knChannel, urn, "two regular attachments", "", clog).(*DBMsg) + msg := ts.b.NewIncomingMsg(knChannel, urn, "two regular attachments", "", clog).(*Msg) msg.WithAttachment("http://example.com/test.jpg") msg.WithAttachment("http://example.com/test.m4a") @@ -1223,7 +1203,7 @@ func (ts *BackendTestSuite) TestWriteMsgWithAttachments() { ts.Equal([]string{"http://example.com/test.jpg", "http://example.com/test.m4a"}, msg.Attachments()) // try an embedded attachment - msg = ts.b.NewIncomingMsg(knChannel, urn, "embedded attachment data", "", clog).(*DBMsg) + msg = ts.b.NewIncomingMsg(knChannel, urn, "embedded attachment data", "", clog).(*Msg) msg.WithAttachment(fmt.Sprintf("data:%s", base64.StdEncoding.EncodeToString(test.ReadFile("../../test/testdata/test.jpg")))) // should have actually fetched and saved it to storage, with the correct content type @@ -1232,14 +1212,14 @@ func (ts *BackendTestSuite) TestWriteMsgWithAttachments() { ts.Equal([]string{"image/jpeg:_test_storage/attachments/media/1/9b95/5e36/9b955e36-ac16-4c6b-8ab6-9b9af5cd042a.jpg"}, msg.Attachments()) // try an invalid embedded attachment - msg = ts.b.NewIncomingMsg(knChannel, urn, "invalid embedded attachment data", "", clog).(*DBMsg) + msg = ts.b.NewIncomingMsg(knChannel, urn, "invalid embedded attachment data", "", clog).(*Msg) msg.WithAttachment("data:34564363576573573") err = ts.b.WriteMsg(ctx, msg, clog) ts.EqualError(err, "unable to decode attachment data: illegal base64 data at input byte 16") // try a geo attachment - msg = ts.b.NewIncomingMsg(knChannel, urn, "geo attachment", "", clog).(*DBMsg) + msg = ts.b.NewIncomingMsg(knChannel, urn, "geo attachment", "", clog).(*Msg) msg.WithAttachment("geo:123.234,-45.676") // should be saved as is @@ -1257,7 +1237,7 @@ func (ts *BackendTestSuite) TestPreferredChannelCheckRole() { now := time.Now().Round(time.Microsecond).In(time.UTC) urn, _ := urns.NewTelURNForCountry("12065552020", exChannel.Country()) - msg := ts.b.NewIncomingMsg(exChannel, urn, "test123", "ext123", clog).WithReceivedOn(now).WithContactName("test contact").(*DBMsg) + msg := ts.b.NewIncomingMsg(exChannel, urn, "test123", "ext123", clog).WithReceivedOn(now).WithContactName("test contact").(*Msg) // try to write it to our db err := ts.b.WriteMsg(ctx, msg, clog) @@ -1272,7 +1252,7 @@ func (ts *BackendTestSuite) TestPreferredChannelCheckRole() { ts.NoError(err) // load our URN - exContactURN, err := contactURNForURN(tx, m.channel, m.ContactID_, urn, "") + exContactURN, err := getOrCreateContactURN(tx, m.channel, m.ContactID_, urn, nil) if !ts.NoError(err) || !ts.NoError(tx.Commit()) { ts.FailNow("failed writing contact urn") } @@ -1286,21 +1266,30 @@ func (ts *BackendTestSuite) TestChannelEvent() { clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, channel, nil) urn, _ := urns.NewTelURNForCountry("12065551616", channel.Country()) - event := ts.b.NewChannelEvent(channel, courier.Referral, urn, clog).WithExtra(map[string]interface{}{"ref_id": "12345"}).WithContactName("kermit frog") + event := ts.b.NewChannelEvent(channel, courier.EventTypeReferral, urn, clog).WithExtra(map[string]string{"ref_id": "12345"}).WithContactName("kermit frog") err := ts.b.WriteChannelEvent(ctx, event, clog) ts.NoError(err) - contact, err := contactForURN(ctx, ts.b, channel.OrgID_, channel, urn, "", "", clog) + contact, err := contactForURN(ctx, ts.b, channel.OrgID_, channel, urn, nil, "", clog) ts.NoError(err) ts.Equal(null.String("kermit frog"), contact.Name_) - dbE := event.(*DBChannelEvent) - dbE, err = readChannelEventFromDB(ts.b, dbE.ID_) - ts.NoError(err) - ts.Equal(dbE.EventType_, courier.Referral) - ts.Equal(map[string]interface{}{"ref_id": "12345"}, dbE.Extra()) + dbE := event.(*ChannelEvent) + dbE = readChannelEventFromDB(ts.b, dbE.ID_) + ts.Equal(dbE.EventType_, courier.EventTypeReferral) + ts.Equal(map[string]string{"ref_id": "12345"}, dbE.Extra()) ts.Equal(contact.ID_, dbE.ContactID_) ts.Equal(contact.URNID_, dbE.ContactURNID_) + + event = ts.b.NewChannelEvent(channel, courier.EventTypeOptIn, urn, clog).WithExtra(map[string]string{"title": "Polls", "payload": "1"}) + err = ts.b.WriteChannelEvent(ctx, event, clog) + ts.NoError(err) + + dbE = event.(*ChannelEvent) + dbE = readChannelEventFromDB(ts.b, dbE.ID_) + ts.Equal(dbE.EventType_, courier.EventTypeOptIn) + ts.Equal(map[string]string{"title": "Polls", "payload": "1"}, dbE.Extra()) + ts.Equal(null.Int(1), dbE.OptInID_) } func (ts *BackendTestSuite) TestSessionTimeout() { @@ -1320,60 +1309,40 @@ func (ts *BackendTestSuite) TestSessionTimeout() { func (ts *BackendTestSuite) TestMailroomEvents() { ctx := context.Background() - rc := ts.b.redisPool.Get() - defer rc.Close() - rc.Do("FLUSHDB") + ts.clearRedis() channel := ts.getChannel("KN", "dbc126ed-66bc-4e28-b67b-81dc3327c95d") clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, channel, nil) urn, _ := urns.NewTelURNForCountry("12065551616", channel.Country()) - event := ts.b.NewChannelEvent(channel, courier.Referral, urn, clog).WithExtra(map[string]interface{}{"ref_id": "12345"}). + event := ts.b.NewChannelEvent(channel, courier.EventTypeReferral, urn, clog). + WithExtra(map[string]string{"ref_id": "12345"}). WithContactName("kermit frog"). WithOccurredOn(time.Date(2020, 8, 5, 13, 30, 0, 123456789, time.UTC)) err := ts.b.WriteChannelEvent(ctx, event, clog) ts.NoError(err) - contact, err := contactForURN(ctx, ts.b, channel.OrgID_, channel, urn, "", "", clog) + contact, err := contactForURN(ctx, ts.b, channel.OrgID_, channel, urn, nil, "", clog) ts.NoError(err) ts.Equal(null.String("kermit frog"), contact.Name_) + ts.False(contact.IsNew_) - dbE := event.(*DBChannelEvent) - dbE, err = readChannelEventFromDB(ts.b, dbE.ID_) - ts.NoError(err) - ts.Equal(dbE.EventType_, courier.Referral) - ts.Equal(map[string]interface{}{"ref_id": "12345"}, dbE.Extra()) + dbE := event.(*ChannelEvent) + dbE = readChannelEventFromDB(ts.b, dbE.ID_) + ts.Equal(dbE.EventType_, courier.EventTypeReferral) + ts.Equal(map[string]string{"ref_id": "12345"}, dbE.Extra()) ts.Equal(contact.ID_, dbE.ContactID_) ts.Equal(contact.URNID_, dbE.ContactURNID_) - count, err := redis.Int(rc.Do("ZCARD", "handler:1")) - ts.NoError(err) - ts.Equal(1, count) - - count, err = redis.Int(rc.Do("ZCARD", "handler:active")) - ts.NoError(err) - ts.Equal(1, count) - - count, err = redis.Int(rc.Do("LLEN", fmt.Sprintf("c:1:%d", contact.ID_))) - ts.NoError(err) - ts.Equal(1, count) - - data, err := redis.Bytes(rc.Do("LPOP", fmt.Sprintf("c:1:%d", contact.ID_))) - ts.NoError(err) - - var body map[string]interface{} - err = json.Unmarshal(data, &body) - ts.NoError(err) - ts.Equal("referral", body["type"]) - ts.Equal(map[string]interface{}{ + ts.assertQueuedContactTask(contact.ID_, "referral", map[string]any{ "channel_id": float64(10), "contact_id": float64(contact.ID_), - "extra": map[string]interface{}{"ref_id": "12345"}, + "extra": map[string]any{"ref_id": "12345"}, "new_contact": contact.IsNew_, "occurred_on": "2020-08-05T13:30:00.123456789Z", "org_id": float64(1), "urn_id": float64(contact.URNID_), - }, body["task"]) + }) } func (ts *BackendTestSuite) TestResolveMedia() { @@ -1386,7 +1355,7 @@ func (ts *BackendTestSuite) TestResolveMedia() { }{ { // image upload that can be resolved url: "http://nyaruka.s3.com/orgs/1/media/ec69/ec6972be-809c-4c8d-be59-ba9dbd74c977/test.jpg", - media: &DBMedia{ + media: &Media{ UUID_: "ec6972be-809c-4c8d-be59-ba9dbd74c977", Path_: "/orgs/1/media/ec69/ec6972be-809c-4c8d-be59-ba9dbd74c977/test.jpg", ContentType_: "image/jpeg", @@ -1394,12 +1363,12 @@ func (ts *BackendTestSuite) TestResolveMedia() { Size_: 123, Width_: 1024, Height_: 768, - Alternates_: []*DBMedia{}, + Alternates_: []*Media{}, }, }, { // same image upload, this time from cache url: "http://nyaruka.s3.com/orgs/1/media/ec69/ec6972be-809c-4c8d-be59-ba9dbd74c977/test.jpg", - media: &DBMedia{ + media: &Media{ UUID_: "ec6972be-809c-4c8d-be59-ba9dbd74c977", Path_: "/orgs/1/media/ec69/ec6972be-809c-4c8d-be59-ba9dbd74c977/test.jpg", ContentType_: "image/jpeg", @@ -1407,7 +1376,7 @@ func (ts *BackendTestSuite) TestResolveMedia() { Size_: 123, Width_: 1024, Height_: 768, - Alternates_: []*DBMedia{}, + Alternates_: []*Media{}, }, }, { // image upload that can't be resolved @@ -1428,14 +1397,14 @@ func (ts *BackendTestSuite) TestResolveMedia() { }, { // audio upload url: "http://nyaruka.s3.com/orgs/1/media/5310/5310f50f-9c8e-4035-9150-be5a1f78f21a/test.mp3", - media: &DBMedia{ + media: &Media{ UUID_: "5310f50f-9c8e-4035-9150-be5a1f78f21a", Path_: "/orgs/1/media/5310/5310f50f-9c8e-4035-9150-be5a1f78f21a/test.mp3", ContentType_: "audio/mp3", URL_: "http://nyaruka.s3.com/orgs/1/media/5310/5310f50f-9c8e-4035-9150-be5a1f78f21a/test.mp3", Size_: 123, Duration_: 500, - Alternates_: []*DBMedia{ + Alternates_: []*Media{ { UUID_: "514c552c-e585-40e2-938a-fe9450172da8", Path_: "/orgs/1/media/514c/514c552c-e585-40e2-938a-fe9450172da8/test.m4a", @@ -1467,6 +1436,32 @@ func (ts *BackendTestSuite) TestResolveMedia() { assertredis.HLen(ts.T(), ts.b.redisPool, fmt.Sprintf("media-lookups:%s", time.Now().In(time.UTC).Format("2006-01-02")), 3) } +func (ts *BackendTestSuite) assertNoQueuedContactTask(contactID ContactID) { + assertredis.ZCard(ts.T(), ts.b.redisPool, "handler:1", 0) + assertredis.ZCard(ts.T(), ts.b.redisPool, "handler:active", 0) + assertredis.LLen(ts.T(), ts.b.redisPool, fmt.Sprintf("c:1:%d", contactID), 0) +} + +func (ts *BackendTestSuite) assertQueuedContactTask(contactID ContactID, expectedType string, expectedBody map[string]any) { + assertredis.ZCard(ts.T(), ts.b.redisPool, "handler:1", 1) + assertredis.ZCard(ts.T(), ts.b.redisPool, "handler:active", 1) + assertredis.LLen(ts.T(), ts.b.redisPool, fmt.Sprintf("c:1:%d", contactID), 1) + + rc := ts.b.redisPool.Get() + defer rc.Close() + + data, err := redis.Bytes(rc.Do("LPOP", fmt.Sprintf("c:1:%d", contactID))) + ts.NoError(err) + + // created_on is usually DB time so exclude it from task body comparison + data = jsonparser.Delete(data, "task", "created_on") + + var body map[string]any + jsonx.MustUnmarshal(data, &body) + ts.Equal(expectedType, body["type"]) + ts.Equal(expectedBody, body["task"]) +} + func TestMsgSuite(t *testing.T) { suite.Run(t, new(BackendTestSuite)) } @@ -1501,8 +1496,8 @@ type ServerTestSuite struct { } // for testing only, returned DBMsg object is not fully populated -func readMsgFromDB(b *backend, id courier.MsgID) *DBMsg { - m := &DBMsg{ +func readMsgFromDB(b *backend, id courier.MsgID) *Msg { + m := &Msg{ ID_: id, } err := b.db.Get(m, sqlSelectMsg, id) @@ -1510,7 +1505,7 @@ func readMsgFromDB(b *backend, id courier.MsgID) *DBMsg { panic(err) } - ch := &DBChannel{ + ch := &Channel{ ID_: m.ChannelID_, } err = b.db.Get(ch, selectChannelSQL, m.ChannelID_) @@ -1522,25 +1517,63 @@ func readMsgFromDB(b *backend, id courier.MsgID) *DBMsg { return m } -const selectMsgIDForID = ` -SELECT m."id" FROM "msgs_msg" m INNER JOIN "channels_channel" c ON (m."channel_id" = c."id") WHERE (m."id" = $1 AND c."uuid" = $2 AND m."direction" = 'O')` - -const selectMsgIDForExternalID = ` -SELECT m."id" FROM "msgs_msg" m INNER JOIN "channels_channel" c ON (m."channel_id" = c."id") WHERE (m."external_id" = $1 AND c."uuid" = $2 AND m."direction" = 'O')` - -func checkMsgExists(b *backend, status courier.MsgStatus) (err error) { - var id int64 - - if status.ID() != courier.NilMsgID { - err = b.db.QueryRow(selectMsgIDForID, status.ID(), status.ChannelUUID()).Scan(&id) - } else if status.ExternalID() != "" { - err = b.db.QueryRow(selectMsgIDForExternalID, status.ExternalID(), status.ChannelUUID()).Scan(&id) - } else { - return fmt.Errorf("no id or external id for status update") - } - - if err == sql.ErrNoRows { - return courier.ErrMsgNotFound +const sqlSelectMsg = ` +SELECT + org_id, + direction, + text, + attachments, + quick_replies, + msg_count, + error_count, + failed_reason, + high_priority, + status, + visibility, + external_id, + channel_id, + contact_id, + contact_urn_id, + created_on, + modified_on, + next_attempt, + queued_on, + sent_on, + log_uuids +FROM + msgs_msg +WHERE + id = $1` + +const selectChannelSQL = ` +SELECT + org_id, + ch.id as id, + ch.uuid as uuid, + ch.name as name, + channel_type, schemes, + address, role, + ch.country as country, + ch.config as config, + org.config as org_config, + org.is_anon as org_is_anon +FROM + channels_channel ch + JOIN orgs_org org on ch.org_id = org.id +WHERE + ch.id = $1 +` + +const sqlSelectEvent = ` +SELECT id, org_id, channel_id, contact_id, contact_urn_id, event_type, optin_id, extra, occurred_on, created_on, log_uuids + FROM channels_channelevent + WHERE id = $1` + +func readChannelEventFromDB(b *backend, id ChannelEventID) *ChannelEvent { + e := &ChannelEvent{} + err := b.db.Get(e, sqlSelectEvent, id) + if err != nil { + panic(err) } - return err + return e } diff --git a/backends/rapidpro/channel.go b/backends/rapidpro/channel.go index eeb2338f8..39a4e51fd 100644 --- a/backends/rapidpro/channel.go +++ b/backends/rapidpro/channel.go @@ -12,7 +12,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/lib/pq" "github.com/nyaruka/courier" - "github.com/nyaruka/null/v2" + "github.com/nyaruka/null/v3" ) type LogPolicy string @@ -23,9 +23,137 @@ const ( LogPolicyAll = "A" ) +// Channel is the RapidPro specific concrete type satisfying the courier.Channel interface +type Channel struct { + OrgID_ OrgID `db:"org_id"` + UUID_ courier.ChannelUUID `db:"uuid"` + ID_ courier.ChannelID `db:"id"` + ChannelType_ courier.ChannelType `db:"channel_type"` + Schemes_ pq.StringArray `db:"schemes"` + Name_ sql.NullString `db:"name"` + Address_ sql.NullString `db:"address"` + Country_ sql.NullString `db:"country"` + Config_ null.Map[any] `db:"config"` + Role_ string `db:"role"` + LogPolicy LogPolicy `db:"log_policy"` + + OrgConfig_ null.Map[any] `db:"org_config"` + OrgIsAnon_ bool `db:"org_is_anon"` + + expiration time.Time +} + +func (c *Channel) ID() courier.ChannelID { return c.ID_ } +func (c *Channel) UUID() courier.ChannelUUID { return c.UUID_ } +func (c *Channel) OrgID() OrgID { return c.OrgID_ } +func (c *Channel) OrgIsAnon() bool { return c.OrgIsAnon_ } +func (c *Channel) ChannelType() courier.ChannelType { return c.ChannelType_ } +func (c *Channel) Name() string { return c.Name_.String } +func (c *Channel) Schemes() []string { return []string(c.Schemes_) } +func (c *Channel) Address() string { return c.Address_.String } + +// ChannelAddress returns the address of this channel +func (c *Channel) ChannelAddress() courier.ChannelAddress { + if !c.Address_.Valid { + return courier.NilChannelAddress + } + + return courier.ChannelAddress(c.Address_.String) +} + +// Country returns the country code for this channel if any +func (c *Channel) Country() string { return c.Country_.String } + +// IsScheme returns whether this channel serves only the passed in scheme +func (c *Channel) IsScheme(scheme string) bool { + return len(c.Schemes_) == 1 && c.Schemes_[0] == scheme +} + +// Roles returns the roles of this channel +func (c *Channel) Roles() []courier.ChannelRole { + roles := []courier.ChannelRole{} + for _, char := range strings.Split(c.Role_, "") { + roles = append(roles, courier.ChannelRole(char)) + } + return roles +} + +// HasRole returns whether the passed in channel supports the passed role +func (c *Channel) HasRole(role courier.ChannelRole) bool { + for _, r := range c.Roles() { + if r == role { + return true + } + } + return false +} + +// ConfigForKey returns the config value for the passed in key, or defaultValue if it isn't found +func (c *Channel) ConfigForKey(key string, defaultValue any) any { + value, found := c.Config_[key] + if !found { + return defaultValue + } + return value +} + +// OrgConfigForKey returns the org config value for the passed in key, or defaultValue if it isn't found +func (c *Channel) OrgConfigForKey(key string, defaultValue any) any { + value, found := c.OrgConfig_[key] + if !found { + return defaultValue + } + return value +} + +// StringConfigForKey returns the config value for the passed in key, or defaultValue if it isn't found +func (c *Channel) StringConfigForKey(key string, defaultValue string) string { + val := c.ConfigForKey(key, defaultValue) + str, isStr := val.(string) + if !isStr { + return defaultValue + } + return str +} + +// BoolConfigForKey returns the config value for the passed in key, or defaultValue if it isn't found +func (c *Channel) BoolConfigForKey(key string, defaultValue bool) bool { + val := c.ConfigForKey(key, defaultValue) + b, isBool := val.(bool) + if !isBool { + return defaultValue + } + return b +} + +// IntConfigForKey returns the config value for the passed in key +func (c *Channel) IntConfigForKey(key string, defaultValue int) int { + val := c.ConfigForKey(key, defaultValue) + + // golang unmarshals number literals in JSON into float64s by default + f, isFloat := val.(float64) + if isFloat { + return int(f) + } + + str, isStr := val.(string) + if isStr { + i, err := strconv.Atoi(str) + if err == nil { + return i + } + } + return defaultValue +} + +// CallbackDomain is convenience utility to get the callback domain configured for this channel +func (c *Channel) CallbackDomain(fallbackDomain string) string { + return c.StringConfigForKey(courier.ConfigCallbackDomain, fallbackDomain) +} + // getChannel will look up the channel with the passed in UUID and channel type. // It will return an error if the channel does not exist or is not active. -func getChannel(ctx context.Context, db *sqlx.DB, channelType courier.ChannelType, channelUUID courier.ChannelUUID) (*DBChannel, error) { +func getChannel(ctx context.Context, db *sqlx.DB, channelType courier.ChannelType, channelUUID courier.ChannelUUID) (*Channel, error) { // look for the channel locally cachedChannel, localErr := getCachedChannel(channelType, channelUUID) @@ -78,8 +206,8 @@ SELECT WHERE c.uuid = $1 AND c.is_active = TRUE AND c.org_id IS NOT NULL` // ChannelForUUID attempts to look up the channel with the passed in UUID, returning it -func loadChannelFromDB(ctx context.Context, db *sqlx.DB, channelType courier.ChannelType, uuid courier.ChannelUUID) (*DBChannel, error) { - channel := &DBChannel{UUID_: uuid} +func loadChannelFromDB(ctx context.Context, db *sqlx.DB, channelType courier.ChannelType, uuid courier.ChannelUUID) (*Channel, error) { + channel := &Channel{UUID_: uuid} // select just the fields we need err := db.GetContext(ctx, channel, sqlLookupChannelFromUUID, uuid) @@ -104,7 +232,7 @@ func loadChannelFromDB(ctx context.Context, db *sqlx.DB, channelType courier.Cha } // getCachedChannel returns a Channel object for the passed in type and UUID. -func getCachedChannel(channelType courier.ChannelType, uuid courier.ChannelUUID) (*DBChannel, error) { +func getCachedChannel(channelType courier.ChannelType, uuid courier.ChannelUUID) (*Channel, error) { // first see if the channel exists in our local cache cacheMutex.RLock() channel, found := channelCache[uuid] @@ -127,7 +255,7 @@ func getCachedChannel(channelType courier.ChannelType, uuid courier.ChannelUUID) return nil, courier.ErrChannelNotFound } -func cacheChannel(channel *DBChannel) { +func cacheChannel(channel *Channel) { channel.expiration = time.Now().Add(localTTL) cacheMutex.Lock() @@ -145,11 +273,11 @@ func clearLocalChannel(uuid courier.ChannelUUID) { const localTTL = 60 * time.Second var cacheMutex sync.RWMutex -var channelCache = make(map[courier.ChannelUUID]*DBChannel) +var channelCache = make(map[courier.ChannelUUID]*Channel) // getChannelByAddress will look up the channel with the passed in address and channel type. // It will return an error if the channel does not exist or is not active. -func getChannelByAddress(ctx context.Context, db *sqlx.DB, channelType courier.ChannelType, address courier.ChannelAddress) (*DBChannel, error) { +func getChannelByAddress(ctx context.Context, db *sqlx.DB, channelType courier.ChannelType, address courier.ChannelAddress) (*Channel, error) { // look for the channel locally cachedChannel, localErr := getCachedChannelByAddress(channelType, address) @@ -164,7 +292,7 @@ func getChannelByAddress(ctx context.Context, db *sqlx.DB, channelType courier.C // if it wasn't found in the DB, clear our cache and return that it wasn't found if dbErr == courier.ErrChannelNotFound { clearLocalChannelByAddress(address) - return cachedChannel, fmt.Errorf("unable to find channel with type: %s and address: %s", channelType.String(), address.String()) + return cachedChannel, fmt.Errorf("unable to find channel with type: %s and address: %s", string(channelType), address.String()) } // if we had some other db error, return it if our cached channel was only just expired @@ -202,8 +330,8 @@ SELECT WHERE c.address = $1 AND c.is_active = TRUE AND c.org_id IS NOT NULL` // loadChannelByAddressFromDB get the channel with the passed in channel type and address from the DB, returning it -func loadChannelByAddressFromDB(ctx context.Context, db *sqlx.DB, channelType courier.ChannelType, address courier.ChannelAddress) (*DBChannel, error) { - channel := &DBChannel{Address_: sql.NullString{String: address.String(), Valid: address == courier.NilChannelAddress}} +func loadChannelByAddressFromDB(ctx context.Context, db *sqlx.DB, channelType courier.ChannelType, address courier.ChannelAddress) (*Channel, error) { + channel := &Channel{Address_: sql.NullString{String: address.String(), Valid: address == courier.NilChannelAddress}} // select just the fields we need err := db.GetContext(ctx, channel, sqlLookupChannelFromAddress, address) @@ -228,7 +356,7 @@ func loadChannelByAddressFromDB(ctx context.Context, db *sqlx.DB, channelType co } // getCachedChannelByAddress returns a Channel object for the passed in type and address. -func getCachedChannelByAddress(channelType courier.ChannelType, address courier.ChannelAddress) (*DBChannel, error) { +func getCachedChannelByAddress(channelType courier.ChannelType, address courier.ChannelAddress) (*Channel, error) { // first see if the channel exists in our local cache cacheByAddressMutex.RLock() channel, found := channelByAddressCache[address] @@ -252,7 +380,7 @@ func getCachedChannelByAddress(channelType courier.ChannelType, address courier. return nil, courier.ErrChannelNotFound } -func cacheChannelByAddress(channel *DBChannel) { +func cacheChannelByAddress(channel *Channel) { channel.expiration = time.Now().Add(localTTL) // never cache if the address is nil or empty @@ -272,151 +400,4 @@ func clearLocalChannelByAddress(address courier.ChannelAddress) { } var cacheByAddressMutex sync.RWMutex -var channelByAddressCache = make(map[courier.ChannelAddress]*DBChannel) - -//----------------------------------------------------------------------------- -// Channel Implementation -//----------------------------------------------------------------------------- - -// DBChannel is the RapidPro specific concrete type satisfying the courier.Channel interface -type DBChannel struct { - OrgID_ OrgID `db:"org_id"` - UUID_ courier.ChannelUUID `db:"uuid"` - ID_ courier.ChannelID `db:"id"` - ChannelType_ courier.ChannelType `db:"channel_type"` - Schemes_ pq.StringArray `db:"schemes"` - Name_ sql.NullString `db:"name"` - Address_ sql.NullString `db:"address"` - Country_ sql.NullString `db:"country"` - Config_ null.Map `db:"config"` - Role_ string `db:"role"` - LogPolicy LogPolicy `db:"log_policy"` - - OrgConfig_ null.Map `db:"org_config"` - OrgIsAnon_ bool `db:"org_is_anon"` - - expiration time.Time -} - -// OrgID returns the id of the org this channel is for -func (c *DBChannel) OrgID() OrgID { return c.OrgID_ } - -// OrgIsAnon returns the org for this channel is anonymous -func (c *DBChannel) OrgIsAnon() bool { return c.OrgIsAnon_ } - -// ChannelType returns the type of this channel -func (c *DBChannel) ChannelType() courier.ChannelType { return c.ChannelType_ } - -// Name returns the name of this channel -func (c *DBChannel) Name() string { return c.Name_.String } - -// Schemes returns the schemes this channels supports -func (c *DBChannel) Schemes() []string { return []string(c.Schemes_) } - -// ID returns the id of this channel -func (c *DBChannel) ID() courier.ChannelID { return c.ID_ } - -// UUID returns the UUID of this channel -func (c *DBChannel) UUID() courier.ChannelUUID { return c.UUID_ } - -// Address returns the address of this channel as a string -func (c *DBChannel) Address() string { return c.Address_.String } - -// ChannelAddress returns the address of this channel -func (c *DBChannel) ChannelAddress() courier.ChannelAddress { - if !c.Address_.Valid { - return courier.NilChannelAddress - } - - return courier.ChannelAddress(c.Address_.String) -} - -// Country returns the country code for this channel if any -func (c *DBChannel) Country() string { return c.Country_.String } - -// IsScheme returns whether this channel serves only the passed in scheme -func (c *DBChannel) IsScheme(scheme string) bool { - return len(c.Schemes_) == 1 && c.Schemes_[0] == scheme -} - -// Roles returns the roles of this channel -func (c *DBChannel) Roles() []courier.ChannelRole { - roles := []courier.ChannelRole{} - for _, char := range strings.Split(c.Role_, "") { - roles = append(roles, courier.ChannelRole(char)) - } - return roles -} - -// HasRole returns whether the passed in channel supports the passed role -func (c *DBChannel) HasRole(role courier.ChannelRole) bool { - for _, r := range c.Roles() { - if r == role { - return true - } - } - return false -} - -// ConfigForKey returns the config value for the passed in key, or defaultValue if it isn't found -func (c *DBChannel) ConfigForKey(key string, defaultValue interface{}) interface{} { - value, found := c.Config_[key] - if !found { - return defaultValue - } - return value -} - -// OrgConfigForKey returns the org config value for the passed in key, or defaultValue if it isn't found -func (c *DBChannel) OrgConfigForKey(key string, defaultValue interface{}) interface{} { - value, found := c.OrgConfig_[key] - if !found { - return defaultValue - } - return value -} - -// StringConfigForKey returns the config value for the passed in key, or defaultValue if it isn't found -func (c *DBChannel) StringConfigForKey(key string, defaultValue string) string { - val := c.ConfigForKey(key, defaultValue) - str, isStr := val.(string) - if !isStr { - return defaultValue - } - return str -} - -// BoolConfigForKey returns the config value for the passed in key, or defaultValue if it isn't found -func (c *DBChannel) BoolConfigForKey(key string, defaultValue bool) bool { - val := c.ConfigForKey(key, defaultValue) - b, isBool := val.(bool) - if !isBool { - return defaultValue - } - return b -} - -// IntConfigForKey returns the config value for the passed in key -func (c *DBChannel) IntConfigForKey(key string, defaultValue int) int { - val := c.ConfigForKey(key, defaultValue) - - // golang unmarshals number literals in JSON into float64s by default - f, isFloat := val.(float64) - if isFloat { - return int(f) - } - - str, isStr := val.(string) - if isStr { - i, err := strconv.Atoi(str) - if err == nil { - return i - } - } - return defaultValue -} - -// CallbackDomain is convenience utility to get the callback domain configured for this channel -func (c *DBChannel) CallbackDomain(fallbackDomain string) string { - return c.StringConfigForKey(courier.ConfigCallbackDomain, fallbackDomain) -} +var channelByAddressCache = make(map[courier.ChannelAddress]*Channel) diff --git a/backends/rapidpro/channel_event.go b/backends/rapidpro/channel_event.go index d817dd420..3e86a8b58 100644 --- a/backends/rapidpro/channel_event.go +++ b/backends/rapidpro/channel_event.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "log" + "log/slog" "os" "strconv" "time" @@ -13,8 +14,7 @@ import ( "github.com/lib/pq" "github.com/nyaruka/courier" "github.com/nyaruka/gocommon/urns" - "github.com/nyaruka/null/v2" - "github.com/sirupsen/logrus" + "github.com/nyaruka/null/v3" ) // ChannelEventID is the type of our channel event ids @@ -35,34 +35,94 @@ func (i ChannelEventID) String() string { return "null" } +// ChannelEvent represents an event on a channel.. that isn't a new message or status update +type ChannelEvent struct { + ID_ ChannelEventID ` db:"id"` + OrgID_ OrgID `json:"org_id" db:"org_id"` + ChannelUUID_ courier.ChannelUUID `json:"channel_uuid" db:"channel_uuid"` + ChannelID_ courier.ChannelID `json:"channel_id" db:"channel_id"` + URN_ urns.URN `json:"urn" db:"urn"` + EventType_ courier.ChannelEventType `json:"event_type" db:"event_type"` + OptInID_ null.Int `json:"optin_id" db:"optin_id"` + Extra_ null.Map[string] `json:"extra" db:"extra"` + OccurredOn_ time.Time `json:"occurred_on" db:"occurred_on"` + CreatedOn_ time.Time `json:"created_on" db:"created_on"` + LogUUIDs pq.StringArray `json:"log_uuids" db:"log_uuids"` + + ContactID_ ContactID `json:"-" db:"contact_id"` + ContactURNID_ ContactURNID `json:"-" db:"contact_urn_id"` + + // used to update contact + ContactName_ string `json:"contact_name"` + URNAuthTokens_ map[string]string `json:"auth_tokens"` + + channel *Channel +} + // newChannelEvent creates a new channel event -func newChannelEvent(channel courier.Channel, eventType courier.ChannelEventType, urn urns.URN, clog *courier.ChannelLog) *DBChannelEvent { - dbChannel := channel.(*DBChannel) - now := time.Now().In(time.UTC) +func newChannelEvent(channel courier.Channel, eventType courier.ChannelEventType, urn urns.URN, clog *courier.ChannelLog) *ChannelEvent { + dbChannel := channel.(*Channel) - return &DBChannelEvent{ + return &ChannelEvent{ ChannelUUID_: dbChannel.UUID_, OrgID_: dbChannel.OrgID_, ChannelID_: dbChannel.ID_, URN_: urn, EventType_: eventType, - OccurredOn_: now, - CreatedOn_: now, + OccurredOn_: time.Now().In(time.UTC), LogUUIDs: []string{string(clog.UUID())}, channel: dbChannel, } } +func (e *ChannelEvent) EventID() int64 { return int64(e.ID_) } +func (e *ChannelEvent) ChannelID() courier.ChannelID { return e.ChannelID_ } +func (e *ChannelEvent) ChannelUUID() courier.ChannelUUID { return e.ChannelUUID_ } +func (e *ChannelEvent) EventType() courier.ChannelEventType { return e.EventType_ } +func (e *ChannelEvent) URN() urns.URN { return e.URN_ } +func (e *ChannelEvent) Extra() map[string]string { return e.Extra_ } +func (e *ChannelEvent) OccurredOn() time.Time { return e.OccurredOn_ } +func (e *ChannelEvent) CreatedOn() time.Time { return e.CreatedOn_ } +func (e *ChannelEvent) Channel() *Channel { return e.channel } + +func (e *ChannelEvent) WithContactName(name string) courier.ChannelEvent { + e.ContactName_ = name + return e +} + +func (e *ChannelEvent) WithURNAuthTokens(tokens map[string]string) courier.ChannelEvent { + e.URNAuthTokens_ = tokens + return e +} + +func (e *ChannelEvent) WithExtra(extra map[string]string) courier.ChannelEvent { + if e.EventType_ == courier.EventTypeOptIn || e.EventType_ == courier.EventTypeOptOut { + optInID := extra["payload"] + if optInID != "" { + asInt, _ := strconv.Atoi(optInID) + e.OptInID_ = null.Int(asInt) + } + } + + e.Extra_ = null.Map[string](extra) + return e +} + +func (e *ChannelEvent) WithOccurredOn(time time.Time) courier.ChannelEvent { + e.OccurredOn_ = time + return e +} + // writeChannelEvent writes the passed in event to the database, queueing it to our spool in case the database is down func writeChannelEvent(ctx context.Context, b *backend, event courier.ChannelEvent, clog *courier.ChannelLog) error { - dbEvent := event.(*DBChannelEvent) + dbEvent := event.(*ChannelEvent) err := writeChannelEventToDB(ctx, b, dbEvent, clog) // failed writing, write to our spool instead if err != nil { - logrus.WithError(err).WithField("channel_id", dbEvent.ChannelID).WithField("event_type", dbEvent.EventType_).Error("error writing channel event to db") + slog.Error("error writing channel event to db", "error", err, "channel_id", dbEvent.ChannelID, "event_type", dbEvent.EventType_) } if err != nil { @@ -74,14 +134,14 @@ func writeChannelEvent(ctx context.Context, b *backend, event courier.ChannelEve const sqlInsertChannelEvent = ` INSERT INTO - channels_channelevent( org_id, channel_id, contact_id, contact_urn_id, event_type, extra, occurred_on, created_on, log_uuids) - VALUES(:org_id, :channel_id, :contact_id, :contact_urn_id, :event_type, :extra, :occurred_on, :created_on, :log_uuids) -RETURNING id` + channels_channelevent( org_id, channel_id, contact_id, contact_urn_id, event_type, optin_id, extra, occurred_on, created_on, log_uuids) + VALUES(:org_id, :channel_id, :contact_id, :contact_urn_id, :event_type, :optin_id, :extra, :occurred_on, NOW(), :log_uuids) +RETURNING id, created_on` -// writeChannelEventToDB writes the passed in msg status to our db -func writeChannelEventToDB(ctx context.Context, b *backend, e *DBChannelEvent, clog *courier.ChannelLog) error { +// writeChannelEventToDB writes the passed in channel event to our db +func writeChannelEventToDB(ctx context.Context, b *backend, e *ChannelEvent, clog *courier.ChannelLog) error { // grab the contact for this event - contact, err := contactForURN(ctx, b, e.OrgID_, e.channel, e.URN_, "", e.ContactName_, clog) + contact, err := contactForURN(ctx, b, e.OrgID_, e.channel, e.URN_, e.URNAuthTokens_, e.ContactName_, clog) if err != nil { return err } @@ -97,8 +157,8 @@ func writeChannelEventToDB(ctx context.Context, b *backend, e *DBChannelEvent, c defer rows.Close() rows.Next() - err = rows.Scan(&e.ID_) - if err != nil { + + if err = rows.Scan(&e.ID_, &e.CreatedOn_); err != nil { return err } @@ -109,7 +169,7 @@ func writeChannelEventToDB(ctx context.Context, b *backend, e *DBChannelEvent, c // if we had a problem queueing the event, log it err = queueChannelEvent(rc, contact, e) if err != nil { - logrus.WithError(err).WithField("evt_id", e.ID_).Error("error queueing channel event") + slog.Error("error queueing channel event", "error", err, "evt_id", e.ID_) } return nil @@ -119,7 +179,7 @@ func (b *backend) flushChannelEventFile(filename string, contents []byte) error ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() - event := &DBChannelEvent{} + event := &ChannelEvent{} err := json.Unmarshal(contents, event) if err != nil { log.Printf("ERROR unmarshalling spool file '%s', renaming: %s\n", filename, err) @@ -132,7 +192,7 @@ func (b *backend) flushChannelEventFile(filename string, contents []byte) error if err != nil { return err } - event.channel = channel.(*DBChannel) + event.channel = channel.(*Channel) // create log tho it won't be written clog := courier.NewChannelLog(courier.ChannelLogTypeMsgReceive, channel, nil) @@ -140,65 +200,3 @@ func (b *backend) flushChannelEventFile(filename string, contents []byte) error // try to flush to our database return writeChannelEventToDB(ctx, b, event, clog) } - -const sqlSelectEvent = ` -SELECT org_id, channel_id, contact_id, contact_urn_id, event_type, extra, occurred_on, created_on, log_uuids - FROM channels_channelevent - WHERE id = $1` - -func readChannelEventFromDB(b *backend, id ChannelEventID) (*DBChannelEvent, error) { - e := &DBChannelEvent{ - ID_: id, - } - err := b.db.Get(e, sqlSelectEvent, id) - return e, err -} - -//----------------------------------------------------------------------------- -// ChannelEvent implementation -//----------------------------------------------------------------------------- - -// DBChannelEvent represents an event on a channel -type DBChannelEvent struct { - ID_ ChannelEventID ` db:"id"` - OrgID_ OrgID `json:"org_id" db:"org_id"` - ChannelUUID_ courier.ChannelUUID `json:"channel_uuid" db:"channel_uuid"` - ChannelID_ courier.ChannelID `json:"channel_id" db:"channel_id"` - URN_ urns.URN `json:"urn" db:"urn"` - EventType_ courier.ChannelEventType `json:"event_type" db:"event_type"` - Extra_ null.Map `json:"extra" db:"extra"` - OccurredOn_ time.Time `json:"occurred_on" db:"occurred_on"` - CreatedOn_ time.Time `json:"created_on" db:"created_on"` - LogUUIDs pq.StringArray `json:"log_uuids" db:"log_uuids"` - - ContactName_ string `json:"contact_name"` - ContactID_ ContactID `json:"-" db:"contact_id"` - ContactURNID_ ContactURNID `json:"-" db:"contact_urn_id"` - - channel *DBChannel -} - -func (e *DBChannelEvent) EventID() int64 { return int64(e.ID_) } -func (e *DBChannelEvent) ChannelID() courier.ChannelID { return e.ChannelID_ } -func (e *DBChannelEvent) ChannelUUID() courier.ChannelUUID { return e.ChannelUUID_ } -func (e *DBChannelEvent) ContactName() string { return e.ContactName_ } -func (e *DBChannelEvent) URN() urns.URN { return e.URN_ } -func (e *DBChannelEvent) Extra() map[string]interface{} { return e.Extra_ } -func (e *DBChannelEvent) EventType() courier.ChannelEventType { return e.EventType_ } -func (e *DBChannelEvent) OccurredOn() time.Time { return e.OccurredOn_ } -func (e *DBChannelEvent) CreatedOn() time.Time { return e.CreatedOn_ } -func (e *DBChannelEvent) Channel() *DBChannel { return e.channel } - -func (e *DBChannelEvent) WithContactName(name string) courier.ChannelEvent { - e.ContactName_ = name - return e -} -func (e *DBChannelEvent) WithExtra(extra map[string]interface{}) courier.ChannelEvent { - e.Extra_ = null.Map(extra) - return e -} - -func (e *DBChannelEvent) WithOccurredOn(time time.Time) courier.ChannelEvent { - e.OccurredOn_ = time - return e -} diff --git a/backends/rapidpro/channel_log.go b/backends/rapidpro/channel_log.go index 2f5ac273e..8c0fbce13 100644 --- a/backends/rapidpro/channel_log.go +++ b/backends/rapidpro/channel_log.go @@ -4,19 +4,18 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "path" "sync" "time" "github.com/jmoiron/sqlx" "github.com/nyaruka/courier" - "github.com/nyaruka/courier/utils" "github.com/nyaruka/gocommon/dbutil" "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/storage" "github.com/nyaruka/gocommon/syncx" - "github.com/sirupsen/logrus" ) const sqlInsertChannelLog = ` @@ -58,7 +57,8 @@ type channelError struct { // queues the passed in channel log to a writer func queueChannelLog(ctx context.Context, b *backend, clog *courier.ChannelLog) { - dbChan := clog.Channel().(*DBChannel) + log := slog.With("log_uuid", clog.UUID(), "log_type", clog.Type(), "channel_uuid", clog.Channel().UUID()) + dbChan := clog.Channel().(*Channel) // so that we don't save null logs := clog.HTTPLogs() @@ -78,7 +78,8 @@ func queueChannelLog(ctx context.Context, b *backend, clog *courier.ChannelLog) } // if log is attached to a call or message, only write to storage - if clog.MsgID() != courier.NilMsgID { + if clog.Attached() { + log = log.With("storage", "s3") v := &stChannelLog{ UUID: clog.UUID(), Type: clog.Type(), @@ -89,10 +90,11 @@ func queueChannelLog(ctx context.Context, b *backend, clog *courier.ChannelLog) ChannelUUID: clog.Channel().UUID(), } if b.stLogWriter.Queue(v) <= 0 { - logrus.Error("channel log storage writer buffer full") + log.Error("channel log writer buffer full") } } else { // otherwise write to database so it's retrievable + log = log.With("storage", "db") v := &dbChannelLog{ UUID: clog.UUID(), Type: clog.Type(), @@ -104,9 +106,11 @@ func queueChannelLog(ctx context.Context, b *backend, clog *courier.ChannelLog) ElapsedMS: int(clog.Elapsed() / time.Millisecond), } if b.dbLogWriter.Queue(v) <= 0 { - logrus.Error("channel log db writer buffer full") + log.Error("channel log writer buffer full") } } + + log.Debug("channel log queued") } type DBLogWriter struct { @@ -120,28 +124,26 @@ func NewDBLogWriter(db *sqlx.DB, wg *sync.WaitGroup) *DBLogWriter { defer cancel() writeDBChannelLogs(ctx, db, batch) - }, time.Millisecond*500, 1000, wg), + }, 1000, time.Millisecond*500, 1000, wg), } } -func writeDBChannelLogs(ctx context.Context, db *sqlx.DB, logs []*dbChannelLog) { - for _, batch := range utils.ChunkSlice(logs, 1000) { - err := dbutil.BulkQuery(ctx, db, sqlInsertChannelLog, batch) - - // if we received an error, try again one at a time (in case it is one value hanging us up) - if err != nil { - for _, v := range batch { - err = dbutil.BulkQuery(ctx, db, sqlInsertChannelLog, []*dbChannelLog{v}) - if err != nil { - log := logrus.WithField("comp", "log writer").WithField("log_uuid", v.UUID) +func writeDBChannelLogs(ctx context.Context, db *sqlx.DB, batch []*dbChannelLog) { + err := dbutil.BulkQuery(ctx, db, sqlInsertChannelLog, batch) - if qerr := dbutil.AsQueryError(err); qerr != nil { - query, params := qerr.Query() - log = log.WithFields(logrus.Fields{"sql": query, "sql_params": params}) - } + // if we received an error, try again one at a time (in case it is one value hanging us up) + if err != nil { + for _, v := range batch { + err = dbutil.BulkQuery(ctx, db, sqlInsertChannelLog, []*dbChannelLog{v}) + if err != nil { + log := slog.With("comp", "log writer", "log_uuid", v.UUID) - log.WithError(err).Error("error writing channel log") + if qerr := dbutil.AsQueryError(err); qerr != nil { + query, params := qerr.Query() + log = log.With("sql", query, "sql_params", params) } + + log.Error("error writing channel log", "error", err) } } } @@ -158,13 +160,13 @@ func NewStorageLogWriter(st storage.Storage, wg *sync.WaitGroup) *StorageLogWrit defer cancel() writeStorageChannelLogs(ctx, st, batch) - }, time.Millisecond*500, 1000, wg), + }, 1000, time.Millisecond*500, 1000, wg), } } -func writeStorageChannelLogs(ctx context.Context, st storage.Storage, logs []*stChannelLog) { - uploads := make([]*storage.Upload, len(logs)) - for i, l := range logs { +func writeStorageChannelLogs(ctx context.Context, st storage.Storage, batch []*stChannelLog) { + uploads := make([]*storage.Upload, len(batch)) + for i, l := range batch { uploads[i] = &storage.Upload{ Path: l.path(), ContentType: "application/json", @@ -172,6 +174,6 @@ func writeStorageChannelLogs(ctx context.Context, st storage.Storage, logs []*st } } if err := st.BatchPut(ctx, uploads); err != nil { - logrus.WithField("comp", "storage log writer").Error("error writing channel logs") + slog.Error("error writing channel logs", "comp", "storage log writer") } } diff --git a/backends/rapidpro/contact.go b/backends/rapidpro/contact.go index ea81fc00c..eeb3a21b4 100644 --- a/backends/rapidpro/contact.go +++ b/backends/rapidpro/contact.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "database/sql/driver" + "log/slog" "strconv" "time" "unicode/utf8" @@ -14,9 +15,8 @@ import ( "github.com/nyaruka/gocommon/dbutil" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/null/v2" + "github.com/nyaruka/null/v3" "github.com/pkg/errors" - "github.com/sirupsen/logrus" ) // used by unit tests to slow down urn operations to test races @@ -41,6 +41,28 @@ func (i ContactID) String() string { return "null" } +// Contact is our struct for a contact in the database +type Contact struct { + OrgID_ OrgID `db:"org_id"` + ID_ ContactID `db:"id"` + UUID_ courier.ContactUUID `db:"uuid"` + Name_ null.String `db:"name"` + + URNID_ ContactURNID `db:"urn_id"` + + CreatedOn_ time.Time `db:"created_on"` + ModifiedOn_ time.Time `db:"modified_on"` + + CreatedBy_ int `db:"created_by_id"` + ModifiedBy_ int `db:"modified_by_id"` + + IsNew_ bool + Status_ string `db:"status"` +} + +// UUID returns the UUID for this contact +func (c *Contact) UUID() courier.ContactUUID { return c.UUID_ } + const insertContactSQL = ` INSERT INTO contacts_contact(org_id, is_active, status, uuid, created_on, modified_on, created_by_id, modified_by_id, name, ticket_count) @@ -49,7 +71,7 @@ RETURNING id ` // insertContact inserts the passed in contact, the id field will be populated with the result on success -func insertContact(tx *sqlx.Tx, contact *DBContact) error { +func insertContact(tx *sqlx.Tx, contact *Contact) error { rows, err := tx.NamedQuery(insertContactSQL, contact) if err != nil { return err @@ -82,27 +104,29 @@ WHERE ` // contactForURN first tries to look up a contact for the passed in URN, if not finding one then creating one -func contactForURN(ctx context.Context, b *backend, org OrgID, channel *DBChannel, urn urns.URN, auth string, name string, clog *courier.ChannelLog) (*DBContact, error) { +func contactForURN(ctx context.Context, b *backend, org OrgID, channel *Channel, urn urns.URN, authTokens map[string]string, name string, clog *courier.ChannelLog) (*Contact, error) { + log := slog.With("org_id", org, "urn", urn.Identity(), "channel_uuid", channel.UUID(), "log_uuid", clog.UUID()) + // try to look up our contact by URN - contact := &DBContact{} + contact := &Contact{} err := b.db.GetContext(ctx, contact, lookupContactFromURNSQL, urn.Identity(), org) if err != nil && err != sql.ErrNoRows { - logrus.WithError(err).WithField("urn", urn.Identity()).WithField("org_id", org).Error("error looking up contact") + log.Error("error looking up contact by URN", "error", err) return nil, errors.Wrap(err, "error looking up contact by URN") } // we found it, return it if err != sql.ErrNoRows { - // insert it tx, err := b.db.BeginTxx(ctx, nil) if err != nil { - logrus.WithError(err).WithField("urn", urn.Identity()).WithField("org_id", org).Error("error looking up contact") + log.Error("error beginning transaction", "error", err) return nil, errors.Wrap(err, "error beginning transaction") } - err = setDefaultURN(tx, channel, contact, urn, auth) + // update contact's URNs so this URN has priority + err = setDefaultURN(tx, channel, contact, urn, authTokens) if err != nil { - logrus.WithError(err).WithField("urn", urn.Identity()).WithField("org_id", org).Error("error looking up contact") + log.Error("error updating default URN for contact", "error", err) tx.Rollback() return nil, errors.Wrap(err, "error setting default URN for contact") } @@ -128,7 +152,7 @@ func contactForURN(ctx context.Context, b *backend, org OrgID, channel *DBChanne // in the case of errors, we log the error but move onwards anyways if err != nil { - logrus.WithField("channel_uuid", channel.UUID()).WithField("channel_type", channel.ChannelType()).WithField("urn", urn).WithError(err).Error("unable to describe URN") + log.Error("unable to describe URN", "error", err) } else { name = attrs["name"] } @@ -169,13 +193,13 @@ func contactForURN(ctx context.Context, b *backend, org OrgID, channel *DBChanne // associate our URN // If we've inserted a duplicate URN then we'll get a uniqueness violation. // That means this contact URN was written by someone else after we tried to look it up. - contactURN, err := contactURNForURN(tx, channel, contact.ID_, urn, auth) + contactURN, err := getOrCreateContactURN(tx, channel, contact.ID_, urn, authTokens) if err != nil { tx.Rollback() if dbutil.IsUniqueViolation(err) { // if this was a duplicate URN, start over with a contact lookup - return contactForURN(ctx, b, org, channel, urn, auth, name, clog) + return contactForURN(ctx, b, org, channel, urn, authTokens, name, clog) } return nil, errors.Wrap(err, "error getting URN for contact") } @@ -183,7 +207,7 @@ func contactForURN(ctx context.Context, b *backend, org OrgID, channel *DBChanne // we stole the URN from another contact, roll back and start over if contactURN.PrevContactID != NilContactID { tx.Rollback() - return contactForURN(ctx, b, org, channel, urn, auth, name, clog) + return contactForURN(ctx, b, org, channel, urn, authTokens, name, clog) } // all is well, we created the new contact, commit and move forward @@ -201,25 +225,3 @@ func contactForURN(ctx context.Context, b *backend, org OrgID, channel *DBChanne // and return it return contact, nil } - -// DBContact is our struct for a contact in the database -type DBContact struct { - OrgID_ OrgID `db:"org_id"` - ID_ ContactID `db:"id"` - UUID_ courier.ContactUUID `db:"uuid"` - Name_ null.String `db:"name"` - - URNID_ ContactURNID `db:"urn_id"` - - CreatedOn_ time.Time `db:"created_on"` - ModifiedOn_ time.Time `db:"modified_on"` - - CreatedBy_ int `db:"created_by_id"` - ModifiedBy_ int `db:"modified_by_id"` - - IsNew_ bool - Status_ string `db:"status"` -} - -// UUID returns the UUID for this contact -func (c *DBContact) UUID() courier.ContactUUID { return c.UUID_ } diff --git a/backends/rapidpro/media.go b/backends/rapidpro/media.go index 7c2e15406..dd782262b 100644 --- a/backends/rapidpro/media.go +++ b/backends/rapidpro/media.go @@ -10,7 +10,7 @@ import ( "github.com/nyaruka/gocommon/uuids" ) -type DBMedia struct { +type Media struct { UUID_ uuids.UUID `db:"uuid" json:"uuid"` Path_ string `db:"path" json:"path"` ContentType_ string `db:"content_type" json:"content_type"` @@ -19,18 +19,18 @@ type DBMedia struct { Width_ int `db:"width" json:"width"` Height_ int `db:"height" json:"height"` Duration_ int `db:"duration" json:"duration"` - Alternates_ []*DBMedia ` json:"alternates"` + Alternates_ []*Media ` json:"alternates"` } -func (m *DBMedia) UUID() uuids.UUID { return m.UUID_ } -func (m *DBMedia) Name() string { return filepath.Base(m.Path_) } -func (m *DBMedia) ContentType() string { return m.ContentType_ } -func (m *DBMedia) URL() string { return m.URL_ } -func (m *DBMedia) Size() int { return m.Size_ } -func (m *DBMedia) Width() int { return m.Width_ } -func (m *DBMedia) Height() int { return m.Height_ } -func (m *DBMedia) Duration() int { return m.Duration_ } -func (m *DBMedia) Alternates() []courier.Media { +func (m *Media) UUID() uuids.UUID { return m.UUID_ } +func (m *Media) Name() string { return filepath.Base(m.Path_) } +func (m *Media) ContentType() string { return m.ContentType_ } +func (m *Media) URL() string { return m.URL_ } +func (m *Media) Size() int { return m.Size_ } +func (m *Media) Width() int { return m.Width_ } +func (m *Media) Height() int { return m.Height_ } +func (m *Media) Duration() int { return m.Duration_ } +func (m *Media) Alternates() []courier.Media { as := make([]courier.Media, len(m.Alternates_)) for i, alt := range m.Alternates_ { as[i] = alt @@ -38,7 +38,7 @@ func (m *DBMedia) Alternates() []courier.Media { return as } -var _ courier.Media = &DBMedia{} +var _ courier.Media = &Media{} var sqlLookupMediaFromUUID = ` SELECT m.uuid, m.path, m.content_type, m.url, m.size, m.width, m.height, m.duration @@ -47,8 +47,8 @@ INNER JOIN msgs_media m0 ON m0.id = m.id OR m0.id = m.original_id WHERE m0.uuid = $1 ORDER BY m.id` -func lookupMediaFromUUID(ctx context.Context, db *sqlx.DB, uuid uuids.UUID) (*DBMedia, error) { - var records []*DBMedia +func lookupMediaFromUUID(ctx context.Context, db *sqlx.DB, uuid uuids.UUID) (*Media, error) { + var records []*Media err := db.SelectContext(ctx, &records, sqlLookupMediaFromUUID, uuid) if err != nil && err != sql.ErrNoRows { return nil, err diff --git a/backends/rapidpro/media_test.go b/backends/rapidpro/media_test.go index 7178dfcd5..f49e3c640 100644 --- a/backends/rapidpro/media_test.go +++ b/backends/rapidpro/media_test.go @@ -9,14 +9,14 @@ import ( ) func TestDBMedia(t *testing.T) { - media1 := &rapidpro.DBMedia{ + media1 := &rapidpro.Media{ UUID_: "5310f50f-9c8e-4035-9150-be5a1f78f21a", Path_: "/orgs/1/media/5310/5310f50f-9c8e-4035-9150-be5a1f78f21a/test.mp3", ContentType_: "audio/mp3", URL_: "http://nyaruka.s3.com/orgs/1/media/5310/5310f50f-9c8e-4035-9150-be5a1f78f21a/test.mp3", Size_: 123, Duration_: 500, - Alternates_: []*rapidpro.DBMedia{ + Alternates_: []*rapidpro.Media{ { UUID_: "514c552c-e585-40e2-938a-fe9450172da8", Path_: "/orgs/1/media/514c/514c552c-e585-40e2-938a-fe9450172da8/test.m4a", @@ -32,7 +32,7 @@ func TestDBMedia(t *testing.T) { media1JSON, err := json.Marshal(media1) assert.NoError(t, err) - media2 := &rapidpro.DBMedia{} + media2 := &rapidpro.Media{} err = json.Unmarshal(media1JSON, media2) assert.NoError(t, err) assert.Equal(t, media1, media2) diff --git a/backends/rapidpro/msg.go b/backends/rapidpro/msg.go index e3eff0ae2..cd56e70d9 100644 --- a/backends/rapidpro/msg.go +++ b/backends/rapidpro/msg.go @@ -2,10 +2,13 @@ package rapidpro import ( "context" + "crypto/sha1" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "log" + "log/slog" "os" "strings" "time" @@ -15,11 +18,11 @@ import ( "github.com/lib/pq" "github.com/nyaruka/courier" "github.com/nyaruka/courier/queue" + "github.com/nyaruka/gocommon/i18n" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/null/v2" + "github.com/nyaruka/null/v3" "github.com/pkg/errors" - "github.com/sirupsen/logrus" filetype "gopkg.in/h2non/filetype.v1" ) @@ -28,9 +31,8 @@ type MsgDirection string // Possible values for MsgDirection const ( - MsgIncoming MsgDirection = "I" - MsgOutgoing MsgDirection = "O" - NilMsgDirection MsgDirection = "" + MsgIncoming MsgDirection = "I" + MsgOutgoing MsgDirection = "O" ) // MsgVisibility is the visibility of a message @@ -43,9 +45,148 @@ const ( MsgArchived MsgVisibility = "A" ) +// Msg is our base struct to represent msgs both in our JSON and db representations +type Msg struct { + OrgID_ OrgID `json:"org_id" db:"org_id"` + ID_ courier.MsgID `json:"id" db:"id"` + UUID_ courier.MsgUUID `json:"uuid" db:"uuid"` + Direction_ MsgDirection ` db:"direction"` + Status_ courier.MsgStatus ` db:"status"` + Visibility_ MsgVisibility ` db:"visibility"` + HighPriority_ bool `json:"high_priority" db:"high_priority"` + Text_ string `json:"text" db:"text"` + Attachments_ pq.StringArray `json:"attachments" db:"attachments"` + QuickReplies_ pq.StringArray `json:"quick_replies" db:"quick_replies"` + Locale_ null.String `json:"locale" db:"locale"` + ExternalID_ null.String ` db:"external_id"` + Metadata_ json.RawMessage `json:"metadata" db:"metadata"` + + ChannelID_ courier.ChannelID ` db:"channel_id"` + ContactID_ ContactID `json:"contact_id" db:"contact_id"` + ContactURNID_ ContactURNID `json:"contact_urn_id" db:"contact_urn_id"` + + MessageCount_ int ` db:"msg_count"` + ErrorCount_ int ` db:"error_count"` + FailedReason_ null.String ` db:"failed_reason"` + + NextAttempt_ time.Time ` db:"next_attempt"` + CreatedOn_ time.Time `json:"created_on" db:"created_on"` + ModifiedOn_ time.Time ` db:"modified_on"` + QueuedOn_ time.Time ` db:"queued_on"` + SentOn_ *time.Time ` db:"sent_on"` + LogUUIDs pq.StringArray ` db:"log_uuids"` + + // extra non-model fields that mailroom will include in queued payload + ChannelUUID_ courier.ChannelUUID `json:"channel_uuid"` + URN_ urns.URN `json:"urn"` + URNAuth_ string `json:"urn_auth"` + ResponseToExternalID_ string `json:"response_to_external_id"` + IsResend_ bool `json:"is_resend"` + Flow_ *courier.FlowReference `json:"flow"` + OptIn_ *courier.OptInReference `json:"optin"` + Origin_ courier.MsgOrigin `json:"origin"` + ContactLastSeenOn_ *time.Time `json:"contact_last_seen_on"` + + // extra fields used to allow courier to update a session's timeout to *after* the message has been sent + SessionID_ SessionID `json:"session_id"` + SessionTimeout_ int `json:"session_timeout"` + SessionWaitStartedOn_ *time.Time `json:"session_wait_started_on"` + SessionStatus_ string `json:"session_status"` + + ContactName_ string `json:"contact_name"` + URNAuthTokens_ map[string]string `json:"auth_tokens"` + channel *Channel + workerToken queue.WorkerToken + alreadyWritten bool +} + +// newMsg creates a new DBMsg object with the passed in parameters +func newMsg(direction MsgDirection, channel courier.Channel, urn urns.URN, text string, extID string, clog *courier.ChannelLog) *Msg { + now := time.Now() + dbChannel := channel.(*Channel) + + return &Msg{ + OrgID_: dbChannel.OrgID(), + UUID_: courier.MsgUUID(uuids.New()), + Direction_: direction, + Status_: courier.MsgStatusPending, + Visibility_: MsgVisible, + HighPriority_: false, + Text_: text, + ExternalID_: null.String(extID), + + ChannelID_: dbChannel.ID(), + ChannelUUID_: dbChannel.UUID(), + + URN_: urn, + MessageCount_: 1, + + NextAttempt_: now, + CreatedOn_: now, + ModifiedOn_: now, + QueuedOn_: now, + LogUUIDs: []string{string(clog.UUID())}, + + channel: dbChannel, + workerToken: "", + alreadyWritten: false, + } +} + +func (m *Msg) EventID() int64 { return int64(m.ID_) } +func (m *Msg) ID() courier.MsgID { return m.ID_ } +func (m *Msg) UUID() courier.MsgUUID { return m.UUID_ } +func (m *Msg) ExternalID() string { return string(m.ExternalID_) } +func (m *Msg) Text() string { return m.Text_ } +func (m *Msg) Attachments() []string { return m.Attachments_ } +func (m *Msg) URN() urns.URN { return m.URN_ } +func (m *Msg) Channel() courier.Channel { return m.channel } + +// outgoing specific +func (m *Msg) QuickReplies() []string { return m.QuickReplies_ } +func (m *Msg) Locale() i18n.Locale { return i18n.Locale(string(m.Locale_)) } +func (m *Msg) URNAuth() string { return m.URNAuth_ } +func (m *Msg) Origin() courier.MsgOrigin { return m.Origin_ } +func (m *Msg) ContactLastSeenOn() *time.Time { return m.ContactLastSeenOn_ } +func (m *Msg) Topic() string { + if m.Metadata_ == nil { + return "" + } + topic, _, _, _ := jsonparser.Get(m.Metadata_, "topic") + return string(topic) +} +func (m *Msg) Metadata() json.RawMessage { + return m.Metadata_ +} +func (m *Msg) ResponseToExternalID() string { return m.ResponseToExternalID_ } +func (m *Msg) SentOn() *time.Time { return m.SentOn_ } +func (m *Msg) IsResend() bool { return m.IsResend_ } +func (m *Msg) Flow() *courier.FlowReference { return m.Flow_ } +func (m *Msg) OptIn() *courier.OptInReference { return m.OptIn_ } +func (m *Msg) SessionStatus() string { return m.SessionStatus_ } +func (m *Msg) HighPriority() bool { return m.HighPriority_ } + +// incoming specific +func (m *Msg) ReceivedOn() *time.Time { return m.SentOn_ } +func (m *Msg) WithAttachment(url string) courier.MsgIn { + m.Attachments_ = append(m.Attachments_, url) + return m +} +func (m *Msg) WithContactName(name string) courier.MsgIn { m.ContactName_ = name; return m } +func (m *Msg) WithURNAuthTokens(tokens map[string]string) courier.MsgIn { + m.URNAuthTokens_ = tokens + return m +} +func (m *Msg) WithReceivedOn(date time.Time) courier.MsgIn { m.SentOn_ = &date; return m } + +func (m *Msg) hash() string { + hash := sha1.Sum([]byte(m.Text_ + "|" + strings.Join(m.Attachments_, "|"))) + return hex.EncodeToString(hash[:]) +} + // WriteMsg creates a message given the passed in arguments -func writeMsg(ctx context.Context, b *backend, msg courier.Msg, clog *courier.ChannelLog) error { - m := msg.(*DBMsg) +func writeMsg(ctx context.Context, b *backend, msg courier.MsgIn, clog *courier.ChannelLog) error { + m := msg.(*Msg) // this msg has already been written (we received it twice), we are a no op if m.alreadyWritten { @@ -87,7 +228,7 @@ func writeMsg(ctx context.Context, b *backend, msg courier.Msg, clog *courier.Ch // fail? log if err != nil { - logrus.WithError(err).WithField("msg", m.UUID()).Error("error writing to db") + slog.Error("error writing to db", "error", err, "msg", m.UUID()) } // if we failed write to spool @@ -96,44 +237,11 @@ func writeMsg(ctx context.Context, b *backend, msg courier.Msg, clog *courier.Ch } // mark this msg as having been seen - b.writeMsgSeen(m) + b.recordMsgReceived(m) return err } -// newMsg creates a new DBMsg object with the passed in parameters -func newMsg(direction MsgDirection, channel courier.Channel, urn urns.URN, text string, extID string, clog *courier.ChannelLog) *DBMsg { - now := time.Now() - dbChannel := channel.(*DBChannel) - - return &DBMsg{ - OrgID_: dbChannel.OrgID(), - UUID_: courier.MsgUUID(uuids.New()), - Direction_: direction, - Status_: courier.MsgPending, - Visibility_: MsgVisible, - HighPriority_: false, - Text_: text, - ExternalID_: null.String(extID), - - ChannelID_: dbChannel.ID(), - ChannelUUID_: dbChannel.UUID(), - - URN_: urn, - MessageCount_: 1, - - NextAttempt_: now, - CreatedOn_: now, - ModifiedOn_: now, - QueuedOn_: now, - LogUUIDs: []string{string(clog.UUID())}, - - channel: dbChannel, - workerToken: "", - alreadyWritten: false, - } -} - const sqlInsertMsg = ` INSERT INTO msgs_msg(org_id, uuid, direction, text, attachments, msg_type, msg_count, error_count, high_priority, status, @@ -142,9 +250,8 @@ INSERT INTO :visibility, :external_id, :channel_id, :contact_id, :contact_urn_id, :created_on, :modified_on, :next_attempt, :queued_on, :sent_on, :log_uuids) RETURNING id` -func writeMsgToDB(ctx context.Context, b *backend, m *DBMsg, clog *courier.ChannelLog) error { - // grab the contact for this msg - contact, err := contactForURN(ctx, b, m.OrgID_, m.channel, m.URN_, m.URNAuth_, m.contactName, clog) +func writeMsgToDB(ctx context.Context, b *backend, m *Msg, clog *courier.ChannelLog) error { + contact, err := contactForURN(ctx, b, m.OrgID_, m.channel, m.URN_, m.URNAuthTokens_, m.ContactName_, clog) // our db is down, write to the spool, we will write/queue this later if err != nil { @@ -175,59 +282,12 @@ func writeMsgToDB(ctx context.Context, b *backend, m *DBMsg, clog *courier.Chann // if we had a problem queueing the handling, log it, but our message is written, it'll // get picked up by our rapidpro catch-all after a period if err != nil { - logrus.WithError(err).WithField("msg_id", m.ID_).Error("error queueing msg handling") + slog.Error("error queueing msg handling", "error", err, "msg_id", m.ID_) } return nil } -const sqlSelectMsg = ` -SELECT - org_id, - direction, - text, - attachments, - quick_replies, - msg_count, - error_count, - failed_reason, - high_priority, - status, - visibility, - external_id, - channel_id, - contact_id, - contact_urn_id, - created_on, - modified_on, - next_attempt, - queued_on, - sent_on, - log_uuids -FROM - msgs_msg -WHERE - id = $1` - -const selectChannelSQL = ` -SELECT - org_id, - ch.id as id, - ch.uuid as uuid, - ch.name as name, - channel_type, schemes, - address, role, - ch.country as country, - ch.config as config, - org.config as org_config, - org.is_anon as org_is_anon -FROM - channels_channel ch - JOIN orgs_org org on ch.org_id = org.id -WHERE - ch.id = $1 -` - //----------------------------------------------------------------------------- // Msg flusher for flushing failed writes //----------------------------------------------------------------------------- @@ -236,7 +296,7 @@ func (b *backend) flushMsgFile(filename string, contents []byte) error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() - msg := &DBMsg{} + msg := &Msg{} err := json.Unmarshal(contents, msg) if err != nil { log.Printf("ERROR unmarshalling spool file '%s', renaming: %s\n", filename, err) @@ -249,7 +309,7 @@ func (b *backend) flushMsgFile(filename string, contents []byte) error { if err != nil { return err } - msg.channel = channel.(*DBChannel) + msg.channel = channel.(*Channel) // create log tho it won't be written clog := courier.NewChannelLog(courier.ChannelLogTypeMsgReceive, channel, nil) @@ -265,8 +325,8 @@ func (b *backend) flushMsgFile(filename string, contents []byte) error { // Deduping utility methods //----------------------------------------------------------------------------- -// checks to see if this message has already been seen and if so returns its UUID -func (b *backend) checkMsgSeen(msg *DBMsg) courier.MsgUUID { +// checks to see if this message has already been received and if so returns its UUID +func (b *backend) checkMsgAlreadyReceived(msg *Msg) courier.MsgUUID { rc := b.redisPool.Get() defer rc.Close() @@ -274,24 +334,20 @@ func (b *backend) checkMsgSeen(msg *DBMsg) courier.MsgUUID { if msg.ExternalID_ != "" { fingerprint := fmt.Sprintf("%s|%s|%s", msg.Channel().UUID(), msg.URN().Identity(), msg.ExternalID()) - uuid, _ := b.seenExternalIDs.Get(rc, fingerprint) - - if uuid != "" { + if uuid, _ := b.receivedExternalIDs.Get(rc, fingerprint); uuid != "" { return courier.MsgUUID(uuid) } } else { // otherwise de-dup based on text received from that channel+urn since last send fingerprint := fmt.Sprintf("%s|%s", msg.Channel().UUID(), msg.URN().Identity()) - uuidAndText, _ := b.seenMsgs.Get(rc, fingerprint) - - // if we have seen a message from this channel+urn check text too - if uuidAndText != "" { - prevText := uuidAndText[37:] + if uuidAndHash, _ := b.receivedMsgs.Get(rc, fingerprint); uuidAndHash != "" { + prevUUID := uuidAndHash[:36] + prevHash := uuidAndHash[37:] - // if it is the same, return the UUID - if prevText == msg.Text() { - return courier.MsgUUID(uuidAndText[:36]) + // if it is the same hash, return the UUID + if prevHash == msg.hash() { + return courier.MsgUUID(prevUUID) } } } @@ -299,163 +355,25 @@ func (b *backend) checkMsgSeen(msg *DBMsg) courier.MsgUUID { return courier.NilMsgUUID } -// writeMsgSeen records that the given message has been seen and written to the database -func (b *backend) writeMsgSeen(msg *DBMsg) { +// records that the given message has been received and written to the database +func (b *backend) recordMsgReceived(msg *Msg) { rc := b.redisPool.Get() defer rc.Close() if msg.ExternalID_ != "" { fingerprint := fmt.Sprintf("%s|%s|%s", msg.Channel().UUID(), msg.URN().Identity(), msg.ExternalID()) - b.seenExternalIDs.Set(rc, fingerprint, string(msg.UUID())) + b.receivedExternalIDs.Set(rc, fingerprint, string(msg.UUID())) } else { fingerprint := fmt.Sprintf("%s|%s", msg.Channel().UUID(), msg.URN().Identity()) - b.seenMsgs.Set(rc, fingerprint, fmt.Sprintf("%s|%s", msg.UUID(), msg.Text())) + b.receivedMsgs.Set(rc, fingerprint, fmt.Sprintf("%s|%s", msg.UUID(), msg.hash())) } } // clearMsgSeen clears our seen incoming messages for the passed in channel and URN -func (b *backend) clearMsgSeen(rc redis.Conn, msg *DBMsg) { +func (b *backend) clearMsgSeen(rc redis.Conn, msg *Msg) { fingerprint := fmt.Sprintf("%s|%s", msg.Channel().UUID(), msg.URN().Identity()) - b.seenMsgs.Remove(rc, fingerprint) -} - -//----------------------------------------------------------------------------- -// Our implementation of Msg interface -//----------------------------------------------------------------------------- - -// DBMsg is our base struct to represent msgs both in our JSON and db representations -type DBMsg struct { - OrgID_ OrgID `json:"org_id" db:"org_id"` - ID_ courier.MsgID `json:"id" db:"id"` - UUID_ courier.MsgUUID `json:"uuid" db:"uuid"` - Direction_ MsgDirection ` db:"direction"` - Status_ courier.MsgStatusValue ` db:"status"` - Visibility_ MsgVisibility ` db:"visibility"` - HighPriority_ bool `json:"high_priority" db:"high_priority"` - Text_ string `json:"text" db:"text"` - Attachments_ pq.StringArray `json:"attachments" db:"attachments"` - QuickReplies_ pq.StringArray `json:"quick_replies" db:"quick_replies"` - Locale_ null.String `json:"locale" db:"locale"` - ExternalID_ null.String ` db:"external_id"` - Metadata_ json.RawMessage `json:"metadata" db:"metadata"` - - ChannelID_ courier.ChannelID ` db:"channel_id"` - ContactID_ ContactID `json:"contact_id" db:"contact_id"` - ContactURNID_ ContactURNID `json:"contact_urn_id" db:"contact_urn_id"` - - MessageCount_ int ` db:"msg_count"` - ErrorCount_ int ` db:"error_count"` - FailedReason_ null.String ` db:"failed_reason"` - - NextAttempt_ time.Time ` db:"next_attempt"` - CreatedOn_ time.Time `json:"created_on" db:"created_on"` - ModifiedOn_ time.Time ` db:"modified_on"` - QueuedOn_ time.Time ` db:"queued_on"` - SentOn_ *time.Time ` db:"sent_on"` - LogUUIDs pq.StringArray ` db:"log_uuids"` - - // extra non-model fields that mailroom will include in queued payload - ChannelUUID_ courier.ChannelUUID `json:"channel_uuid"` - URN_ urns.URN `json:"urn"` - URNAuth_ string `json:"urn_auth"` - ResponseToExternalID_ string `json:"response_to_external_id"` - IsResend_ bool `json:"is_resend"` - Flow_ *courier.FlowReference `json:"flow"` - Origin_ courier.MsgOrigin `json:"origin"` - ContactLastSeenOn_ *time.Time `json:"contact_last_seen_on"` - - // extra fields used to allow courier to update a session's timeout to *after* the message has been sent - SessionID_ SessionID `json:"session_id"` - SessionTimeout_ int `json:"session_timeout"` - SessionWaitStartedOn_ *time.Time `json:"session_wait_started_on"` - SessionStatus_ string `json:"session_status"` - - contactName string - channel *DBChannel - workerToken queue.WorkerToken - alreadyWritten bool -} - -func (m *DBMsg) ID() courier.MsgID { return m.ID_ } -func (m *DBMsg) EventID() int64 { return int64(m.ID_) } -func (m *DBMsg) UUID() courier.MsgUUID { return m.UUID_ } -func (m *DBMsg) Text() string { return m.Text_ } -func (m *DBMsg) Attachments() []string { return m.Attachments_ } -func (m *DBMsg) QuickReplies() []string { return m.QuickReplies_ } -func (m *DBMsg) Locale() courier.Locale { return courier.Locale(string(m.Locale_)) } -func (m *DBMsg) ExternalID() string { return string(m.ExternalID_) } -func (m *DBMsg) URN() urns.URN { return m.URN_ } -func (m *DBMsg) URNAuth() string { return m.URNAuth_ } -func (m *DBMsg) ContactName() string { return m.contactName } -func (m *DBMsg) HighPriority() bool { return m.HighPriority_ } -func (m *DBMsg) ReceivedOn() *time.Time { return m.SentOn_ } -func (m *DBMsg) SentOn() *time.Time { return m.SentOn_ } -func (m *DBMsg) ResponseToExternalID() string { return m.ResponseToExternalID_ } -func (m *DBMsg) IsResend() bool { return m.IsResend_ } -func (m *DBMsg) Channel() courier.Channel { return m.channel } -func (m *DBMsg) SessionStatus() string { return m.SessionStatus_ } -func (m *DBMsg) Flow() *courier.FlowReference { return m.Flow_ } -func (m *DBMsg) Origin() courier.MsgOrigin { return m.Origin_ } -func (m *DBMsg) ContactLastSeenOn() *time.Time { return m.ContactLastSeenOn_ } - -func (m *DBMsg) FlowName() string { - if m.Flow_ == nil { - return "" - } - return m.Flow_.Name -} - -func (m *DBMsg) FlowUUID() string { - if m.Flow_ == nil { - return "" - } - return m.Flow_.UUID -} - -func (m *DBMsg) Topic() string { - if m.Metadata_ == nil { - return "" - } - topic, _, _, _ := jsonparser.Get(m.Metadata_, "topic") - return string(topic) -} - -// Metadata returns the metadata for this message -func (m *DBMsg) Metadata() json.RawMessage { - return m.Metadata_ -} - -// WithContactName can be used to set the contact name on a msg -func (m *DBMsg) WithContactName(name string) courier.Msg { m.contactName = name; return m } - -// WithReceivedOn can be used to set sent_on on a msg in a chained call -func (m *DBMsg) WithReceivedOn(date time.Time) courier.Msg { m.SentOn_ = &date; return m } - -// WithID can be used to set the id on a msg in a chained call -func (m *DBMsg) WithID(id courier.MsgID) courier.Msg { m.ID_ = id; return m } - -// WithUUID can be used to set the id on a msg in a chained call -func (m *DBMsg) WithUUID(uuid courier.MsgUUID) courier.Msg { m.UUID_ = uuid; return m } - -// WithMetadata can be used to add metadata to a Msg -func (m *DBMsg) WithMetadata(metadata json.RawMessage) courier.Msg { m.Metadata_ = metadata; return m } - -// WithFlow can be used to add flow to a Msg -func (m *DBMsg) WithFlow(flow *courier.FlowReference) courier.Msg { m.Flow_ = flow; return m } - -// WithAttachment can be used to append to the media urls for a message -func (m *DBMsg) WithAttachment(url string) courier.Msg { - m.Attachments_ = append(m.Attachments_, url) - return m -} - -func (m *DBMsg) WithLocale(lc courier.Locale) courier.Msg { m.Locale_ = null.String(lc); return m } - -// WithURNAuth can be used to add a URN auth setting to a message -func (m *DBMsg) WithURNAuth(auth string) courier.Msg { - m.URNAuth_ = auth - return m + b.receivedMsgs.Del(rc, fingerprint) } diff --git a/backends/rapidpro/schema.sql b/backends/rapidpro/schema.sql index 4983ee4b2..857260fe3 100644 --- a/backends/rapidpro/schema.sql +++ b/backends/rapidpro/schema.sql @@ -52,10 +52,18 @@ CREATE TABLE contacts_contacturn ( channel_id integer references channels_channel(id) on delete cascade, contact_id integer references contacts_contact(id) on delete cascade, org_id integer NOT NULL references orgs_org(id) on delete cascade, - auth text, + auth_tokens jsonb, UNIQUE (org_id, identity) ); +DROP TABLE IF EXISTS msgs_optin CASCADE; +CREATE TABLE msgs_optin ( + id serial primary key, + uuid uuid NOT NULL, + org_id integer NOT NULL references orgs_org(id) on delete cascade, + name character varying(64) +); + DROP TABLE IF EXISTS msgs_msg CASCADE; CREATE TABLE msgs_msg ( id bigserial primary key, @@ -83,7 +91,7 @@ CREATE TABLE msgs_msg ( contact_urn_id integer NOT NULL references contacts_contacturn(id) on delete cascade, org_id integer NOT NULL references orgs_org(id) on delete cascade, metadata text, - topup_id integer, + optin_id integer references msgs_optin(id) on delete cascade, delete_from_counts boolean, log_uuids uuid[] ); @@ -111,6 +119,7 @@ CREATE TABLE channels_channelevent ( channel_id integer NOT NULL references channels_channel(id) on delete cascade, contact_id integer NOT NULL references contacts_contact(id) on delete cascade, contact_urn_id integer NOT NULL references contacts_contacturn(id) on delete cascade, + optin_id integer references msgs_optin(id) on delete cascade, org_id integer NOT NULL references orgs_org(id) on delete cascade, log_uuids uuid[] ); diff --git a/backends/rapidpro/status.go b/backends/rapidpro/status.go index 0b4c5259a..5793049b8 100644 --- a/backends/rapidpro/status.go +++ b/backends/rapidpro/status.go @@ -3,31 +3,41 @@ package rapidpro import ( "context" "encoding/json" - "errors" "fmt" - "log" + "log/slog" "os" "strconv" "sync" "time" - "github.com/jmoiron/sqlx" "github.com/nyaruka/courier" - "github.com/nyaruka/courier/utils" "github.com/nyaruka/gocommon/dbutil" "github.com/nyaruka/gocommon/syncx" "github.com/nyaruka/gocommon/urns" - "github.com/sirupsen/logrus" + "github.com/pkg/errors" ) -// newMsgStatus creates a new DBMsgStatus for the passed in parameters -func newMsgStatus(channel courier.Channel, id courier.MsgID, externalID string, status courier.MsgStatusValue, clog *courier.ChannelLog) *DBMsgStatus { - dbChannel := channel.(*DBChannel) +// StatusUpdate represents a status update on a message +type StatusUpdate struct { + ChannelUUID_ courier.ChannelUUID `json:"channel_uuid" db:"channel_uuid"` + ChannelID_ courier.ChannelID `json:"channel_id" db:"channel_id"` + MsgID_ courier.MsgID `json:"msg_id,omitempty" db:"msg_id"` + OldURN_ urns.URN `json:"old_urn" db:"old_urn"` + NewURN_ urns.URN `json:"new_urn" db:"new_urn"` + ExternalID_ string `json:"external_id,omitempty" db:"external_id"` + Status_ courier.MsgStatus `json:"status" db:"status"` + ModifiedOn_ time.Time `json:"modified_on" db:"modified_on"` + LogUUID courier.ChannelLogUUID `json:"log_uuid" db:"log_uuid"` +} - return &DBMsgStatus{ +// creates a new message status update +func newStatusUpdate(channel courier.Channel, id courier.MsgID, externalID string, status courier.MsgStatus, clog *courier.ChannelLog) *StatusUpdate { + dbChannel := channel.(*Channel) + + return &StatusUpdate{ ChannelUUID_: channel.UUID(), ChannelID_: dbChannel.ID(), - ID_: id, + MsgID_: id, OldURN_: urns.NilURN, NewURN_: urns.NilURN, ExternalID_: externalID, @@ -37,24 +47,6 @@ func newMsgStatus(channel courier.Channel, id courier.MsgID, externalID string, } } -// writeMsgStatus writes the passed in status to the database, queueing it to our spool in case the database is down -func writeMsgStatus(ctx context.Context, b *backend, status courier.MsgStatus) error { - dbStatus := status.(*DBMsgStatus) - - err := writeMsgStatusToDB(ctx, b, dbStatus) - - if err == courier.ErrMsgNotFound { - return err - } - - // failed writing, write to our spool instead - if err != nil { - err = courier.WriteToSpool(b.config.SpoolDir, "statuses", dbStatus) - } - - return err -} - // the craziness below lets us update our status to 'F' and schedule retries without knowing anything about the message const sqlUpdateMsgByID = ` UPDATE msgs_msg SET @@ -124,149 +116,26 @@ WHERE msgs_msg.direction = 'O' ` -const sqlUpdateMsgByExternalID = ` -UPDATE msgs_msg SET - status = CASE - WHEN - :status = 'E' - THEN CASE - WHEN - error_count >= 2 OR status = 'F' - THEN - 'F' - ELSE - 'E' - END - ELSE - :status - END, - error_count = CASE - WHEN - :status = 'E' - THEN - error_count + 1 - ELSE - error_count - END, - next_attempt = CASE - WHEN - :status = 'E' - THEN - NOW() + (5 * (error_count+1) * interval '1 minutes') - ELSE - next_attempt - END, - failed_reason = CASE - WHEN - error_count >= 2 - THEN - 'E' - ELSE - failed_reason - END, - sent_on = CASE - WHEN - :status IN ('W', 'S', 'D') - THEN - COALESCE(sent_on, NOW()) - ELSE - NULL - END, - modified_on = :modified_on, - log_uuids = array_append(log_uuids, :log_uuid) -WHERE - msgs_msg.id = (SELECT msgs_msg.id FROM msgs_msg WHERE msgs_msg.external_id = :external_id AND msgs_msg.channel_id = :channel_id AND msgs_msg.direction = 'O' LIMIT 1) -RETURNING - msgs_msg.id -` - -// writeMsgStatusToDB writes the passed in msg status to our db -func writeMsgStatusToDB(ctx context.Context, b *backend, status *DBMsgStatus) error { - if status.ID() == courier.NilMsgID && status.ExternalID() == "" { - return fmt.Errorf("attempt to update msg status without id or external id") - } - - var rows *sqlx.Rows - var err error - - if status.ID() != courier.NilMsgID { - err = dbutil.BulkQuery(context.Background(), b.db, sqlUpdateMsgByID, []*DBMsgStatus{status}) - return err - } - - rows, err = b.db.NamedQueryContext(ctx, sqlUpdateMsgByExternalID, status) - if err != nil { - return err - } - defer rows.Close() - - // scan and read the id of the msg that was updated - if rows.Next() { - rows.Scan(&status.ID_) - } else { - return courier.ErrMsgNotFound - } - - return nil -} - func (b *backend) flushStatusFile(filename string, contents []byte) error { - status := &DBMsgStatus{} + ctx := context.Background() + status := &StatusUpdate{} err := json.Unmarshal(contents, status) if err != nil { - log.Printf("ERROR unmarshalling spool file '%s', renaming: %s\n", filename, err) + slog.Info(fmt.Sprintf("ERROR unmarshalling spool file '%s', renaming: %s\n", filename, err)) os.Rename(filename, fmt.Sprintf("%s.error", filename)) return nil } // try to flush to our db - err = writeMsgStatusToDB(context.Background(), b, status) - - // not finding the message is ok for status updates - if err == courier.ErrMsgNotFound { - return nil - } - - // Ignore wrong status update for incoming messages - if err == courier.ErrWrongIncomingMsgStatus { - return nil - } - + _, err = b.writeStatusUpdatesToDB(ctx, []*StatusUpdate{status}) return err } -//----------------------------------------------------------------------------- -// MsgStatusUpdate implementation -//----------------------------------------------------------------------------- - -// DBMsgStatus represents a status update on a message -type DBMsgStatus struct { - ChannelUUID_ courier.ChannelUUID `json:"channel_uuid" db:"channel_uuid"` - ChannelID_ courier.ChannelID `json:"channel_id" db:"channel_id"` - ID_ courier.MsgID `json:"msg_id,omitempty" db:"msg_id"` - OldURN_ urns.URN `json:"old_urn" db:"old_urn"` - NewURN_ urns.URN `json:"new_urn" db:"new_urn"` - ExternalID_ string `json:"external_id,omitempty" db:"external_id"` - Status_ courier.MsgStatusValue `json:"status" db:"status"` - ModifiedOn_ time.Time `json:"modified_on" db:"modified_on"` - LogUUID courier.ChannelLogUUID `json:"log_uuid" db:"log_uuid"` -} - -func (s *DBMsgStatus) EventID() int64 { return int64(s.ID_) } - -func (s *DBMsgStatus) ChannelUUID() courier.ChannelUUID { return s.ChannelUUID_ } -func (s *DBMsgStatus) ID() courier.MsgID { return s.ID_ } - -func (s *DBMsgStatus) RowID() string { - if s.ID_ != courier.NilMsgID { - return strconv.FormatInt(int64(s.ID_), 10) - } else if s.ExternalID_ != "" { - return s.ExternalID_ - } - return "" -} +func (s *StatusUpdate) EventID() int64 { return int64(s.MsgID_) } +func (s *StatusUpdate) ChannelUUID() courier.ChannelUUID { return s.ChannelUUID_ } +func (s *StatusUpdate) MsgID() courier.MsgID { return s.MsgID_ } -func (s *DBMsgStatus) SetUpdatedURN(old, new urns.URN) error { +func (s *StatusUpdate) SetURNUpdate(old, new urns.URN) error { // check by nil URN if old == urns.NilURN || new == urns.NilURN { return errors.New("cannot update contact URN from/to nil URN") @@ -283,61 +152,174 @@ func (s *DBMsgStatus) SetUpdatedURN(old, new urns.URN) error { s.NewURN_ = new return nil } -func (s *DBMsgStatus) UpdatedURN() (urns.URN, urns.URN) { +func (s *StatusUpdate) URNUpdate() (urns.URN, urns.URN) { return s.OldURN_, s.NewURN_ } -func (s *DBMsgStatus) HasUpdatedURN() bool { - if s.OldURN_ != urns.NilURN && s.NewURN_ != urns.NilURN { - return true - } - return false -} -func (s *DBMsgStatus) ExternalID() string { return s.ExternalID_ } -func (s *DBMsgStatus) SetExternalID(id string) { s.ExternalID_ = id } +func (s *StatusUpdate) ExternalID() string { return s.ExternalID_ } +func (s *StatusUpdate) SetExternalID(id string) { s.ExternalID_ = id } -func (s *DBMsgStatus) Status() courier.MsgStatusValue { return s.Status_ } -func (s *DBMsgStatus) SetStatus(status courier.MsgStatusValue) { s.Status_ = status } +func (s *StatusUpdate) Status() courier.MsgStatus { return s.Status_ } +func (s *StatusUpdate) SetStatus(status courier.MsgStatus) { s.Status_ = status } +// StatusWriter handles batched writes of status updates to the database type StatusWriter struct { - *syncx.Batcher[*DBMsgStatus] + *syncx.Batcher[*StatusUpdate] } -func NewStatusWriter(db *sqlx.DB, spoolDir string, wg *sync.WaitGroup) *StatusWriter { +// NewStatusWriter creates a new status update writer +func NewStatusWriter(b *backend, spoolDir string, wg *sync.WaitGroup) *StatusWriter { return &StatusWriter{ - Batcher: syncx.NewBatcher[*DBMsgStatus](func(batch []*DBMsgStatus) { + Batcher: syncx.NewBatcher[*StatusUpdate](func(batch []*StatusUpdate) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - writeMsgStatuses(ctx, db, spoolDir, batch) - }, time.Millisecond*500, 1000, wg), + b.writeStatuseUpdates(ctx, spoolDir, batch) + + }, 1000, time.Millisecond*500, 1000, wg), } } -func writeMsgStatuses(ctx context.Context, db *sqlx.DB, spoolDir string, statuses []*DBMsgStatus) { - for _, batch := range utils.ChunkSlice(statuses, 1000) { - err := dbutil.BulkQuery(ctx, db, sqlUpdateMsgByID, batch) +// tries to write a batch of message statuses to the database and spools those that fail +func (b *backend) writeStatuseUpdates(ctx context.Context, spoolDir string, batch []*StatusUpdate) { + log := slog.With("comp", "status writer") - // if we received an error, try again one at a time (in case it is one value hanging us up) - if err != nil { - for _, s := range batch { - err = dbutil.BulkQuery(ctx, db, sqlUpdateMsgByID, []*DBMsgStatus{s}) - if err != nil { - log := logrus.WithField("comp", "status writer").WithField("msg_id", s.ID()) + unresolved, err := b.writeStatusUpdatesToDB(ctx, batch) - if qerr := dbutil.AsQueryError(err); qerr != nil { - query, params := qerr.Query() - log = log.WithFields(logrus.Fields{"sql": query, "sql_params": params}) - } + // if we received an error, try again one at a time (in case it is one value hanging us up) + if err != nil { + for _, s := range batch { + _, err = b.writeStatusUpdatesToDB(ctx, []*StatusUpdate{s}) + if err != nil { + log := log.With("msg_id", s.MsgID()) + + if qerr := dbutil.AsQueryError(err); qerr != nil { + query, params := qerr.Query() + log = log.With("sql", query, "sql_params", params) + } - log.WithError(err).Error("error writing msg status") + log.Error("error writing msg status", "error", err) - err = courier.WriteToSpool(spoolDir, "statuses", s) - if err != nil { - logrus.WithField("comp", "status committer").WithError(err).Error("error writing status to spool") - } + err := courier.WriteToSpool(spoolDir, "statuses", s) + if err != nil { + log.Error("error writing status to spool", "error", err) // just have to log and move on } } } + } else { + for _, s := range unresolved { + log.Warn(fmt.Sprintf("unable to find message with channel_id=%d and external_id=%s", s.ChannelID_, s.ExternalID_)) + } + } +} + +// writes a batch of msg status updates to the database - messages that can't be resolved are returned and aren't +// considered an error +func (b *backend) writeStatusUpdatesToDB(ctx context.Context, statuses []*StatusUpdate) ([]*StatusUpdate, error) { + // get the statuses which have external ID instead of a message ID + missingID := make([]*StatusUpdate, 0, 500) + for _, s := range statuses { + if s.MsgID_ == courier.NilMsgID { + missingID = append(missingID, s) + } } + + // try to resolve channel ID + external ID to message IDs + if len(missingID) > 0 { + if err := b.resolveStatusUpdateMsgIDs(ctx, missingID); err != nil { + return nil, err + } + } + + resolved := make([]*StatusUpdate, 0, len(statuses)) + unresolved := make([]*StatusUpdate, 0, len(statuses)) + + for _, s := range statuses { + if s.MsgID_ != courier.NilMsgID { + resolved = append(resolved, s) + } else { + unresolved = append(unresolved, s) + } + } + + err := dbutil.BulkQuery(ctx, b.db, sqlUpdateMsgByID, resolved) + if err != nil { + return nil, errors.Wrap(err, "error updating status") + } + + return unresolved, nil +} + +const sqlResolveStatusMsgIDs = ` +SELECT id, channel_id, external_id + FROM msgs_msg + WHERE (channel_id, external_id) IN (VALUES(CAST(:channel_id AS int), :external_id))` + +// resolveStatusUpdateMsgIDs tries to resolve msg IDs for the given statuses - if there's no matching channel id + external id pair +// found for a status, that status will be left with a nil msg ID. +func (b *backend) resolveStatusUpdateMsgIDs(ctx context.Context, statuses []*StatusUpdate) error { + rc := b.redisPool.Get() + defer rc.Close() + + chAndExtKeys := make([]string, len(statuses)) + for i, s := range statuses { + chAndExtKeys[i] = fmt.Sprintf("%d|%s", s.ChannelID_, s.ExternalID_) + } + cachedIDs, err := b.sentExternalIDs.MGet(rc, chAndExtKeys...) + if err != nil { + // log error but we continue and try to get ids from the database + slog.Error("error looking up sent message ids in redis", "error", err) + } + + // collect the statuses that couldn't be resolved from cache, update the ones that could + notInCache := make([]*StatusUpdate, 0, len(statuses)) + for i := range cachedIDs { + id, err := strconv.Atoi(cachedIDs[i]) + if err != nil { + notInCache = append(notInCache, statuses[i]) + } else { + statuses[i].MsgID_ = courier.MsgID(id) + } + } + + if len(notInCache) == 0 { + return nil + } + + // create a mapping of channel id + external id -> status + type ext struct { + channelID courier.ChannelID + externalID string + } + statusesByExt := make(map[ext]*StatusUpdate, len(notInCache)) + for _, s := range statuses { + statusesByExt[ext{s.ChannelID_, s.ExternalID_}] = s + } + + sql, params, err := dbutil.BulkSQL(b.db, sqlResolveStatusMsgIDs, notInCache) + if err != nil { + return err + } + + rows, err := b.db.QueryContext(ctx, sql, params...) + if err != nil { + return err + } + defer rows.Close() + + var msgID courier.MsgID + var channelID courier.ChannelID + var externalID string + + for rows.Next() { + if err := rows.Scan(&msgID, &channelID, &externalID); err != nil { + return errors.Wrap(err, "error scanning rows") + } + + // find the status with this channel ID and external ID and update its msg ID + s := statusesByExt[ext{channelID, externalID}] + s.MsgID_ = msgID + } + + return rows.Err() } diff --git a/backends/rapidpro/task.go b/backends/rapidpro/task.go index 204338672..f9967a355 100644 --- a/backends/rapidpro/task.go +++ b/backends/rapidpro/task.go @@ -9,8 +9,8 @@ import ( "github.com/nyaruka/gocommon/jsonx" ) -func queueMsgHandling(rc redis.Conn, c *DBContact, m *DBMsg) error { - channel := m.Channel().(*DBChannel) +func queueMsgHandling(rc redis.Conn, c *Contact, m *Msg) error { + channel := m.Channel().(*Channel) // queue to mailroom body := map[string]any{ @@ -30,60 +30,43 @@ func queueMsgHandling(rc redis.Conn, c *DBContact, m *DBMsg) error { return queueMailroomTask(rc, "msg_event", m.OrgID_, m.ContactID_, body) } -func queueChannelEvent(rc redis.Conn, c *DBContact, e *DBChannelEvent) error { - // queue to mailroom - switch e.EventType() { - case courier.StopContact: - body := map[string]interface{}{ - "org_id": e.OrgID_, - "contact_id": e.ContactID_, - "occurred_on": e.OccurredOn_, - } - return queueMailroomTask(rc, "stop_event", e.OrgID_, e.ContactID_, body) +func queueChannelEvent(rc redis.Conn, c *Contact, e *ChannelEvent) error { + body := map[string]any{ + "org_id": e.OrgID_, + "contact_id": e.ContactID_, + "urn_id": e.ContactURNID_, + "channel_id": e.ChannelID_, + "extra": e.Extra(), + "new_contact": c.IsNew_, + "occurred_on": e.OccurredOn_, + "created_on": e.CreatedOn_, + } - case courier.WelcomeMessage: - body := map[string]interface{}{ - "org_id": e.OrgID_, - "contact_id": e.ContactID_, - "urn_id": e.ContactURNID_, - "channel_id": e.ChannelID_, - "new_contact": c.IsNew_, - "occurred_on": e.OccurredOn_, - } + switch e.EventType() { + case courier.EventTypeStopContact: + return queueMailroomTask(rc, "stop_contact", e.OrgID_, e.ContactID_, body) + case courier.EventTypeWelcomeMessage: return queueMailroomTask(rc, "welcome_message", e.OrgID_, e.ContactID_, body) - - case courier.Referral: - body := map[string]interface{}{ - "org_id": e.OrgID_, - "contact_id": e.ContactID_, - "urn_id": e.ContactURNID_, - "channel_id": e.ChannelID_, - "extra": e.Extra(), - "new_contact": c.IsNew_, - "occurred_on": e.OccurredOn_, - } + case courier.EventTypeReferral: return queueMailroomTask(rc, "referral", e.OrgID_, e.ContactID_, body) - - case courier.NewConversation: - body := map[string]interface{}{ - "org_id": e.OrgID_, - "contact_id": e.ContactID_, - "urn_id": e.ContactURNID_, - "channel_id": e.ChannelID_, - "extra": e.Extra(), - "new_contact": c.IsNew_, - "occurred_on": e.OccurredOn_, - } + case courier.EventTypeNewConversation: return queueMailroomTask(rc, "new_conversation", e.OrgID_, e.ContactID_, body) - + case courier.EventTypeOptIn: + return queueMailroomTask(rc, "optin", e.OrgID_, e.ContactID_, body) + case courier.EventTypeOptOut: + return queueMailroomTask(rc, "optout", e.OrgID_, e.ContactID_, body) default: return fmt.Errorf("unknown event type: %s", e.EventType()) } } +func queueMsgDeleted(rc redis.Conn, ch *Channel, msgID courier.MsgID, contactID ContactID) error { + return queueMailroomTask(rc, "msg_deleted", ch.OrgID_, contactID, map[string]any{"org_id": ch.OrgID_, "msg_id": msgID}) +} + // queueMailroomTask queues the passed in task to mailroom. Mailroom processes both messages and // channel event tasks through the same ordered queue. -func queueMailroomTask(rc redis.Conn, taskType string, orgID OrgID, contactID ContactID, body map[string]interface{}) (err error) { +func queueMailroomTask(rc redis.Conn, taskType string, orgID OrgID, contactID ContactID, body map[string]any) (err error) { // create our event task eventJSON := jsonx.MustMarshal(mrTask{ Type: taskType, @@ -119,8 +102,8 @@ type mrContactTask struct { } type mrTask struct { - Type string `json:"type"` - OrgID OrgID `json:"org_id"` - Task interface{} `json:"task"` - QueuedOn time.Time `json:"queued_on"` + Type string `json:"type"` + OrgID OrgID `json:"org_id"` + Task any `json:"task"` + QueuedOn time.Time `json:"queued_on"` } diff --git a/backends/rapidpro/testdata.sql b/backends/rapidpro/testdata.sql index ebee9b009..5487a2fc4 100644 --- a/backends/rapidpro/testdata.sql +++ b/backends/rapidpro/testdata.sql @@ -36,8 +36,14 @@ DELETE FROM contacts_contacturn; INSERT INTO contacts_contacturn("id", "identity", "path", "scheme", "priority", "channel_id", "contact_id", "org_id") VALUES(1000, 'tel:+12067799192', '+12067799192', 'tel', 50, 10, 100, 1); -/** Msg with id 10,000 */ -DELETE from msgs_msg; +/* Msg optins with ids 1, 2 */ +DELETE FROM msgs_optin; +INSERT INTO msgs_optin(id, uuid, org_id, name) VALUES + (1, 'fc1cef6e-b5b1-452d-9528-a4b24db28eb0', 1, 'Polls'), + (2, '2b1eba23-4a97-46ac-9022-11304412b32f', 1, 'Jokes'); + +/** Msg with id 10000 */ +DELETE FROM msgs_msg; INSERT INTO msgs_msg("id", "text", "high_priority", "created_on", "modified_on", "sent_on", "queued_on", "direction", "status", "visibility", "msg_type", "msg_count", "error_count", "next_attempt", "external_id", "channel_id", "contact_id", "contact_urn_id", "org_id") VALUES(10000, 'test message', True, now(), now(), now(), now(), 'O', 'W', 'V', 'T', diff --git a/backends/rapidpro/urn.go b/backends/rapidpro/urn.go index f74ec2e48..07717754d 100644 --- a/backends/rapidpro/urn.go +++ b/backends/rapidpro/urn.go @@ -4,14 +4,14 @@ import ( "database/sql" "database/sql/driver" "fmt" - - "github.com/nyaruka/null/v2" - "github.com/pkg/errors" + "log/slog" "github.com/jmoiron/sqlx" "github.com/nyaruka/courier" + "github.com/nyaruka/courier/utils" "github.com/nyaruka/gocommon/urns" - "github.com/sirupsen/logrus" + "github.com/nyaruka/null/v3" + "github.com/pkg/errors" ) // ContactURNID represents a contact urn's id @@ -25,58 +25,67 @@ func (i ContactURNID) Value() (driver.Value, error) { return null.IntValue(i) } func (i *ContactURNID) UnmarshalJSON(b []byte) error { return null.UnmarshalInt(b, i) } func (i ContactURNID) MarshalJSON() ([]byte, error) { return null.MarshalInt(i) } -// NewDBContactURN returns a new ContactURN object for the passed in org, contact and string urn, this is not saved to the DB yet -func newDBContactURN(org OrgID, channelID courier.ChannelID, contactID ContactID, urn urns.URN, auth string) *DBContactURN { - return &DBContactURN{ - OrgID: org, - ChannelID: channelID, - ContactID: contactID, - Identity: string(urn.Identity()), - Scheme: urn.Scheme(), - Path: urn.Path(), - Display: null.String(urn.Display()), - Auth: null.String(auth), +// ContactURN is our struct to map to database level URNs +type ContactURN struct { + ID ContactURNID `db:"id"` + OrgID OrgID `db:"org_id"` + ContactID ContactID `db:"contact_id"` + Identity string `db:"identity"` + Scheme string `db:"scheme"` + Path string `db:"path"` + Display null.String `db:"display"` + AuthTokens null.Map[string] `db:"auth_tokens"` + Priority int `db:"priority"` + ChannelID courier.ChannelID `db:"channel_id"` + PrevContactID ContactID +} + +// returns a new ContactURN object for the passed in org, contact and string URN +func newContactURN(org OrgID, channelID courier.ChannelID, contactID ContactID, urn urns.URN, authTokens map[string]string) *ContactURN { + return &ContactURN{ + OrgID: org, + ChannelID: channelID, + ContactID: contactID, + Identity: string(urn.Identity()), + Scheme: urn.Scheme(), + Path: urn.Path(), + Display: null.String(urn.Display()), + AuthTokens: null.Map[string](authTokens), } } -const selectContactURNs = ` -SELECT - id, - identity, - scheme, - display, - auth, - priority, - contact_id, - channel_id -FROM - contacts_contacturn -WHERE - contact_id = $1 -ORDER BY - priority desc -` - -// selectContactURNs returns all the ContactURNs for the passed in contact, sorted by priority -func contactURNsForContact(db *sqlx.Tx, contactID ContactID) ([]*DBContactURN, error) { +const sqlSelectURNsByContact = ` + SELECT id, org_id, contact_id, identity, scheme, path, display, auth_tokens, priority, channel_id + FROM contacts_contacturn + WHERE contact_id = $1 +ORDER BY priority DESC` + +const sqlSelectURNByIdentity = ` + SELECT id, org_id, contact_id, identity, scheme, path, display, auth_tokens, priority, channel_id + FROM contacts_contacturn + WHERE org_id = $1 AND identity = $2 +ORDER BY priority DESC + LIMIT 1` + +// returns all the ContactURNs for the passed in contact, sorted by priority +func getURNsForContact(db *sqlx.Tx, contactID ContactID) ([]*ContactURN, error) { // select all the URNs for this contact - rows, err := db.Queryx(selectContactURNs, contactID) + rows, err := db.Queryx(sqlSelectURNsByContact, contactID) if err != nil { return nil, err } defer rows.Close() - // read our URNs out - urns := make([]*DBContactURN, 0, 3) - idx := 0 + urns := make([]*ContactURN, 0, 3) + for rows.Next() { - u := &DBContactURN{} - err = rows.StructScan(u) - if err != nil { + u := &ContactURN{} + + if err := rows.StructScan(u); err != nil { return nil, err } + urns = append(urns, u) - idx++ } return urns, nil } @@ -85,11 +94,11 @@ func contactURNsForContact(db *sqlx.Tx, contactID ContactID) ([]*DBContactURN, e // that the passed in channel is the default one for that URN // // Note that the URN must be one of the contact's URN before calling this method -func setDefaultURN(db *sqlx.Tx, channel *DBChannel, contact *DBContact, urn urns.URN, auth string) error { +func setDefaultURN(db *sqlx.Tx, channel *Channel, contact *Contact, urn urns.URN, authTokens map[string]string) error { scheme := urn.Scheme() - contactURNs, err := contactURNsForContact(db, contact.ID_) + contactURNs, err := getURNsForContact(db, contact.ID_) if err != nil { - logrus.WithError(err).WithField("urn", urn.Identity()).WithField("channel_id", channel.ID()).Error("error looking up contact urns") + slog.Error("error looking up contact urns", "error", err, "urn", urn.Identity(), "channel_id", channel.ID()) return err } @@ -102,17 +111,16 @@ func setDefaultURN(db *sqlx.Tx, channel *DBChannel, contact *DBContact, urn urns if contactURNs[0].Identity == string(urn.Identity()) { display := urn.Display() - // if display, channel id or auth changed, update them - if string(contactURNs[0].Display) != display || contactURNs[0].ChannelID != channel.ID() || (auth != "" && string(contactURNs[0].Auth) != auth) { + // if display, channel id or auth tokens changed, update them + if string(contactURNs[0].Display) != display || contactURNs[0].ChannelID != channel.ID() || (authTokens != nil && !utils.MapContains(contactURNs[0].AuthTokens, authTokens)) { contactURNs[0].Display = null.String(display) if channel.HasRole(courier.ChannelRoleSend) { contactURNs[0].ChannelID = channel.ID() } - if auth != "" { - contactURNs[0].Auth = null.String(auth) - } + utils.MapUpdate(contactURNs[0].AuthTokens, authTokens) + return updateContactURN(db, contactURNs[0]) } return nil @@ -132,9 +140,7 @@ func setDefaultURN(db *sqlx.Tx, channel *DBChannel, contact *DBContact, urn urns existing.ChannelID = channel.ID() } - if auth != "" { - existing.Auth = null.String(auth) - } + utils.MapUpdate(contactURNs[0].AuthTokens, authTokens) } else { existing.Priority = currPriority @@ -153,47 +159,24 @@ func setDefaultURN(db *sqlx.Tx, channel *DBChannel, contact *DBContact, urn urns return nil } -const selectOrgURN = ` -SELECT - org_id, - id, - identity, - scheme, - path, - display, - auth, - priority, - channel_id, - contact_id -FROM - contacts_contacturn -WHERE - org_id = $1 AND - identity = $2 -ORDER BY - priority desc -LIMIT 1 -` - -// selectContactURN returns the ContactURN for the passed in org and URN -func selectContactURN(db *sqlx.Tx, org OrgID, urn urns.URN) (*DBContactURN, error) { - contactURN := newDBContactURN(org, courier.NilChannelID, NilContactID, urn, "") - err := db.Get(contactURN, selectOrgURN, org, urn.Identity()) - +// getContactURNByIdentity returns the ContactURN for the passed in org and identity +func getContactURNByIdentity(db *sqlx.Tx, org OrgID, urn urns.URN) (*ContactURN, error) { + contactURN := newContactURN(org, courier.NilChannelID, NilContactID, urn, map[string]string{}) + err := db.Get(contactURN, sqlSelectURNByIdentity, org, urn.Identity()) if err != nil { return nil, err } return contactURN, nil } -// contactURNForURN returns the ContactURN for the passed in org and URN, creating and associating +// getOrCreateContactURN returns the ContactURN for the passed in org and URN, creating and associating // it with the passed in contact if necessary -func contactURNForURN(db *sqlx.Tx, channel *DBChannel, contactID ContactID, urn urns.URN, auth string) (*DBContactURN, error) { - contactURN := newDBContactURN(channel.OrgID(), courier.NilChannelID, contactID, urn, auth) +func getOrCreateContactURN(db *sqlx.Tx, channel *Channel, contactID ContactID, urn urns.URN, authTokens map[string]string) (*ContactURN, error) { + contactURN := newContactURN(channel.OrgID(), courier.NilChannelID, contactID, urn, authTokens) if channel.HasRole(courier.ChannelRoleSend) { contactURN.ChannelID = channel.ID() } - err := db.Get(contactURN, selectOrgURN, channel.OrgID(), urn.Identity()) + err := db.Get(contactURN, sqlSelectURNByIdentity, channel.OrgID(), urn.Identity()) if err != nil && err != sql.ErrNoRows { return nil, errors.Wrap(err, "error looking up URN by identity") } @@ -222,25 +205,24 @@ func contactURNForURN(db *sqlx.Tx, channel *DBChannel, contactID ContactID, urn } } - // update our auth if we have a value set - if auth != "" && auth != string(contactURN.Auth) { - contactURN.Auth = null.String(auth) + // update our auth tokens if any provided + if authTokens != nil { + utils.MapUpdate(contactURN.AuthTokens, authTokens) + err = updateContactURN(db, contactURN) } return contactURN, errors.Wrap(err, "error updating URN auth") } -const insertURN = ` -INSERT INTO - contacts_contacturn(org_id, identity, path, scheme, display, auth, priority, channel_id, contact_id) - VALUES(:org_id, :identity, :path, :scheme, :display, :auth, :priority, :channel_id, :contact_id) -RETURNING id -` +const sqlInsertURN = ` +INSERT INTO contacts_contacturn(org_id, identity, path, scheme, display, auth_tokens, priority, channel_id, contact_id) + VALUES(:org_id, :identity, :path, :scheme, :display, :auth_tokens, :priority, :channel_id, :contact_id) + RETURNING id` // InsertContactURN inserts the passed in urn, the id field will be populated with the result on success -func insertContactURN(db *sqlx.Tx, urn *DBContactURN) error { - rows, err := db.NamedQuery(insertURN, urn) +func insertContactURN(db *sqlx.Tx, urn *ContactURN) error { + rows, err := db.NamedQuery(sqlInsertURN, urn) if err != nil { return err } @@ -252,38 +234,21 @@ func insertContactURN(db *sqlx.Tx, urn *DBContactURN) error { return err } -const updateURN = ` -UPDATE - contacts_contacturn -SET - channel_id = :channel_id, - contact_id = :contact_id, - display = :display, - auth = :auth, - priority = :priority -WHERE - id = :id -` -const fullyUpdateURN = ` -UPDATE - contacts_contacturn -SET - channel_id = :channel_id, - contact_id = :contact_id, - identity = :identity, - path = :path, - display = :display, - auth = :auth, - priority = :priority -WHERE - id = :id -` +const sqlUpdateURN = ` +UPDATE contacts_contacturn + SET channel_id = :channel_id, contact_id = :contact_id, display = :display, auth_tokens = :auth_tokens, priority = :priority + WHERE id = :id` + +const sqlFullyUpdateURN = ` +UPDATE contacts_contacturn + SET channel_id = :channel_id, contact_id = :contact_id, identity = :identity, path = :path, display = :display, auth_tokens = :auth_tokens, priority = :priority + WHERE id = :id` // UpdateContactURN updates the Channel and Contact on an existing URN -func updateContactURN(db *sqlx.Tx, urn *DBContactURN) error { - rows, err := db.NamedQuery(updateURN, urn) +func updateContactURN(db *sqlx.Tx, urn *ContactURN) error { + rows, err := db.NamedQuery(sqlUpdateURN, urn) if err != nil { - logrus.WithError(err).WithField("urn_id", urn.ID).Error("error updating contact urn") + slog.Error("error updating contact urn", "error", err, "urn_id", urn.ID) return err } defer rows.Close() @@ -295,10 +260,10 @@ func updateContactURN(db *sqlx.Tx, urn *DBContactURN) error { } // FullyUpdateContactURN updates the Identity, Channel and Contact on an existing URN -func fullyUpdateContactURN(db *sqlx.Tx, urn *DBContactURN) error { - rows, err := db.NamedQuery(fullyUpdateURN, urn) +func fullyUpdateContactURN(db *sqlx.Tx, urn *ContactURN) error { + rows, err := db.NamedQuery(sqlFullyUpdateURN, urn) if err != nil { - logrus.WithError(err).WithField("urn_id", urn.ID).Error("error updating contact urn") + slog.Error("error updating contact urn", "error", err, "urn_id", urn.ID) return err } defer rows.Close() @@ -308,18 +273,3 @@ func fullyUpdateContactURN(db *sqlx.Tx, urn *DBContactURN) error { } return err } - -// DBContactURN is our struct to map to database level URNs -type DBContactURN struct { - OrgID OrgID `db:"org_id"` - ID ContactURNID `db:"id"` - Identity string `db:"identity"` - Scheme string `db:"scheme"` - Path string `db:"path"` - Display null.String `db:"display"` - Auth null.String `db:"auth"` - Priority int `db:"priority"` - ChannelID courier.ChannelID `db:"channel_id"` - ContactID ContactID `db:"contact_id"` - PrevContactID ContactID -} diff --git a/celery/celery.go b/celery/celery.go index 90851aa65..597b148c9 100644 --- a/celery/celery.go +++ b/celery/celery.go @@ -44,7 +44,7 @@ func QueueEmptyTask(rc redis.Conn, queueName string, taskName string) error { task := Task{ Body: body, - Headers: map[string]interface{}{ + Headers: map[string]any{ "root_id": taskUUID, "id": taskUUID, "lang": "py", @@ -84,11 +84,11 @@ func QueueEmptyTask(rc redis.Conn, queueName string, taskName string) error { // Task is the outer struct for a celery task type Task struct { - Body string `json:"body"` - Headers map[string]interface{} `json:"headers"` - ContentType string `json:"content-type"` - Properties TaskProperties `json:"properties"` - ContentEncoding string `json:"content-encoding"` + Body string `json:"body"` + Headers map[string]any `json:"headers"` + ContentType string `json:"content-type"` + Properties TaskProperties `json:"properties"` + ContentEncoding string `json:"content-encoding"` } // TaskProperties is the struct for a task's properties diff --git a/channel.go b/channel.go index ab06b60b7..a8bbf19fd 100644 --- a/channel.go +++ b/channel.go @@ -5,7 +5,7 @@ import ( "errors" "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/null/v2" + "github.com/nyaruka/null/v3" ) const ( @@ -55,15 +55,11 @@ const ( ConfigSendHeaders = "headers" ) -// ChannelType is our typing of the two char channel types +// ChannelType is the 1-3 letter code used for channel types in the database type ChannelType string // AnyChannelType is our empty channel type used when doing lookups without channel type assertions -var AnyChannelType = ChannelType("") - -func (ct ChannelType) String() string { - return string(ct) -} +const AnyChannelType = ChannelType("") // ChannelRole is a role that a channel can perform type ChannelRole string @@ -139,9 +135,9 @@ type Channel interface { // CallbackDomain returns the domain that should be used for any callbacks the channel registers CallbackDomain(fallbackDomain string) string - ConfigForKey(key string, defaultValue interface{}) interface{} + ConfigForKey(key string, defaultValue any) any StringConfigForKey(key string, defaultValue string) string BoolConfigForKey(key string, defaultValue bool) bool IntConfigForKey(key string, defaultValue int) int - OrgConfigForKey(key string, defaultValue interface{}) interface{} + OrgConfigForKey(key string, defaultValue any) any } diff --git a/channel_event.go b/channel_event.go index 3fc7a7144..ef26fa617 100644 --- a/channel_event.go +++ b/channel_event.go @@ -11,10 +11,12 @@ type ChannelEventType string // Possible values for ChannelEventTypes const ( - NewConversation ChannelEventType = "new_conversation" - Referral ChannelEventType = "referral" - StopContact ChannelEventType = "stop_contact" - WelcomeMessage ChannelEventType = "welcome_message" + EventTypeNewConversation ChannelEventType = "new_conversation" + EventTypeReferral ChannelEventType = "referral" + EventTypeStopContact ChannelEventType = "stop_contact" + EventTypeWelcomeMessage ChannelEventType = "welcome_message" + EventTypeOptIn ChannelEventType = "optin" + EventTypeOptOut ChannelEventType = "optout" ) //----------------------------------------------------------------------------- @@ -23,16 +25,17 @@ const ( // ChannelEvent represents an event on a channel, such as a follow, new conversation or referral type ChannelEvent interface { + Event + ChannelUUID() ChannelUUID URN() urns.URN EventType() ChannelEventType - Extra() map[string]interface{} + Extra() map[string]string CreatedOn() time.Time OccurredOn() time.Time WithContactName(name string) ChannelEvent - WithExtra(extra map[string]interface{}) ChannelEvent + WithURNAuthTokens(tokens map[string]string) ChannelEvent + WithExtra(extra map[string]string) ChannelEvent WithOccurredOn(time.Time) ChannelEvent - - EventID() int64 } diff --git a/channel_log.go b/channel_log.go index 722bfd8d2..155d44abf 100644 --- a/channel_log.go +++ b/channel_log.go @@ -100,12 +100,12 @@ type ChannelLog struct { uuid ChannelLogUUID type_ ChannelLogType channel Channel - msgID MsgID httpLogs []*httpx.Log errors []*ChannelError createdOn time.Time elapsed time.Duration + attached bool recorder *httpx.Recorder redactor stringsx.Redactor } @@ -113,30 +113,29 @@ type ChannelLog struct { // NewChannelLogForIncoming creates a new channel log for an incoming request, the type of which won't be known // until the handler completes. func NewChannelLogForIncoming(logType ChannelLogType, ch Channel, r *httpx.Recorder, redactVals []string) *ChannelLog { - return newChannelLog(logType, ch, r, NilMsgID, redactVals) + return newChannelLog(logType, ch, r, false, redactVals) } // NewChannelLogForSend creates a new channel log for a message send -func NewChannelLogForSend(msg Msg, redactVals []string) *ChannelLog { - return newChannelLog(ChannelLogTypeMsgSend, msg.Channel(), nil, msg.ID(), redactVals) +func NewChannelLogForSend(msg MsgOut, redactVals []string) *ChannelLog { + return newChannelLog(ChannelLogTypeMsgSend, msg.Channel(), nil, true, redactVals) } // NewChannelLogForSend creates a new channel log for an attachment fetch -func NewChannelLogForAttachmentFetch(ch Channel, msgID MsgID, redactVals []string) *ChannelLog { - return newChannelLog(ChannelLogTypeAttachmentFetch, ch, nil, msgID, redactVals) +func NewChannelLogForAttachmentFetch(ch Channel, redactVals []string) *ChannelLog { + return newChannelLog(ChannelLogTypeAttachmentFetch, ch, nil, true, redactVals) } // NewChannelLog creates a new channel log with the given type and channel func NewChannelLog(t ChannelLogType, ch Channel, redactVals []string) *ChannelLog { - return newChannelLog(t, ch, nil, NilMsgID, redactVals) + return newChannelLog(t, ch, nil, false, redactVals) } -func newChannelLog(t ChannelLogType, ch Channel, r *httpx.Recorder, mid MsgID, redactVals []string) *ChannelLog { +func newChannelLog(t ChannelLogType, ch Channel, r *httpx.Recorder, attached bool, redactVals []string) *ChannelLog { return &ChannelLog{ uuid: ChannelLogUUID(uuids.New()), type_: t, channel: ch, - msgID: mid, recorder: r, createdOn: dates.Now(), @@ -183,12 +182,12 @@ func (l *ChannelLog) Channel() Channel { return l.channel } -func (l *ChannelLog) MsgID() MsgID { - return l.msgID +func (l *ChannelLog) Attached() bool { + return l.attached } -func (l *ChannelLog) SetMsgID(id MsgID) { - l.msgID = id +func (l *ChannelLog) SetAttached(a bool) { + l.attached = a } func (l *ChannelLog) HTTPLogs() []*httpx.Log { diff --git a/channel_log_test.go b/channel_log_test.go index 88c4daa67..5961d328a 100644 --- a/channel_log_test.go +++ b/channel_log_test.go @@ -48,7 +48,7 @@ func TestChannelLog(t *testing.T) { assert.Equal(t, courier.ChannelLogUUID("c00e5d67-c275-4389-aded-7d8b151cbd5b"), clog.UUID()) assert.Equal(t, courier.ChannelLogTypeTokenRefresh, clog.Type()) assert.Equal(t, channel, clog.Channel()) - assert.Equal(t, courier.NilMsgID, clog.MsgID()) + assert.False(t, clog.Attached()) assert.Equal(t, 2, len(clog.HTTPLogs())) assert.Equal(t, 2, len(clog.Errors())) assert.False(t, clog.CreatedOn().IsZero()) @@ -74,10 +74,10 @@ func TestChannelLog(t *testing.T) { assert.Equal(t, "this is an error", err2.Message()) assert.Equal(t, "", err2.Code()) - clog.SetMsgID(123) + clog.SetAttached(true) clog.SetType(courier.ChannelLogTypeEventReceive) - assert.Equal(t, courier.MsgID(123), clog.MsgID()) + assert.True(t, clog.Attached()) assert.Equal(t, courier.ChannelLogTypeEventReceive, clog.Type()) } diff --git a/cmd/courier/main.go b/cmd/courier/main.go index ca8224085..e0720d866 100644 --- a/cmd/courier/main.go +++ b/cmd/courier/main.go @@ -1,20 +1,23 @@ package main import ( + "log" + "log/slog" "os" "os/signal" "syscall" + "time" - "github.com/evalphobia/logrus_sentry" + "github.com/getsentry/sentry-go" _ "github.com/lib/pq" "github.com/nyaruka/courier" - "github.com/sirupsen/logrus" + slogmulti "github.com/samber/slog-multi" + slogsentry "github.com/samber/slog-sentry" // load channel handler packages _ "github.com/nyaruka/courier/handlers/africastalking" _ "github.com/nyaruka/courier/handlers/arabiacell" _ "github.com/nyaruka/courier/handlers/bandwidth" - _ "github.com/nyaruka/courier/handlers/blackmyna" _ "github.com/nyaruka/courier/handlers/bongolive" _ "github.com/nyaruka/courier/handlers/burstsms" _ "github.com/nyaruka/courier/handlers/clickatell" @@ -25,8 +28,7 @@ import ( _ "github.com/nyaruka/courier/handlers/discord" _ "github.com/nyaruka/courier/handlers/dmark" _ "github.com/nyaruka/courier/handlers/external" - _ "github.com/nyaruka/courier/handlers/facebook" - _ "github.com/nyaruka/courier/handlers/facebookapp" + _ "github.com/nyaruka/courier/handlers/facebook_legacy" _ "github.com/nyaruka/courier/handlers/firebase" _ "github.com/nyaruka/courier/handlers/freshchat" _ "github.com/nyaruka/courier/handlers/globe" @@ -37,7 +39,6 @@ import ( _ "github.com/nyaruka/courier/handlers/infobip" _ "github.com/nyaruka/courier/handlers/jasmin" _ "github.com/nyaruka/courier/handlers/jiochat" - _ "github.com/nyaruka/courier/handlers/junebug" _ "github.com/nyaruka/courier/handlers/justcall" _ "github.com/nyaruka/courier/handlers/kaleyra" _ "github.com/nyaruka/courier/handlers/kannel" @@ -45,7 +46,9 @@ import ( _ "github.com/nyaruka/courier/handlers/m3tech" _ "github.com/nyaruka/courier/handlers/macrokiosk" _ "github.com/nyaruka/courier/handlers/mblox" + _ "github.com/nyaruka/courier/handlers/messagebird" _ "github.com/nyaruka/courier/handlers/messangi" + _ "github.com/nyaruka/courier/handlers/meta" _ "github.com/nyaruka/courier/handlers/mtarget" _ "github.com/nyaruka/courier/handlers/mtn" _ "github.com/nyaruka/courier/handlers/nexmo" @@ -58,7 +61,6 @@ import ( _ "github.com/nyaruka/courier/handlers/slack" _ "github.com/nyaruka/courier/handlers/smscentral" _ "github.com/nyaruka/courier/handlers/start" - _ "github.com/nyaruka/courier/handlers/teams" _ "github.com/nyaruka/courier/handlers/telegram" _ "github.com/nyaruka/courier/handlers/telesom" _ "github.com/nyaruka/courier/handlers/thinq" @@ -69,10 +71,9 @@ import ( _ "github.com/nyaruka/courier/handlers/wavy" _ "github.com/nyaruka/courier/handlers/wechat" _ "github.com/nyaruka/courier/handlers/weniwebchat" - _ "github.com/nyaruka/courier/handlers/whatsapp" + _ "github.com/nyaruka/courier/handlers/whatsapp_legacy" _ "github.com/nyaruka/courier/handlers/yo" _ "github.com/nyaruka/courier/handlers/zenvia" - _ "github.com/nyaruka/courier/handlers/zenviaold" // load available backends _ "github.com/nyaruka/courier/backends/rapidpro" @@ -88,42 +89,61 @@ func main() { config.Version = version } - // configure our logger - logrus.SetOutput(os.Stdout) - level, err := logrus.ParseLevel(config.LogLevel) + var level slog.Level + err := level.UnmarshalText([]byte(config.LogLevel)) if err != nil { - logrus.Fatalf("Invalid log level '%s'", level) + log.Fatalf("invalid log level %s", level) + os.Exit(1) } - logrus.SetLevel(level) + + // configure our logger + logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}) + slog.SetDefault(slog.New(logHandler)) + + logger := slog.With("comp", "main") + logger.Info("starting courier", "version", version) // if we have a DSN entry, try to initialize it if config.SentryDSN != "" { - hook, err := logrus_sentry.NewSentryHook(config.SentryDSN, []logrus.Level{logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel}) - hook.Timeout = 0 - hook.StacktraceConfiguration.Enable = true - hook.StacktraceConfiguration.Skip = 4 - hook.StacktraceConfiguration.Context = 5 + err := sentry.Init(sentry.ClientOptions{ + Dsn: config.SentryDSN, + EnableTracing: false, + }) if err != nil { - logrus.Fatalf("Invalid sentry DSN: '%s': %s", config.SentryDSN, err) + log.Fatalf("error initiating sentry client, error %s, dsn %s", err, config.SentryDSN) + os.Exit(1) } - logrus.StandardLogger().Hooks.Add(hook) + + defer sentry.Flush(2 * time.Second) + + logger = slog.New( + slogmulti.Fanout( + logHandler, + slogsentry.Option{Level: slog.LevelError}.NewSentryHandler(), + ), + ) + logger = logger.With("release", version) + slog.SetDefault(logger) } // load our backend backend, err := courier.NewBackend(config) if err != nil { - logrus.Fatalf("Error creating backend: %s", err) + logger.Error("error creating backend", "error", err) + os.Exit(1) } server := courier.NewServer(config, backend) err = server.Start() if err != nil { - logrus.Fatalf("Error starting server: %s", err) + logger.Error("unable to start server", "error", err) + os.Exit(1) } - ch := make(chan os.Signal) + ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) - logrus.WithField("comp", "main").WithField("signal", <-ch).Info("stopping") + logger.Info("stopping", "comp", "main", "signal", <-ch) server.Stop() + } diff --git a/config.go b/config.go index 4ecc4dcc3..10a5c1069 100644 --- a/config.go +++ b/config.go @@ -1,7 +1,15 @@ package courier import ( + "encoding/csv" + "io" + "net" + "strings" + + "github.com/nyaruka/courier/utils" "github.com/nyaruka/ezconf" + "github.com/nyaruka/gocommon/httpx" + "github.com/pkg/errors" ) // Config is our top level configuration object @@ -32,15 +40,16 @@ type Config struct { FacebookWebhookSecret string `help:"the secret for Facebook webhook URL verification"` WhatsappAdminSystemUserToken string `help:"the token of the admin system user for WhatsApp"` - MediaDomain string `help:"the domain on which we'll try to resolve outgoing media URLs"` - MaxWorkers int `help:"the maximum number of go routines that will be used for sending (set to 0 to disable sending)"` - LibratoUsername string `help:"the username that will be used to authenticate to Librato"` - LibratoToken string `help:"the token that will be used to authenticate to Librato"` - StatusUsername string `help:"the username that is needed to authenticate against the /status endpoint"` - StatusPassword string `help:"the password that is needed to authenticate against the /status endpoint"` - AuthToken string `help:"the authentication token need to access non-channel endpoints"` - LogLevel string `help:"the logging level courier should use"` - Version string `help:"the version that will be used in request and response headers"` + DisallowedNetworks string `help:"comma separated list of IP addresses and networks which we disallow fetching attachments from"` + MediaDomain string `help:"the domain on which we'll try to resolve outgoing media URLs"` + MaxWorkers int `help:"the maximum number of go routines that will be used for sending (set to 0 to disable sending)"` + LibratoUsername string `help:"the username that will be used to authenticate to Librato"` + LibratoToken string `help:"the token that will be used to authenticate to Librato"` + StatusUsername string `help:"the username that is needed to authenticate against the /status endpoint"` + StatusPassword string `help:"the password that is needed to authenticate against the /status endpoint"` + AuthToken string `help:"the authentication token need to access non-channel endpoints"` + LogLevel string `help:"the logging level courier should use"` + Version string `help:"the version that will be used in request and response headers"` // IncludeChannels is the list of channels to enable, empty means include all IncludeChannels []string @@ -76,9 +85,10 @@ func NewConfig() *Config { WhatsappAdminSystemUserToken: "missing_whatsapp_admin_system_user_token", WhatsappCloudApplicationSecret: "missing_whatsapp_cloud_app_secret", - MaxWorkers: 32, - LogLevel: "error", - Version: "Dev", + DisallowedNetworks: `127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,fe80::/10`, + MaxWorkers: 32, + LogLevel: "error", + Version: "Dev", } } @@ -94,3 +104,25 @@ func LoadConfig(filename string) *Config { loader.MustLoad() return config } + +// Validate validates the config +func (c *Config) Validate() error { + if err := utils.Validate(c); err != nil { + return err + } + + if _, _, err := c.ParseDisallowedNetworks(); err != nil { + return errors.Wrap(err, "unable to parse 'DisallowedNetworks'") + } + return nil +} + +// ParseDisallowedNetworks parses the list of IPs and IP networks (written in CIDR notation) +func (c *Config) ParseDisallowedNetworks() ([]net.IP, []*net.IPNet, error) { + addrs, err := csv.NewReader(strings.NewReader(c.DisallowedNetworks)).Read() + if err != nil && err != io.EOF { + return nil, nil, err + } + + return httpx.ParseNetworks(addrs...) +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 5a08e40ea..0aa7adace 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,20 +1,19 @@ -FROM golang:1.18.9-alpine3.17 +FROM golang:1.23-bookworm AS builder -WORKDIR /app +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download -x -RUN apk update \ - && apk add --virtual build-deps gcc git \ - && rm -rf /var/cache/apk/* +COPY . ./ -RUN addgroup -S golang \ - && adduser -S -G golang golang +RUN GOOS=linux GOARCH=amd64 go build -o /bin/courier ./cmd/courier/*.go -COPY . . +FROM gcr.io/distroless/base-debian12 -RUN go install -v ./cmd/... -RUN chown -R golang /app +WORKDIR /app -USER golang +COPY --from=builder bin/courier ./ EXPOSE 8080 -ENTRYPOINT ["courier"] +ENTRYPOINT ["./courier"] diff --git a/go.mod b/go.mod index efe4275bf..d0397e6cf 100644 --- a/go.mod +++ b/go.mod @@ -1,74 +1,67 @@ module github.com/nyaruka/courier -go 1.19 +go 1.21 require ( - github.com/antchfx/xmlquery v1.3.17 - github.com/aws/aws-sdk-go v1.44.305 + github.com/antchfx/xmlquery v1.3.18 + github.com/aws/aws-sdk-go v1.49.0 github.com/buger/jsonparser v1.1.1 github.com/dghubble/oauth1 v0.7.2 - github.com/evalphobia/logrus_sentry v0.8.2 + github.com/getsentry/sentry-go v0.25.0 github.com/go-chi/chi v4.1.2+incompatible + github.com/golang-jwt/jwt/v5 v5.2.0 github.com/gomodule/redigo v1.8.9 - github.com/gorilla/schema v1.2.0 + github.com/gorilla/schema v1.2.1 github.com/jmoiron/sqlx v1.3.5 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.10 // indirect github.com/nyaruka/ezconf v0.2.1 - github.com/nyaruka/gocommon v1.37.0 - github.com/nyaruka/null/v2 v2.0.3 - github.com/nyaruka/redisx v0.3.1 + github.com/nyaruka/gocommon v1.42.7 + github.com/nyaruka/null/v3 v3.0.0 + github.com/nyaruka/redisx v0.5.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 - github.com/sirupsen/logrus v1.9.3 + github.com/samber/slog-multi v1.0.2 + github.com/samber/slog-sentry v1.2.2 github.com/stretchr/testify v1.8.4 - golang.org/x/mod v0.12.0 + golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb + golang.org/x/mod v0.14.0 gopkg.in/go-playground/validator.v9 v9.31.0 gopkg.in/h2non/filetype.v1 v1.0.5 ) require ( - github.com/gabriel-vasile/mimetype v1.4.2 - github.com/golang-jwt/jwt/v4 v4.4.1 - github.com/lestrrat-go/jwx v1.2.25 - gopkg.in/go-playground/assert.v1 v1.2.1 + github.com/gabriel-vasile/mimetype v1.4.3 + github.com/sirupsen/logrus v1.9.0 ) require ( - github.com/antchfx/xpath v1.2.4 // indirect - github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect + github.com/antchfx/xpath v1.2.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d // indirect github.com/fatih/structs v1.1.0 // indirect - github.com/getsentry/raven-go v0.2.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.11.2 // indirect - github.com/goccy/go-json v0.9.7 // indirect + github.com/go-playground/validator/v10 v10.16.0 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.2.4 // indirect - github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect - github.com/lestrrat-go/blackmagic v1.0.0 // indirect - github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/iter v1.0.1 // indirect - github.com/lestrrat-go/option v1.0.0 // indirect github.com/naoina/go-stringutil v0.1.0 // indirect github.com/naoina/toml v0.1.1 // indirect - github.com/nyaruka/librato v1.0.0 // indirect - github.com/nyaruka/phonenumbers v1.1.7 // indirect + github.com/nyaruka/librato v1.1.1 // indirect + github.com/nyaruka/null/v2 v2.0.3 // indirect + github.com/nyaruka/phonenumbers v1.2.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/samber/lo v1.39.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect - golang.org/x/crypto v0.11.0 // indirect - golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect - golang.org/x/net v0.12.0 // indirect - golang.org/x/sys v0.10.0 // indirect - golang.org/x/text v0.11.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace github.com/nyaruka/gocommon => github.com/Ilhasoft/gocommon v1.37.0-weni +replace github.com/nyaruka/gocommon => github.com/Ilhasoft/gocommon v1.42.7-weni diff --git a/go.sum b/go.sum index 614b2807e..82cdc3fa9 100644 --- a/go.sum +++ b/go.sum @@ -1,85 +1,64 @@ -github.com/Ilhasoft/gocommon v1.37.0-weni h1:yvs60nSB4lOfqNrBzR2O/DKirzx9V3hscUgMqQbhtcw= -github.com/Ilhasoft/gocommon v1.37.0-weni/go.mod h1:HaUQmWPrZfKS9MLnXKQj28zF4KlJrzFou+DGuqT7RbE= -github.com/antchfx/xmlquery v1.3.17 h1:d0qWjPp/D+vtRw7ivCwT5ApH/3CkQU8JOeo3245PpTk= -github.com/antchfx/xmlquery v1.3.17/go.mod h1:Afkq4JIeXut75taLSuI31ISJ/zeq+3jG7TunF7noreA= -github.com/antchfx/xpath v1.2.4 h1:dW1HB/JxKvGtJ9WyVGJ0sIoEcqftV3SqIstujI+B9XY= +github.com/Ilhasoft/gocommon v1.42.7-weni h1:7V5i59zAjexVkDtX20cBcIAYvYlCLEs97vEivZs1T7E= +github.com/Ilhasoft/gocommon v1.42.7-weni/go.mod h1:DMj0TJPT2zi6eoXrBSsJTGBxSAUkpBk+UzcMyAbq5DA= +github.com/antchfx/xmlquery v1.3.18 h1:FSQ3wMuphnPPGJOFhvc+cRQ2CT/rUj4cyQXkJcjOwz0= +github.com/antchfx/xmlquery v1.3.18/go.mod h1:Afkq4JIeXut75taLSuI31ISJ/zeq+3jG7TunF7noreA= github.com/antchfx/xpath v1.2.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= -github.com/aws/aws-sdk-go v1.44.305 h1:fU/5lY3WyBjGU9fkmQYd8o4fZu+2RaOv/i+sPaJVvFg= -github.com/aws/aws-sdk-go v1.44.305/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/antchfx/xpath v1.2.5 h1:hqZ+wtQ+KIOV/S3bGZcIhpgYC26um2bZYP2KVGcR7VY= +github.com/antchfx/xpath v1.2.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/aws/aws-sdk-go v1.49.0 h1:g9BkW1fo9GqKfwg2+zCD+TW/D36Ux+vtfJ8guF4AYmY= +github.com/aws/aws-sdk-go v1.49.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= -github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s= -github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d h1:1iy2qD6JEhHKKhUOA9IWs7mjco7lnw2qx8FsRI2wirE= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= github.com/dghubble/oauth1 v0.7.2 h1:pwcinOZy8z6XkNxvPmUDY52M7RDPxt0Xw1zgZ6Cl5JA= github.com/dghubble/oauth1 v0.7.2/go.mod h1:9erQdIhqhOHG/7K9s/tgh9Ks/AfoyrO5mW/43Lu2+kE= -github.com/evalphobia/logrus_sentry v0.8.2 h1:dotxHq+YLZsT1Bb45bB5UQbfCh3gM/nFFetyN46VoDQ= -github.com/evalphobia/logrus_sentry v0.8.2/go.mod h1:pKcp+vriitUqu9KiWj/VRFbRfFNUwz95/UkgG8a6MNc= github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= -github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI= +github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU= -github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= +github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= +github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM= -github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= -github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= -github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= -github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/schema v1.2.1 h1:tjDxcmdb+siIqkTNoV+qRH2mjYdr2hHe5MKXbp61ziM= +github.com/gorilla/schema v1.2.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= -github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= -github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= -github.com/lestrrat-go/blackmagic v1.0.0 h1:XzdxDbuQTz0RZZEmdU7cnQxUtFUzgCSPq8RCz4BxIi4= -github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ= -github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= -github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A= -github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= -github.com/lestrrat-go/jwx v1.2.25 h1:tAx93jN2SdPvFn08fHNAhqFJazn5mBBOB8Zli0g0otA= -github.com/lestrrat-go/jwx v1.2.25/go.mod h1:zoNuZymNl5lgdcu6P7K6ie2QRll5HVfF4xwxBBK1NxY= -github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4= -github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= @@ -92,34 +71,37 @@ github.com/naoina/toml v0.1.1 h1:PT/lllxVVN0gzzSqSlHEmP8MJB4MY2U7STGxiouV4X8= github.com/naoina/toml v0.1.1/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0= github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw= -github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0= -github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg= +github.com/nyaruka/librato v1.1.1 h1:0nTYtJLl3Sn7lX3CuHsLf+nXy1k/tGV0OjVxLy3Et4s= +github.com/nyaruka/librato v1.1.1/go.mod h1:fme1Fu1PT2qvkaBZyw8WW+SrnFe2qeeCWpvqmAaKAKE= github.com/nyaruka/null/v2 v2.0.3 h1:rdmMRQyVzrOF3Jff/gpU/7BDR9mQX0lcLl4yImsA3kw= github.com/nyaruka/null/v2 v2.0.3/go.mod h1:OCVeCkCXwrg5/qE6RU0c1oUVZBy+ZDrT+xYg1XSaIWA= -github.com/nyaruka/phonenumbers v1.1.7 h1:5UUI9hE79Kk0dymSquXbMYB7IlNDNhvu2aNlJpm9et8= -github.com/nyaruka/phonenumbers v1.1.7/go.mod h1:DC7jZd321FqUe+qWSNcHi10tyIyGNXGcNbfkPvdp1Vs= -github.com/nyaruka/redisx v0.3.1 h1:vnq1tHQwDh+7oG9BANyEVkqGjacgu8wpPxKBOx/exiw= -github.com/nyaruka/redisx v0.3.1/go.mod h1:v3PY8t0gyf/0E7S0Cxb1RpCCxYo9GUFAIQdF/RufsVw= +github.com/nyaruka/null/v3 v3.0.0 h1:JvOiNuKmRBFHxzZFt4sWii+ewmMkCQ1vO7X0clTNn6E= +github.com/nyaruka/null/v3 v3.0.0/go.mod h1:Sus286RmC8P0VihFuQDDQPib/xJQ7++TsaPLdRuwgVc= +github.com/nyaruka/phonenumbers v1.2.3 h1:xjbKWbTk+tTKU+FsHPBhRNZY0Kszk+1+K+fpvdPDLcg= +github.com/nyaruka/phonenumbers v1.2.3/go.mod h1:Jv2/XnmnjYDo3rW3/CSkH0zZB6Gl4RsDmlUKZV0JMW8= +github.com/nyaruka/redisx v0.5.0 h1:XH1pjG17lhj2DZJbrrZ2yZuPLAXrrHidXVA7cIuQq4g= +github.com/nyaruka/redisx v0.5.0/go.mod h1:v3PY8t0gyf/0E7S0Cxb1RpCCxYo9GUFAIQdF/RufsVw= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/samber/slog-multi v1.0.2 h1:6BVH9uHGAsiGkbbtQgAOQJMpKgV8unMrHhhJaw+X1EQ= +github.com/samber/slog-multi v1.0.2/go.mod h1:uLAvHpGqbYgX4FSL0p1ZwoLuveIAJvBECtE07XmYvFo= +github.com/samber/slog-sentry v1.2.2 h1:S0glIVITlGCCfSvIOte2Sh63HMHJpYN3hDr+97hILIk= +github.com/samber/slog-sentry v1.2.2/go.mod h1:bHm8jm1dks0p+xc/lH2i4TIFwnPcMTvZeHgCBj5+uhA= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -129,68 +111,58 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= +golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M= gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/h2non/filetype.v1 v1.0.5 h1:CC1jjJjoEhNVbMhXYalmGBhOBK2V70Q1N850wt/98/Y= gopkg.in/h2non/filetype.v1 v1.0.5/go.mod h1:M0yem4rwSX5lLVrkEuRRp2/NinFMD5vgJ4DlAhZcfNo= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler.go b/handler.go index 732cb1d50..a8c7cf7ac 100644 --- a/handler.go +++ b/handler.go @@ -27,10 +27,10 @@ type ChannelHandler interface { UseChannelRouteUUID() bool RedactValues(Channel) []string GetChannel(context.Context, *http.Request) (Channel, error) - Send(context.Context, Msg, *ChannelLog) (MsgStatus, error) + Send(context.Context, MsgOut, *ChannelLog) (StatusUpdate, error) - WriteStatusSuccessResponse(context.Context, http.ResponseWriter, []MsgStatus) error - WriteMsgSuccessResponse(context.Context, http.ResponseWriter, []Msg) error + WriteStatusSuccessResponse(context.Context, http.ResponseWriter, []StatusUpdate) error + WriteMsgSuccessResponse(context.Context, http.ResponseWriter, []MsgIn) error WriteRequestError(context.Context, http.ResponseWriter, error) error WriteRequestIgnored(context.Context, http.ResponseWriter, string) error } diff --git a/handler_test.go b/handler_test.go index 1a0186088..ccd335c8f 100644 --- a/handler_test.go +++ b/handler_test.go @@ -40,11 +40,11 @@ func TestHandling(t *testing.T) { time.Sleep(100 * time.Millisecond) // create and add a new outgoing message - brokenChannel := test.NewMockChannel("53e5aafa-8155-449d-9009-fcb30d54bd26", "XX", "2020", "US", map[string]interface{}{}) - mockChannel := test.NewMockChannel("e4bb1578-29da-4fa5-a214-9da19dd24230", "MCK", "2020", "US", map[string]interface{}{}) + brokenChannel := test.NewMockChannel("53e5aafa-8155-449d-9009-fcb30d54bd26", "XX", "2020", "US", map[string]any{}) + mockChannel := test.NewMockChannel("e4bb1578-29da-4fa5-a214-9da19dd24230", "MCK", "2020", "US", map[string]any{}) mb.AddChannel(mockChannel) - msg := test.NewMockMsg(courier.MsgID(101), courier.NilMsgUUID, brokenChannel, "tel:+250788383383", "test message") + msg := test.NewMockMsg(courier.MsgID(101), courier.NilMsgUUID, brokenChannel, "tel:+250788383383", "test message", nil) mb.PushOutgoingMsg(msg) // sleep a second, sender should take care of it in that time @@ -52,14 +52,14 @@ func TestHandling(t *testing.T) { // message should have failed because we don't have a registered handler assert.Equal(1, len(mb.WrittenMsgStatuses())) - assert.Equal(msg.ID(), mb.WrittenMsgStatuses()[0].ID()) - assert.Equal(courier.MsgFailed, mb.WrittenMsgStatuses()[0].Status()) + assert.Equal(msg.ID(), mb.WrittenMsgStatuses()[0].MsgID()) + assert.Equal(courier.MsgStatusFailed, mb.WrittenMsgStatuses()[0].Status()) assert.Equal(1, len(mb.WrittenChannelLogs())) mb.Reset() // change our channel to our dummy channel - msg = test.NewMockMsg(courier.MsgID(102), courier.NilMsgUUID, mockChannel, "tel:+250788383383", "test message 2") + msg = test.NewMockMsg(courier.MsgID(102), courier.NilMsgUUID, mockChannel, "tel:+250788383383", "test message 2", nil) // send it mb.PushOutgoingMsg(msg) @@ -68,8 +68,8 @@ func TestHandling(t *testing.T) { // message should be marked as wired assert.Len(mb.WrittenMsgStatuses(), 1) status := mb.WrittenMsgStatuses()[0] - assert.Equal(msg.ID(), status.ID()) - assert.Equal(courier.MsgSent, status.Status()) + assert.Equal(msg.ID(), status.MsgID()) + assert.Equal(courier.MsgStatusSent, status.Status()) assert.Len(mb.WrittenChannelLogs(), 1) clog := mb.WrittenChannelLogs()[0] @@ -89,8 +89,8 @@ func TestHandling(t *testing.T) { // message should be marked as wired assert.Equal(1, len(mb.WrittenMsgStatuses())) - assert.Equal(msg.ID(), mb.WrittenMsgStatuses()[0].ID()) - assert.Equal(courier.MsgWired, mb.WrittenMsgStatuses()[0].Status()) + assert.Equal(msg.ID(), mb.WrittenMsgStatuses()[0].MsgID()) + assert.Equal(courier.MsgStatusWired, mb.WrittenMsgStatuses()[0].Status()) // try to receive a message instead resp, err := http.Get("http://localhost:8080/c/mck/e4bb1578-29da-4fa5-a214-9da19dd24230/receive") diff --git a/handlers/africastalking/africastalking.go b/handlers/africastalking/handler.go similarity index 85% rename from handlers/africastalking/africastalking.go rename to handlers/africastalking/handler.go index 2799bfa60..697e1e8db 100644 --- a/handlers/africastalking/africastalking.go +++ b/handlers/africastalking/handler.go @@ -76,7 +76,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w msg := h.Backend().NewIncomingMsg(channel, urn, form.Text, form.ID, clog).WithReceivedOn(date) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } type statusForm struct { @@ -84,13 +84,13 @@ type statusForm struct { Status string `validate:"required" name:"status"` } -var statusMapping = map[string]courier.MsgStatusValue{ - "Success": courier.MsgDelivered, - "Sent": courier.MsgSent, - "Buffered": courier.MsgSent, - "Rejected": courier.MsgFailed, - "Failed": courier.MsgFailed, - "Expired": courier.MsgFailed, +var statusMapping = map[string]courier.MsgStatus{ + "Success": courier.MsgStatusDelivered, + "Sent": courier.MsgStatusSent, + "Buffered": courier.MsgStatusSent, + "Rejected": courier.MsgStatusFailed, + "Failed": courier.MsgStatusFailed, + "Expired": courier.MsgStatusFailed, } // receiveStatus is our HTTP handler function for status updates @@ -109,12 +109,12 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, form.ID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, form.ID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { isSharedStr := msg.Channel().ConfigForKey(configIsShared, false) isShared, _ := isSharedStr.(bool) @@ -128,7 +128,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no API key set for AT channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) // build our request form := url.Values{ @@ -150,7 +150,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.Header.Set("apikey", apiKey) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -158,13 +158,13 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // was this request successful? msgStatus, _ := jsonparser.GetString(respBody, "SMSMessageData", "Recipients", "[0]", "status") if msgStatus != "Success" { - status.SetStatus(courier.MsgErrored) + status.SetStatus(courier.MsgStatusErrored) return status, nil } // grab the external id if we can externalID, _ := jsonparser.GetString(respBody, "SMSMessageData", "Recipients", "[0]", "messageId") - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) status.SetExternalID(externalID) return status, nil diff --git a/handlers/africastalking/africastalking_test.go b/handlers/africastalking/handler_test.go similarity index 76% rename from handlers/africastalking/africastalking_test.go rename to handlers/africastalking/handler_test.go index 744afb996..42c257766 100644 --- a/handlers/africastalking/africastalking_test.go +++ b/handlers/africastalking/handler_test.go @@ -2,6 +2,7 @@ package africastalking import ( "net/http/httptest" + "net/url" "testing" "time" @@ -19,7 +20,7 @@ const ( statusURL = "/c/at/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status/" ) -var testCases = []ChannelHandleTestCase{ +var incomingTestCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -90,7 +91,7 @@ var testCases = []ChannelHandleTestCase{ Data: "id=ATXid_dda018a640edfcc5d2ce455de3e4a6e7&status=Success", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "ATXid_dda018a640edfcc5d2ce455de3e4a6e7", Status: courier.MsgStatusDelivered}}, }, { Label: "Status Expired", @@ -98,24 +99,24 @@ var testCases = []ChannelHandleTestCase{ Data: "id=ATXid_dda018a640edfcc5d2ce455de3e4a6e7&status=Expired", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "ATXid_dda018a640edfcc5d2ce455de3e4a6e7", Status: courier.MsgStatusFailed}}, }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), incomingTestCases) } func BenchmarkHandler(b *testing.B) { - RunChannelBenchmarks(b, testChannels, newHandler(), testCases) + RunChannelBenchmarks(b, testChannels, newHandler(), incomingTestCases) } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var outgoingTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -123,7 +124,9 @@ var defaultSendTestCases = []ChannelSendTestCase{ MockResponseBody: `{ "SMSMessageData": {"Recipients": [{"status": "Success", "messageId": "1002"}] } }`, MockResponseStatus: 200, ExpectedHeaders: map[string]string{"apikey": "KEY"}, - ExpectedPostParams: map[string]string{"message": "Simple Message ☺", "username": "Username", "to": "+250788383383", "from": "2020"}, + ExpectedRequests: []ExpectedRequest{ + {Form: url.Values{"message": {"Simple Message ☺"}, "username": {"Username"}, "to": {"+250788383383"}, "from": {"2020"}}}, + }, ExpectedMsgStatus: "W", ExpectedExternalID: "1002", SendPrep: setSendURL, @@ -135,7 +138,9 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, MockResponseBody: `{ "SMSMessageData": {"Recipients": [{"status": "Success", "messageId": "1002"}] } }`, MockResponseStatus: 200, - ExpectedPostParams: map[string]string{"message": "My pic!\nhttps://foo.bar/image.jpg"}, + ExpectedRequests: []ExpectedRequest{ + {Form: url.Values{"message": {"My pic!\nhttps://foo.bar/image.jpg"}, "username": {"Username"}, "to": {"+250788383383"}, "from": {"2020"}}}, + }, ExpectedMsgStatus: "W", ExpectedExternalID: "1002", SendPrep: setSendURL, @@ -146,9 +151,11 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgURN: "tel:+250788383383", MockResponseBody: `{ "SMSMessageData": {"Recipients": [{"status": "Failed" }] } }`, MockResponseStatus: 200, - ExpectedPostParams: map[string]string{"message": `No External ID`}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, + ExpectedRequests: []ExpectedRequest{ + {Form: url.Values{"message": {`No External ID`}, "username": {"Username"}, "to": {"+250788383383"}, "from": {"2020"}}}, + }, + ExpectedMsgStatus: "E", + SendPrep: setSendURL, }, { Label: "Error Sending", @@ -156,13 +163,15 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgURN: "tel:+250788383383", MockResponseBody: `{ "error": "failed" }`, MockResponseStatus: 401, - ExpectedPostParams: map[string]string{"message": `Error Message`}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, + ExpectedRequests: []ExpectedRequest{ + {Form: url.Values{"message": {`Error Message`}, "username": {"Username"}, "to": {"+250788383383"}, "from": {"2020"}}}, + }, + ExpectedMsgStatus: "E", + SendPrep: setSendURL, }, } -var sharedSendTestCases = []ChannelSendTestCase{ +var sharedSendTestCases = []OutgoingTestCase{ { Label: "Shared Send", MsgText: "Simple Message ☺", @@ -170,26 +179,28 @@ var sharedSendTestCases = []ChannelSendTestCase{ MockResponseBody: `{ "SMSMessageData": {"Recipients": [{"status": "Success", "messageId": "1002"}] } }`, MockResponseStatus: 200, ExpectedHeaders: map[string]string{"apikey": "KEY"}, - ExpectedPostParams: map[string]string{"message": "Simple Message ☺", "username": "Username", "to": "+250788383383", "from": ""}, + ExpectedRequests: []ExpectedRequest{ + {Form: url.Values{"message": {"Simple Message ☺"}, "username": {"Username"}, "to": {"+250788383383"}}}, + }, ExpectedMsgStatus: "W", ExpectedExternalID: "1002", SendPrep: setSendURL, }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "AT", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigUsername: "Username", courier.ConfigAPIKey: "KEY", }) var sharedChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "AT", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigUsername: "Username", courier.ConfigAPIKey: "KEY", configIsShared: true, }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"KEY"}, nil) - RunChannelSendTestCases(t, sharedChannel, newHandler(), sharedSendTestCases, []string{"KEY"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), outgoingTestCases, []string{"KEY"}, nil) + RunOutgoingTestCases(t, sharedChannel, newHandler(), sharedSendTestCases, []string{"KEY"}, nil) } diff --git a/handlers/arabiacell/arabiacell.go b/handlers/arabiacell/handler.go similarity index 89% rename from handlers/arabiacell/arabiacell.go rename to handlers/arabiacell/handler.go index 8cc220c12..ce0f33417 100644 --- a/handlers/arabiacell/arabiacell.go +++ b/handlers/arabiacell/handler.go @@ -56,7 +56,7 @@ type mtResponse struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for AC channel") @@ -77,7 +77,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no charging_level set for AC channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) for _, part := range handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) { form := url.Values{ "userName": []string{username}, @@ -96,7 +96,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/xml") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -111,10 +111,10 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // we always get 204 on success if response.Code == "204" { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) status.SetExternalID(response.MessageID) } else { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) clog.Error(courier.ErrorResponseStatusCode()) break } diff --git a/handlers/arabiacell/arabiacell_test.go b/handlers/arabiacell/handler_test.go similarity index 78% rename from handlers/arabiacell/arabiacell_test.go rename to handlers/arabiacell/handler_test.go index 700ae9e41..22e8b3e64 100644 --- a/handlers/arabiacell/arabiacell_test.go +++ b/handlers/arabiacell/handler_test.go @@ -2,6 +2,7 @@ package arabiacell import ( "net/http/httptest" + "net/url" "testing" "github.com/nyaruka/courier" @@ -17,7 +18,7 @@ const ( receiveURL = "/c/ac/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -36,19 +37,19 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -60,14 +61,18 @@ var defaultSendTestCases = []ChannelSendTestCase{ external1 `, MockResponseStatus: 200, - ExpectedPostParams: map[string]string{ - "userName": "user1", - "password": "pass1", - "handlerType": "send_msg", - "serviceId": "service1", - "msisdn": "+250788383383", - "messageBody": "Simple Message ☺\nhttps://foo.bar/image.jpg", - "chargingLevel": "0", + ExpectedRequests: []ExpectedRequest{ + { + Form: url.Values{ + "userName": {"user1"}, + "password": {"pass1"}, + "handlerType": {"send_msg"}, + "serviceId": {"service1"}, + "msisdn": {"+250788383383"}, + "messageBody": {"Simple Message ☺\nhttps://foo.bar/image.jpg"}, + "chargingLevel": {"0"}, + }, + }, }, ExpectedMsgStatus: "W", ExpectedExternalID: "external1", @@ -104,13 +109,13 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "AC", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigUsername: "user1", courier.ConfigPassword: "pass1", configServiceID: "service1", configChargingLevel: "0", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"pass1"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"pass1"}, nil) } diff --git a/handlers/bandwidth/bandwidth.go b/handlers/bandwidth/handler.go similarity index 90% rename from handlers/bandwidth/bandwidth.go rename to handlers/bandwidth/handler.go index 1fec3375d..98e76bf95 100644 --- a/handlers/bandwidth/bandwidth.go +++ b/handlers/bandwidth/handler.go @@ -13,6 +13,7 @@ import ( "github.com/nyaruka/courier/handlers" "github.com/nyaruka/courier/utils" "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/gocommon/jsonx" ) var ( @@ -101,7 +102,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w } // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } type moStatusData struct { @@ -113,10 +114,10 @@ type moStatusData struct { } `json:"message" validate:"required"` } -var statusMapping = map[string]courier.MsgStatusValue{ - "message-sending": courier.MsgSent, - "message-delivered": courier.MsgDelivered, - "message-failed": courier.MsgFailed, +var statusMapping = map[string]courier.MsgStatus{ + "message-sending": courier.MsgStatusSent, + "message-delivered": courier.MsgStatusDelivered, + "message-failed": courier.MsgStatusFailed, } // receiveMessage is our HTTP handler function for incoming messages @@ -153,7 +154,7 @@ func (h *handler) statusMessage(ctx context.Context, channel courier.Channel, w } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, statusPayload.Message.ID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, statusPayload.Message.ID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } @@ -172,7 +173,7 @@ type mtResponse struct { } // Send implements courier.ChannelHandler -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for BW channel") @@ -193,12 +194,17 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no application ID set for BW channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + 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) + } else { + if len(msg.Attachments()) > 0 { + msgParts = append(msgParts, "") + } } + for i, part := range msgParts { payload := &mtPayload{} payload.ApplicationID = applicationID @@ -215,10 +221,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann payload.Media = attachments } - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, err - } + jsonBody := jsonx.MustMarshal(payload) // build our request req, err := http.NewRequest(http.MethodPost, fmt.Sprintf(sendURL, accountID), bytes.NewReader(jsonBody)) @@ -229,7 +232,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.SetBasicAuth(username, password) - resp, respBody, _ := handlers.RequestHTTP(req, clog) + resp, respBody, _ := h.RequestHTTP(req, clog) response := &mtResponse{} err = json.Unmarshal(respBody, response) @@ -239,7 +242,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) if response.ID == "" { clog.Error(courier.ErrorResponseValueMissing("id")) } else { diff --git a/handlers/bandwidth/bandwidth_test.go b/handlers/bandwidth/handler_test.go similarity index 70% rename from handlers/bandwidth/bandwidth_test.go rename to handlers/bandwidth/handler_test.go index cb3ad5c86..76d96b682 100644 --- a/handlers/bandwidth/bandwidth_test.go +++ b/handlers/bandwidth/handler_test.go @@ -14,7 +14,7 @@ import ( var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "BW", "2020", "US", - map[string]interface{}{courier.ConfigUsername: "user1", courier.ConfigPassword: "pass1", configAccountID: "accound-id", configApplicationID: "application-id"}), + map[string]any{courier.ConfigUsername: "user1", courier.ConfigPassword: "pass1", configAccountID: "accound-id", configApplicationID: "application-id"}), } const ( @@ -179,7 +179,7 @@ var invalidStatus = `[ } ]` -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -217,7 +217,7 @@ var testCases = []ChannelHandleTestCase{ Data: validStatusSent, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, - ExpectedMsgStatus: courier.MsgSent, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusSent}}, }, { Label: "Status delivered", @@ -225,21 +225,21 @@ var testCases = []ChannelHandleTestCase{ Data: validStatusDelivered, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusDelivered}}, }, { - Label: "Status delivered", + Label: "Status failed", URL: statusURL, Data: validStatusFailed, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "14762070468292kw2fuqty55yp2b2", Status: courier.MsgStatusFailed}}, ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("4432", "forbidden to country")}, - ExpectedMsgStatus: courier.MsgFailed, }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -247,26 +247,30 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL + "?%s" } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", MsgURN: "tel:+12067791234", MockResponseBody: `{"id": "55555"}`, MockResponseStatus: 200, - ExpectedHeaders: map[string]string{ - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": "Basic dXNlcjE6cGFzczE=", + ExpectedRequests: []ExpectedRequest{ + { + Headers: map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Basic dXNlcjE6cGFzczE=", + }, + Body: `{"applicationId":"application-id","to":["+12067791234"],"from":"2020","text":"Simple Message ☺"}`, + }, }, - ExpectedRequestBody: `{"applicationId":"application-id","to":["+12067791234"],"from":"2020","text":"Simple Message ☺"}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "55555", - SendPrep: setSendURL, + ExpectedMsgStatus: "W", + ExpectedExternalID: "55555", + SendPrep: setSendURL, }, { Label: "Send Attachment", @@ -275,15 +279,40 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, MockResponseBody: `{"id": "55555"}`, MockResponseStatus: 200, - ExpectedHeaders: map[string]string{ - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": "Basic dXNlcjE6cGFzczE=", + ExpectedRequests: []ExpectedRequest{ + { + Headers: map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Basic dXNlcjE6cGFzczE=", + }, + Body: `{"applicationId":"application-id","to":["+12067791234"],"from":"2020","text":"My pic!","media":["https://foo.bar/image.jpg"]}`, + }, + }, + ExpectedMsgStatus: "W", + ExpectedExternalID: "55555", + SendPrep: setSendURL, + }, + { + Label: "Send Attachment no text", + MsgText: "", + MsgURN: "tel:+12067791234", + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponseBody: `{"id": "55555"}`, + MockResponseStatus: 200, + ExpectedRequests: []ExpectedRequest{ + { + Headers: map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Basic dXNlcjE6cGFzczE=", + }, + Body: `{"applicationId":"application-id","to":["+12067791234"],"from":"2020","text":"","media":["https://foo.bar/image.jpg"]}`, + }, }, - ExpectedRequestBody: `{"applicationId":"application-id","to":["+12067791234"],"from":"2020","text":"My pic!","media":["https://foo.bar/image.jpg"]}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "55555", - SendPrep: setSendURL, + ExpectedMsgStatus: "W", + ExpectedExternalID: "55555", + SendPrep: setSendURL, }, { Label: "No External ID", @@ -291,15 +320,19 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgURN: "tel:+12067791234", MockResponseBody: `{}`, MockResponseStatus: 200, - ExpectedHeaders: map[string]string{ - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": "Basic dXNlcjE6cGFzczE=", + ExpectedRequests: []ExpectedRequest{ + { + Headers: map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Basic dXNlcjE6cGFzczE=", + }, + Body: `{"applicationId":"application-id","to":["+12067791234"],"from":"2020","text":"No External ID"}`, + }, }, - ExpectedRequestBody: `{"applicationId":"application-id","to":["+12067791234"],"from":"2020","text":"No External ID"}`, - ExpectedMsgStatus: "W", - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("id")}, - SendPrep: setSendURL, + ExpectedMsgStatus: "W", + ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("id")}, + SendPrep: setSendURL, }, { Label: "Error Sending", @@ -307,23 +340,27 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgURN: "tel:+12067791234", MockResponseBody: `{ "type": "request-validation", "description": "Your request could not be accepted" }`, MockResponseStatus: 401, - ExpectedHeaders: map[string]string{ - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": "Basic dXNlcjE6cGFzczE=", + ExpectedRequests: []ExpectedRequest{ + { + Headers: map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Basic dXNlcjE6cGFzczE=", + }, + Body: `{"applicationId":"application-id","to":["+12067791234"],"from":"2020","text":"Error Message"}`, + }, }, - ExpectedRequestBody: `{"applicationId":"application-id","to":["+12067791234"],"from":"2020","text":"Error Message"}`, - ExpectedMsgStatus: "E", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("request-validation", "Your request could not be accepted")}, - SendPrep: setSendURL, + ExpectedMsgStatus: "E", + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("request-validation", "Your request could not be accepted")}, + SendPrep: setSendURL, }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "BW", "2020", "US", - map[string]interface{}{courier.ConfigUsername: "user1", courier.ConfigPassword: "pass1", configAccountID: "accound-id", configApplicationID: "application-id"}) + map[string]any{courier.ConfigUsername: "user1", courier.ConfigPassword: "pass1", configAccountID: "accound-id", configApplicationID: "application-id"}) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("user1", "pass1")}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("user1", "pass1")}, nil) } func TestBuildAttachmentRequest(t *testing.T) { diff --git a/handlers/base.go b/handlers/base.go index e63a9fa86..a0fe32797 100644 --- a/handlers/base.go +++ b/handlers/base.go @@ -2,36 +2,49 @@ package handlers import ( "context" + "fmt" "net/http" "github.com/go-chi/chi" "github.com/nyaruka/courier" + "github.com/nyaruka/gocommon/httpx" ) var defaultRedactConfigKeys = []string{courier.ConfigAuthToken, courier.ConfigAPIKey, courier.ConfigSecret, courier.ConfigPassword, courier.ConfigSendAuthorization} // BaseHandler is the base class for most handlers, it just stored the server, name and channel type for the handler type BaseHandler struct { - channelType courier.ChannelType - name string - server courier.Server - backend courier.Backend - useChannelRouteUUID bool - redactConfigKeys []string + channelType courier.ChannelType + name string + server courier.Server + backend courier.Backend + uuidChannelRouting bool + redactConfigKeys []string } // NewBaseHandler returns a newly constructed BaseHandler with the passed in parameters -func NewBaseHandler(channelType courier.ChannelType, name string) BaseHandler { - return NewBaseHandlerWithParams(channelType, name, true, defaultRedactConfigKeys) +func NewBaseHandler(channelType courier.ChannelType, name string, options ...func(*BaseHandler)) BaseHandler { + h := &BaseHandler{ + channelType: channelType, + name: name, + uuidChannelRouting: true, + redactConfigKeys: defaultRedactConfigKeys, + } + for _, o := range options { + o(h) + } + return *h +} + +func DisableUUIDRouting() func(*BaseHandler) { + return func(s *BaseHandler) { + s.uuidChannelRouting = false + } } -// NewBaseHandlerWithParams returns a newly constructed BaseHandler with the passed in parameters -func NewBaseHandlerWithParams(channelType courier.ChannelType, name string, useChannelRouteUUID bool, redactConfigKeys []string) BaseHandler { - return BaseHandler{ - channelType: channelType, - name: name, - useChannelRouteUUID: useChannelRouteUUID, - redactConfigKeys: redactConfigKeys, +func WithRedactConfigKeys(keys ...string) func(*BaseHandler) { + return func(s *BaseHandler) { + s.redactConfigKeys = keys } } @@ -63,7 +76,7 @@ func (h *BaseHandler) ChannelName() string { // UseChannelRouteUUID returns whether the router should use the channel UUID in the URL path func (h *BaseHandler) UseChannelRouteUUID() bool { - return h.useChannelRouteUUID + return h.uuidChannelRouting } func (h *BaseHandler) RedactValues(ch courier.Channel) []string { @@ -87,13 +100,43 @@ func (h *BaseHandler) GetChannel(ctx context.Context, r *http.Request) (courier. return h.backend.GetChannel(ctx, h.ChannelType(), uuid) } +// RequestHTTP does the given request, logging the trace, and returns the response +func (h *BaseHandler) RequestHTTP(req *http.Request, clog *courier.ChannelLog) (*http.Response, []byte, error) { + return h.RequestHTTPWithClient(h.backend.HttpClient(true), req, clog) +} + +// RequestHTTP does the given request, logging the trace, and returns the response +func (h *BaseHandler) RequestHTTPInsecure(req *http.Request, clog *courier.ChannelLog) (*http.Response, []byte, error) { + return h.RequestHTTPWithClient(h.backend.HttpClient(false), req, clog) +} + +// RequestHTTP does the given request using the given client, logging the trace, and returns the response +func (h *BaseHandler) RequestHTTPWithClient(client *http.Client, req *http.Request, clog *courier.ChannelLog) (*http.Response, []byte, error) { + var resp *http.Response + var body []byte + + req.Header.Set("User-Agent", fmt.Sprintf("Courier/%s", h.server.Config().Version)) + + trace, err := httpx.DoTrace(client, req, nil, h.backend.HttpAccess(), 0) + if trace != nil { + clog.HTTP(trace) + resp = trace.Response + body = trace.ResponseBody + } + if err != nil { + return nil, nil, err + } + + return resp, body, nil +} + // WriteStatusSuccessResponse writes a success response for the statuses -func (h *BaseHandler) WriteStatusSuccessResponse(ctx context.Context, w http.ResponseWriter, statuses []courier.MsgStatus) error { +func (h *BaseHandler) WriteStatusSuccessResponse(ctx context.Context, w http.ResponseWriter, statuses []courier.StatusUpdate) error { return courier.WriteStatusSuccess(w, statuses) } // WriteMsgSuccessResponse writes a success response for the messages -func (h *BaseHandler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *BaseHandler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { return courier.WriteMsgSuccess(w, msgs) } diff --git a/handlers/base_test.go b/handlers/base_test.go index 6ab242775..bc6934a12 100644 --- a/handlers/base_test.go +++ b/handlers/base_test.go @@ -1,75 +1,55 @@ -package handlers +package handlers_test import ( + "net/http" "testing" "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 test6 = ` -SSByZWNlaXZlZCB5b3VyIGxldHRlciB0b2RheSwgaW4gd2hpY2ggeW91IHNheSB5b3Ugd2FudCB0 -byByZXNjdWUgTm9ydGggQ2Fyb2xpbmlhbnMgZnJvbSB0aGUgQUNBLCBvciBPYmFtYWNhcmUgYXMg -eW91IG9kZGx5IGluc2lzdCBvbiBjYWxsaW5nIGl0LiAKCkkgaGF2ZSB0byBjYWxsIHlvdXIgYXR0 -ZW50aW9uIHRvIHlvdXIgc2luIG9mIG9taXNzaW9uLiBZb3Ugc2F5IHRoYXQgd2UgYXJlIGRvd24g -dG8gb25lIGluc3VyZXIgYmVjYXVzZSBvZiBPYmFtYWNhcmUuIERpZCB5b3UgZm9yZ2V0IHRoYXQg -VGhlIEJhdGhyb29tIFN0YXRlIGhhcyBkb25lIGV2ZXJ5dGhpbmcgcG9zc2libGUgdG8gbWFrZSBU -aGUgQUNBIGZhaWw/ICBJbmNsdWRpbmcgbWlsbGlvbnMgb2YgZG9sbGFycyBmcm9tIHRoZSBmZWQ/ -CgpXZSBkb24ndCBuZWVkIHRvIGJlIHNhdmVkIGZyb20gYSBwcm9ncmFtIHRoYXQgaGFzIGhlbHBl -ZCB0aG91c2FuZHMuIFdlIG5lZWQgeW91IHRvIGJ1Y2tsZSBkb3duIGFuZCBpbXByb3ZlIHRoZSBB -Q0EuIFlvdSBoYWQgeWVhcnMgdG8gY29tZSB1cCB3aXRoIGEgcGxhbi4gWW91IGZhaWxlZC4gCgpU -aGUgbGF0ZXN0IHZlcnNpb24geW91ciBwYXJ0eSBoYXMgY29tZSB1cCB3aXRoIHVzIHdvcnNlIHRo -YW4gdGhlIGxhc3QuIFBsZWFzZSB2b3RlIGFnYWluc3QgaXQuIERvbid0IGNvbmRlbW4gdGhlIGdv -b2Qgb2YgcGVvcGxlIG9mIE5DIHRvIGxpdmVzIHRoYXQgYXJlIG5hc3R5LCBicnV0aXNoIGFuZCBz -aG9ydC4gSSdtIG9uZSBvZiB0aGUgZm9sa3Mgd2hvIHdpbGwgZGllIGlmIHlvdSByaXAgdGhlIHBy -b3RlY3Rpb25zIGF3YXkuIAoKVm90ZSBOTyBvbiBhbnkgYmlsbCB0aGF0IGRvZXNuJ3QgY29udGFp -biBwcm90ZWN0aW9ucyBpbnN0ZWFkIG9mIHB1bmlzaG1lbnRzLiBXZSBhcmUgd2F0Y2hpbmcgY2xv -c2VseS4g` - -func TestDecodePossibleBase64(t *testing.T) { - assert := assert.New(t) - assert.Equal("This test\nhas a newline", DecodePossibleBase64("This test\nhas a newline")) - assert.Equal("Please vote NO on the confirmation of Gorsuch.", DecodePossibleBase64("Please vote NO on the confirmation of Gorsuch.")) - assert.Equal("Bannon Explains The World ...\n“The Camp of the Saints", DecodePossibleBase64("QmFubm9uIEV4cGxhaW5zIFRoZSBXb3JsZCAuLi4K4oCcVGhlIENhbXAgb2YgdGhlIFNhaW50c+KA\r")) - assert.Equal("the sweat, the tears and the sacrifice of working America", DecodePossibleBase64("dGhlIHN3ZWF0LCB0aGUgdGVhcnMgYW5kIHRoZSBzYWNyaWZpY2Ugb2Ygd29ya2luZyBBbWVyaWNh\r")) - assert.Contains(DecodePossibleBase64("Tm93IGlzDQp0aGUgdGltZQ0KZm9yIGFsbCBnb29kDQpwZW9wbGUgdG8NCnJlc2lzdC4NCg0KSG93IGFib3V0IGhhaWt1cz8NCkkgZmluZCB0aGVtIHRvIGJlIGZyaWVuZGx5Lg0KcmVmcmlnZXJhdG9yDQoNCjAxMjM0NTY3ODkNCiFAIyQlXiYqKCkgW117fS09Xys7JzoiLC4vPD4/fFx+YA0KQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5eg=="), "I find them to be friendly") - assert.Contains(DecodePossibleBase64(test6), "I received your letter today") -} - -func TestSplitMsg(t *testing.T) { - assert := assert.New(t) - assert.Equal([]string{""}, SplitMsg("", 160)) - assert.Equal([]string{"Simple message"}, SplitMsg("Simple message", 160)) - assert.Equal([]string{"This is a message", "longer than 10"}, SplitMsg("This is a message longer than 10", 20)) - assert.Equal([]string{" "}, SplitMsg(" ", 20)) - assert.Equal([]string{"This is a message", "longer than 10"}, SplitMsg("This is a message longer than 10", 20)) -} - -func TestSplitMsgByChannel(t *testing.T) { - assert := assert.New(t) - var channelWithMaxLength = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "AC", "2020", "US", - map[string]interface{}{ - courier.ConfigUsername: "user1", - courier.ConfigPassword: "pass1", - courier.ConfigMaxLength: 25, - }) - var channelWithoutMaxLength = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "AC", "2020", "US", - map[string]interface{}{ - courier.ConfigUsername: "user1", - courier.ConfigPassword: "pass1", - }) - - assert.Equal([]string{""}, SplitMsgByChannel(channelWithoutMaxLength, "", 160)) - assert.Equal([]string{"Simple message"}, SplitMsgByChannel(channelWithoutMaxLength, "Simple message", 160)) - assert.Equal([]string{"This is a message", "longer than 10"}, SplitMsgByChannel(channelWithoutMaxLength, "This is a message longer than 10", 20)) - assert.Equal([]string{" "}, SplitMsgByChannel(channelWithoutMaxLength, " ", 20)) - assert.Equal([]string{"This is a message", "longer than 10"}, SplitMsgByChannel(channelWithoutMaxLength, "This is a message longer than 10", 20)) - - // Max length should be the one configured on the channel - assert.Equal([]string{""}, SplitMsgByChannel(channelWithMaxLength, "", 160)) - assert.Equal([]string{"Simple message"}, SplitMsgByChannel(channelWithMaxLength, "Simple message", 160)) - assert.Equal([]string{"This is a message longer", "than 10"}, SplitMsgByChannel(channelWithMaxLength, "This is a message longer than 10", 20)) - assert.Equal([]string{" "}, SplitMsgByChannel(channelWithMaxLength, " ", 20)) - assert.Equal([]string{"This is a message", "longer than 10"}, SplitMsgByChannel(channelWithMaxLength, "This is a message longer than 10", 20)) +func TestRequestHTTP(t *testing.T) { + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ + "https://api.messages.com/send.json": { + httpx.NewMockResponse(200, nil, []byte(`{"status":"success"}`)), + httpx.NewMockResponse(400, nil, []byte(`{"status":"error"}`)), + }, + })) + defer httpx.SetRequestor(httpx.DefaultRequestor) + + mb := test.NewMockBackend() + mc := test.NewMockChannel("7a8ff1d4-f211-4492-9d05-e1905f6da8c8", "NX", "1234", "EC", nil) + mm := mb.NewOutgoingMsg(mc, 123, urns.URN("tel:+1234"), "Hello World", false, nil, "", "", courier.MsgOriginChat, nil) + clog := courier.NewChannelLogForSend(mm, nil) + + config := courier.NewConfig() + server := test.NewMockServer(config, mb) + + h := handlers.NewBaseHandler("NX", "Test") + h.SetServer(server) + + req, _ := http.NewRequest("POST", "https://api.messages.com/send.json", nil) + resp, respBody, err := h.RequestHTTP(req, clog) + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, []byte(`{"status":"success"}`), respBody) + assert.Len(t, clog.HTTPLogs(), 1) + + hlog1 := clog.HTTPLogs()[0] + assert.Equal(t, 200, hlog1.StatusCode) + assert.Equal(t, "https://api.messages.com/send.json", hlog1.URL) + + req, _ = http.NewRequest("POST", "https://api.messages.com/send.json", nil) + resp, _, err = h.RequestHTTP(req, clog) + assert.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + assert.Len(t, clog.HTTPLogs(), 2) + + hlog2 := clog.HTTPLogs()[1] + assert.Equal(t, 400, hlog2.StatusCode) + assert.Equal(t, "https://api.messages.com/send.json", hlog2.URL) } diff --git a/handlers/blackmyna/blackmyna.go b/handlers/blackmyna/blackmyna.go deleted file mode 100644 index bc2211453..000000000 --- a/handlers/blackmyna/blackmyna.go +++ /dev/null @@ -1,153 +0,0 @@ -package blackmyna - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/buger/jsonparser" - "github.com/nyaruka/courier" - "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/gocommon/httpx" - "github.com/pkg/errors" -) - -var sendURL = "http://api.blackmyna.com/2/smsmessaging/outbound" - -type handler struct { - handlers.BaseHandler -} - -func newHandler() courier.ChannelHandler { - return &handler{handlers.NewBaseHandler(courier.ChannelType("BM"), "Blackmyna")} -} - -func init() { - courier.RegisterHandler(newHandler()) -} - -// 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.ChannelLogTypeMsgReceive, h.receiveMessage) - s.AddHandlerRoute(h, http.MethodGet, "status", courier.ChannelLogTypeMsgStatus, h.StatusMessage) - return nil -} - -type moForm struct { - To string `validate:"required" name:"to"` - Text string `validate:"required" name:"text"` - From string `validate:"required" name:"from"` -} - -// receiveMessage is our HTTP handler function for incoming messages -func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, error) { - // get our params - form := &moForm{} - err := handlers.DecodeAndValidateForm(form, r) - if err != nil { - return nil, err - } - - // create our URN - urn, err := handlers.StrictTelForCountry(form.From, channel.Country()) - if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) - } - - // build our msg - msg := h.Backend().NewIncomingMsg(channel, urn, form.Text, "", clog) - - // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) -} - -type statusForm struct { - ID string `validate:"required" name:"id"` - Status int `validate:"required" name:"status"` -} - -var statusMapping = map[int]courier.MsgStatusValue{ - 1: courier.MsgDelivered, - 2: courier.MsgFailed, - 8: courier.MsgSent, - 16: courier.MsgFailed, -} - -// StatusMessage is our HTTP handler function for status updates -func (h *handler) StatusMessage(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, error) { - // get our params - form := &statusForm{} - err := handlers.DecodeAndValidateForm(form, r) - if err != nil { - return nil, err - } - - msgStatus, found := statusMapping[form.Status] - if !found { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("unknown status '%d', must be one of 1, 2, 8 or 16", form.Status)) - } - - // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, form.ID, msgStatus, clog) - return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) -} - -// Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { - username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") - if username == "" { - return nil, fmt.Errorf("no username set for BM channel") - } - - password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "") - if password == "" { - return nil, fmt.Errorf("no password set for BM channel") - } - - apiKey := msg.Channel().StringConfigForKey(courier.ConfigAPIKey, "") - if apiKey == "" { - return nil, fmt.Errorf("no API key set for BM channel") - } - - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) - - // build our request - form := url.Values{ - "address": []string{msg.URN().Path()}, - "senderaddress": []string{msg.Channel().Address()}, - "message": []string{handlers.GetTextAndAttachments(msg)}, - } - - req, err := http.NewRequest(http.MethodPost, sendURL, strings.NewReader(form.Encode())) - - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetBasicAuth(username, password) - - resp, respBody, err := handlers.RequestHTTP(req, clog) - if err != nil || resp.StatusCode/100 != 2 { - return status, nil - } - - // get our external id - externalID, _ := jsonparser.GetString(respBody, "[0]", "id") - if externalID == "" { - return status, errors.Errorf("no external id returned in body") - } - - status.SetStatus(courier.MsgWired) - status.SetExternalID(externalID) - - return status, nil -} - -func (h *handler) RedactValues(ch courier.Channel) []string { - return []string{ - httpx.BasicAuth(ch.StringConfigForKey(courier.ConfigUsername, ""), ch.StringConfigForKey(courier.ConfigPassword, "")), - } -} diff --git a/handlers/blackmyna/blackmyna_test.go b/handlers/blackmyna/blackmyna_test.go deleted file mode 100644 index d0d666dc2..000000000 --- a/handlers/blackmyna/blackmyna_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package blackmyna - -import ( - "net/http/httptest" - "testing" - - "github.com/nyaruka/courier" - . "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/test" - "github.com/nyaruka/gocommon/httpx" -) - -var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "BM", "2020", "US", nil), -} - -const ( - receiveURL = "/c/bm/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" - statusURL = "/c/bm/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status/" -) - -var testCases = []ChannelHandleTestCase{ - { - Label: "Receive Valid", - URL: receiveURL + "?to=3344&smsc=ncell&from=%2B9779814641111&text=Msg", - ExpectedRespStatus: 200, - ExpectedBodyContains: "Message Accepted", - ExpectedMsgText: Sp("Msg"), - ExpectedURN: "tel:+9779814641111", - }, - { - Label: "Invalid URN", - URL: receiveURL + "?to=3344&smsc=ncell&from=MTN&text=Msg", - ExpectedRespStatus: 400, - ExpectedBodyContains: "phone number supplied is not a number", - }, - { - Label: "Receive Empty", - URL: receiveURL + "", - ExpectedRespStatus: 400, - ExpectedBodyContains: "field 'text' required", - }, - { - Label: "Receive Missing Text", - URL: receiveURL + "?to=3344&smsc=ncell&from=%2B9779814641111", - ExpectedRespStatus: 400, - ExpectedBodyContains: "field 'text' required", - }, - { - Label: "Status Invalid", - URL: statusURL + "?id=bmID&status=13", - ExpectedRespStatus: 400, - ExpectedBodyContains: "unknown status", - }, - { - Label: "Status Missing", - URL: statusURL + "?", - ExpectedRespStatus: 400, - ExpectedBodyContains: "field 'status' required", - }, - { - Label: "Valid Status", - URL: statusURL + "?id=bmID&status=2", - ExpectedRespStatus: 200, - ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, - }, -} - -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) -} - -func BenchmarkHandler(b *testing.B) { - RunChannelBenchmarks(b, testChannels, newHandler(), testCases) -} - -// setSend takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { - sendURL = s.URL -} - -var defaultSendTestCases = []ChannelSendTestCase{ - { - Label: "Plain Send", - MsgText: "Simple Message", - MsgURN: "tel:+250788383383", - MockResponseBody: `[{"id": "1002"}]`, - MockResponseStatus: 200, - ExpectedHeaders: map[string]string{"Authorization": "Basic VXNlcm5hbWU6UGFzc3dvcmQ="}, - ExpectedPostParams: map[string]string{"message": "Simple Message", "address": "+250788383383", "senderaddress": "2020"}, - ExpectedMsgStatus: "W", - ExpectedExternalID: "1002", - SendPrep: setSendURL, - }, - { - Label: "Unicode Send", - MsgText: "☺", - MsgURN: "tel:+250788383383", - MockResponseBody: `[{"id": "1002"}]`, - MockResponseStatus: 200, - ExpectedPostParams: map[string]string{"message": "☺", "address": "+250788383383", "senderaddress": "2020"}, - ExpectedMsgStatus: "W", - ExpectedExternalID: "1002", - SendPrep: setSendURL, - }, - { - Label: "Send Attachment", - MsgText: "My pic!", - MsgURN: "tel:+250788383383", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, - MockResponseBody: `[{ "id": "1002" }]`, - MockResponseStatus: 200, - ExpectedPostParams: map[string]string{"message": "My pic!\nhttps://foo.bar/image.jpg", "address": "+250788383383", "senderaddress": "2020"}, - ExpectedMsgStatus: "W", - ExpectedExternalID: "1002", - SendPrep: setSendURL, - }, - { - Label: "No External Id", - MsgText: "No External ID", - MsgURN: "tel:+250788383383", - MockResponseBody: `{ "error": "failed" }`, - MockResponseStatus: 200, - ExpectedErrors: []*courier.ChannelError{courier.NewChannelError("", "", "no external id returned in body")}, - ExpectedPostParams: map[string]string{"message": `No External ID`, "address": "+250788383383", "senderaddress": "2020"}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, - { - Label: "Error Sending", - MsgText: "Error Message", - MsgURN: "tel:+250788383383", - MockResponseBody: `{ "error": "failed" }`, - MockResponseStatus: 401, - ExpectedPostParams: map[string]string{"message": `Error Message`, "address": "+250788383383", "senderaddress": "2020"}, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, -} - -func TestSending(t *testing.T) { - var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "BM", "2020", "US", - map[string]interface{}{ - courier.ConfigPassword: "Password", - courier.ConfigUsername: "Username", - courier.ConfigAPIKey: "KEY", - }) - - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("Username", "Password")}, nil) -} diff --git a/handlers/bongolive/bongolive.go b/handlers/bongolive/handler.go similarity index 81% rename from handlers/bongolive/bongolive.go rename to handlers/bongolive/handler.go index 6df8767c7..eead3510d 100644 --- a/handlers/bongolive/bongolive.go +++ b/handlers/bongolive/handler.go @@ -39,18 +39,18 @@ func (h *handler) Initialize(s courier.Server) error { return nil } -var statusMapping = map[int]courier.MsgStatusValue{ - 1: courier.MsgDelivered, - 2: courier.MsgSent, - 3: courier.MsgErrored, - 4: courier.MsgErrored, - 5: courier.MsgErrored, - 6: courier.MsgErrored, - 7: courier.MsgErrored, - 8: courier.MsgSent, - 9: courier.MsgErrored, - 10: courier.MsgErrored, - 11: courier.MsgErrored, +var statusMapping = map[int]courier.MsgStatus{ + 1: courier.MsgStatusDelivered, + 2: courier.MsgStatusSent, + 3: courier.MsgStatusErrored, + 4: courier.MsgStatusErrored, + 5: courier.MsgStatusErrored, + 6: courier.MsgStatusErrored, + 7: courier.MsgStatusErrored, + 8: courier.MsgStatusSent, + 9: courier.MsgStatusErrored, + 10: courier.MsgStatusErrored, + 11: courier.MsgStatusErrored, } type moForm struct { @@ -85,7 +85,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, form.DLRID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, form.DLRID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } @@ -101,15 +101,15 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w msg := h.Backend().NewIncomingMsg(channel, urn, form.Message, form.ID, clog).WithReceivedOn(time.Now().UTC()) // and finally queue our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } -func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { return writeBongoLiveResponse(w) } -func (h *handler) WriteStatusSuccessResponse(ctx context.Context, w http.ResponseWriter, statuses []courier.MsgStatus) error { +func (h *handler) WriteStatusSuccessResponse(ctx context.Context, w http.ResponseWriter, statuses []courier.StatusUpdate) error { return writeBongoLiveResponse(w) } @@ -126,7 +126,7 @@ func writeBongoLiveResponse(w http.ResponseWriter) error { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for %s channel", msg.Channel().ChannelType()) @@ -137,7 +137,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no password set for %s channel", msg.Channel().ChannelType()) } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for _, part := range parts { form := url.Values{ @@ -165,7 +165,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, respBody, err := handlers.RequestHTTPInsecure(req, clog) + resp, respBody, err := h.RequestHTTPInsecure(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -173,12 +173,12 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // was this request successful? msgStatus, _ := jsonparser.GetString(respBody, "results", "[0]", "status") if msgStatus != "0" { - status.SetStatus(courier.MsgErrored) + status.SetStatus(courier.MsgStatusErrored) return status, nil } // grab the external id if we can externalID, _ := jsonparser.GetString(respBody, "results", "[0]", "msgid") - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) status.SetExternalID(externalID) } diff --git a/handlers/bongolive/bongolive_test.go b/handlers/bongolive/handler_test.go similarity index 74% rename from handlers/bongolive/bongolive_test.go rename to handlers/bongolive/handler_test.go index 251608aad..4689add5f 100644 --- a/handlers/bongolive/bongolive_test.go +++ b/handlers/bongolive/handler_test.go @@ -2,6 +2,7 @@ package bongolive import ( "net/http/httptest" + "net/url" "testing" "github.com/nyaruka/courier" @@ -17,7 +18,7 @@ const ( receiveURL = "/c/bl/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -46,8 +47,8 @@ var testCases = []ChannelHandleTestCase{ { Label: "Status No params", URL: receiveURL, - Data: "", - ExpectedRespStatus: 405, + Data: "&", + ExpectedRespStatus: 400, ExpectedBodyContains: "", }, { @@ -63,7 +64,7 @@ var testCases = []ChannelHandleTestCase{ Data: "msgtype=5&dlrid=12345&status=1", ExpectedRespStatus: 200, ExpectedBodyContains: "", - ExpectedMsgStatus: courier.MsgDelivered, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusDelivered}}, }, { Label: "Invalid Msg Type", @@ -74,19 +75,19 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -94,13 +95,18 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, MockResponseBody: `{"results": [{"status": "0", "msgid": "123"}]}`, MockResponseStatus: 200, - ExpectedURLParams: map[string]string{ - "USERNAME": "user1", - "PASSWORD": "pass1", - "SOURCEADDR": "2020", - "DESTADDR": "250788383383", - "DLR": "1", - "MESSAGE": "Simple Message ☺\nhttps://foo.bar/image.jpg", + ExpectedRequests: []ExpectedRequest{ + { + Params: url.Values{ + "USERNAME": {"user1"}, + "PASSWORD": {"pass1"}, + "SOURCEADDR": {"2020"}, + "DESTADDR": {"250788383383"}, + "DLR": {"1"}, + "MESSAGE": {"Simple Message ☺\nhttps://foo.bar/image.jpg"}, + "CHARCODE": {"2"}, + }, + }, }, ExpectedMsgStatus: "W", ExpectedExternalID: "123", @@ -113,13 +119,18 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, MockResponseBody: `{"results": [{"status": "3"}]}`, MockResponseStatus: 200, - ExpectedURLParams: map[string]string{ - "USERNAME": "user1", - "PASSWORD": "pass1", - "SOURCEADDR": "2020", - "DESTADDR": "250788383383", - "DLR": "1", - "MESSAGE": "Simple Message ☺\nhttps://foo.bar/image.jpg", + ExpectedRequests: []ExpectedRequest{ + { + Params: url.Values{ + "USERNAME": {"user1"}, + "PASSWORD": {"pass1"}, + "SOURCEADDR": {"2020"}, + "DESTADDR": {"250788383383"}, + "DLR": {"1"}, + "MESSAGE": {"Simple Message ☺\nhttps://foo.bar/image.jpg"}, + "CHARCODE": {"2"}, + }, + }, }, ExpectedMsgStatus: "E", SendPrep: setSendURL, @@ -144,11 +155,11 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "BL", "2020", "KE", - map[string]interface{}{ + map[string]any{ courier.ConfigUsername: "user1", courier.ConfigPassword: "pass1", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"pass1"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"pass1"}, nil) } diff --git a/handlers/burstsms/burstsms.go b/handlers/burstsms/handler.go similarity index 83% rename from handlers/burstsms/burstsms.go rename to handlers/burstsms/handler.go index b33faf1d4..9f8a244de 100644 --- a/handlers/burstsms/burstsms.go +++ b/handlers/burstsms/handler.go @@ -16,11 +16,11 @@ import ( var ( sendURL = "https://api.transmitsms.com/send-sms.json" maxMsgLength = 612 - statusMap = map[string]courier.MsgStatusValue{ - "delivered": courier.MsgDelivered, - "pending": courier.MsgSent, - "soft-bounce": courier.MsgErrored, - "hard-bounce": courier.MsgFailed, + statusMap = map[string]courier.MsgStatus{ + "delivered": courier.MsgStatusDelivered, + "pending": courier.MsgStatusSent, + "soft-bounce": courier.MsgStatusErrored, + "hard-bounce": courier.MsgStatusFailed, } ) @@ -57,7 +57,7 @@ type mtResponse struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for BS channel") @@ -68,7 +68,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no password set for BS channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) for _, part := range handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) { form := url.Values{ "to": []string{strings.TrimLeft(msg.URN().Path(), "+")}, @@ -84,7 +84,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -98,10 +98,10 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } if response.MessageID != 0 { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) status.SetExternalID(fmt.Sprintf("%d", response.MessageID)) } else { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) clog.Error(courier.ErrorResponseValueMissing("message_id")) break } diff --git a/handlers/burstsms/burstsms_test.go b/handlers/burstsms/handler_test.go similarity index 81% rename from handlers/burstsms/burstsms_test.go rename to handlers/burstsms/handler_test.go index 051515312..eae25d0d9 100644 --- a/handlers/burstsms/burstsms_test.go +++ b/handlers/burstsms/handler_test.go @@ -2,6 +2,7 @@ package burstsms import ( "net/http/httptest" + "net/url" "testing" "github.com/nyaruka/courier" @@ -19,7 +20,7 @@ const ( statusURL = "/c/bs/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status/" ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL + "?response=Msg&mobile=254791541111", @@ -39,8 +40,7 @@ var testCases = []ChannelHandleTestCase{ URL: statusURL + "?message_id=12345&status=pending", ExpectedRespStatus: 200, ExpectedBodyContains: "Status Update Accepted", - ExpectedExternalID: "12345", - ExpectedMsgStatus: "S", + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusSent}}, }, { Label: "Receive Invalid Status", @@ -50,19 +50,19 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -70,10 +70,14 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, MockResponseBody: `{ "message_id": 19835, "recipients": 3, "cost": 1.000 }`, MockResponseStatus: 200, - ExpectedPostParams: map[string]string{ - "to": "250788383383", - "message": "Simple Message ☺\nhttps://foo.bar/image.jpg", - "from": "2020", + ExpectedRequests: []ExpectedRequest{ + { + Form: url.Values{ + "to": {"250788383383"}, + "message": {"Simple Message ☺\nhttps://foo.bar/image.jpg"}, + "from": {"2020"}, + }, + }, }, ExpectedMsgStatus: "W", ExpectedExternalID: "19835", @@ -110,11 +114,11 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "BS", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigUsername: "user1", courier.ConfigPassword: "pass1", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("user1", "pass1")}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("user1", "pass1")}, nil) } diff --git a/handlers/clickatell/clickatell.go b/handlers/clickatell/handler.go similarity index 82% rename from handlers/clickatell/clickatell.go rename to handlers/clickatell/handler.go index 64c229acf..a5ad4be5b 100644 --- a/handlers/clickatell/clickatell.go +++ b/handlers/clickatell/handler.go @@ -47,20 +47,20 @@ type statusPayload struct { StatusCode int `name:"statusCode"` } -var statusMapping = map[int]courier.MsgStatusValue{ - 1: courier.MsgFailed, // incorrect msg id - 2: courier.MsgWired, // queued - 3: courier.MsgSent, // delivered to upstream gateway - 4: courier.MsgSent, // delivered to upstream gateway - 5: courier.MsgFailed, // error in message - 6: courier.MsgFailed, // terminated by user - 7: courier.MsgFailed, // error delivering - 8: courier.MsgWired, // msg received - 9: courier.MsgFailed, // error routing - 10: courier.MsgFailed, // expired - 11: courier.MsgWired, // delayed but queued - 12: courier.MsgFailed, // out of credit - 14: courier.MsgFailed, // too long +var statusMapping = map[int]courier.MsgStatus{ + 1: courier.MsgStatusFailed, // incorrect msg id + 2: courier.MsgStatusWired, // queued + 3: courier.MsgStatusSent, // delivered to upstream gateway + 4: courier.MsgStatusSent, // delivered to upstream gateway + 5: courier.MsgStatusFailed, // error in message + 6: courier.MsgStatusFailed, // terminated by user + 7: courier.MsgStatusFailed, // error delivering + 8: courier.MsgStatusWired, // msg received + 9: courier.MsgStatusFailed, // error routing + 10: courier.MsgStatusFailed, // expired + 11: courier.MsgStatusWired, // delayed but queued + 12: courier.MsgStatusFailed, // out of credit + 14: courier.MsgStatusFailed, // too long } // receiveStatus is our HTTP handler function for status updates @@ -77,7 +77,7 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, payload.MessageID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, payload.MessageID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } @@ -131,7 +131,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w msg := h.Backend().NewIncomingMsg(channel, urn, text, payload.MessageID, clog).WithReceivedOn(date.UTC()) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // utility method to decode crazy clickatell 16 bit format @@ -154,13 +154,13 @@ func decodeUTF16BE(b []byte) (string, error) { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { apiKey := msg.Channel().StringConfigForKey(courier.ConfigAPIKey, "") if apiKey == "" { return nil, fmt.Errorf("no api_key set for CT channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for _, part := range parts { form := url.Values{ @@ -180,7 +180,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -190,7 +190,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann if err != nil { clog.Error(courier.ErrorResponseValueMissing("apiMessageId")) } else { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) status.SetExternalID(externalID) } } diff --git a/handlers/clickatell/clickatell_test.go b/handlers/clickatell/handler_test.go similarity index 92% rename from handlers/clickatell/clickatell_test.go rename to handlers/clickatell/handler_test.go index ac862d965..eebc079c3 100644 --- a/handlers/clickatell/clickatell_test.go +++ b/handlers/clickatell/handler_test.go @@ -11,14 +11,14 @@ import ( ) // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } var successSendResponse = `{"messages":[{"apiMessageId":"id1002","accepted":true,"to":"12067799299","error":null}],"error":null}` var failSendResponse = `{"messages":[],"error":"Two-Way integration error - From number is not related to integration"}` -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -76,19 +76,19 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "CT", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigAPIKey: "API-KEY", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"API-KEY"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"API-KEY"}, nil) } var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "CT", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigAPIKey: "12345", }), } @@ -130,7 +130,7 @@ const ( }` ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Valid Receive", URL: receiveURL, @@ -191,7 +191,7 @@ var testCases = []ChannelHandleTestCase{ Data: `{"messageId": "msg1", "statusCode": 5}`, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "msg1", Status: courier.MsgStatusFailed}}, }, { Label: "Valid Delivered status report", @@ -199,7 +199,7 @@ var testCases = []ChannelHandleTestCase{ Data: `{"messageId": "msg1", "statusCode": 4}`, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, - ExpectedMsgStatus: courier.MsgSent, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "msg1", Status: courier.MsgStatusSent}}, }, { Label: "Unexpected status report", @@ -224,8 +224,8 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { diff --git a/handlers/clickmobile/clickmobile.go b/handlers/clickmobile/handler.go similarity index 92% rename from handlers/clickmobile/clickmobile.go rename to handlers/clickmobile/handler.go index f58df01fa..c0ac4b0cf 100644 --- a/handlers/clickmobile/clickmobile.go +++ b/handlers/clickmobile/handler.go @@ -80,7 +80,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w msg := h.Backend().NewIncomingMsg(channel, urn, payload.Text, payload.ReferenceID, clog) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } type mtPayload struct { @@ -98,7 +98,7 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for CM channel") @@ -121,7 +121,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann cmSendURL := msg.Channel().StringConfigForKey(courier.ConfigSendURL, sendURL) - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) for _, part := range handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) { @@ -156,14 +156,14 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } responseCode, _ := jsonparser.GetString(respBody, "code") if responseCode == "000" { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } else { clog.Error(courier.ErrorResponseValueUnexpected("code", "000")) } diff --git a/handlers/clickmobile/clickmobile_test.go b/handlers/clickmobile/handler_test.go similarity index 94% rename from handlers/clickmobile/clickmobile_test.go rename to handlers/clickmobile/handler_test.go index cff119ff4..584f313b5 100644 --- a/handlers/clickmobile/clickmobile_test.go +++ b/handlers/clickmobile/handler_test.go @@ -64,7 +64,7 @@ var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "CM", "2020", "MW", nil), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL, @@ -134,8 +134,8 @@ var handleTestCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { @@ -143,13 +143,13 @@ func BenchmarkHandler(b *testing.B) { } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { c.(*test.MockChannel).SetConfig(courier.ConfigSendURL, s.URL) sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -197,9 +197,9 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "CM", "2020", "MW", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username", "app_id": "001-app", @@ -211,5 +211,5 @@ func TestSending(t *testing.T) { // mock time so we can have predictable MD5 hashes dates.SetNowSource(dates.NewFixedNowSource(time.Date(2018, 4, 11, 18, 24, 30, 123456000, time.UTC))) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/clicksend/clicksend.go b/handlers/clicksend/handler.go similarity index 90% rename from handlers/clicksend/clicksend.go rename to handlers/clicksend/handler.go index 1461fba1c..ce8f1610d 100644 --- a/handlers/clicksend/clicksend.go +++ b/handlers/clicksend/handler.go @@ -61,7 +61,7 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("Missing 'username' config for CS channel") @@ -72,7 +72,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("Missing 'password' config for CS channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for _, part := range parts { payload := &mtPayload{} @@ -93,7 +93,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.SetBasicAuth(username, password) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -113,7 +113,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } status.SetExternalID(id) - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil diff --git a/handlers/clicksend/clicksend_test.go b/handlers/clicksend/handler_test.go similarity index 92% rename from handlers/clicksend/clicksend_test.go rename to handlers/clicksend/handler_test.go index 1cfe9a75c..0b865afeb 100644 --- a/handlers/clicksend/clicksend_test.go +++ b/handlers/clicksend/handler_test.go @@ -18,7 +18,7 @@ var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "CS", "2020", "US", nil), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL, @@ -39,8 +39,8 @@ var handleTestCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { @@ -48,7 +48,7 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } @@ -110,7 +110,7 @@ const failureResponse = `{ ] }` -var sendTestCases = []ChannelSendTestCase{ +var sendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -167,13 +167,13 @@ var sendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "GL", "2020", "US", - map[string]interface{}{ + map[string]any{ "username": "Aladdin", "password": "open sesame", }, ) - RunChannelSendTestCases(t, defaultChannel, newHandler(), sendTestCases, []string{httpx.BasicAuth("Aladdin", "open sesame")}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), sendTestCases, []string{httpx.BasicAuth("Aladdin", "open sesame")}, nil) } diff --git a/handlers/dart/dart.go b/handlers/dart/handler.go similarity index 86% rename from handlers/dart/dart.go rename to handlers/dart/handler.go index 4c23c0e68..9e91bfead 100644 --- a/handlers/dart/dart.go +++ b/handlers/dart/handler.go @@ -56,9 +56,10 @@ func (h *handler) Initialize(s courier.Server) error { } type moForm struct { - Message string `name:"message"` - Original string `name:"original"` - SendTo string `name:"sendto"` + Message string `name:"message"` + Original string `name:"original"` + SendTo string `name:"sendto"` + MessageID string `name:"messageid"` } // receiveMessage is our HTTP handler function for incoming messages @@ -83,10 +84,10 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w } // build our msg - msg := h.Backend().NewIncomingMsg(channel, urn, form.Message, "", clog) + msg := h.Backend().NewIncomingMsg(channel, urn, form.Message, form.MessageID, clog) // and finally queue our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } type statusForm struct { @@ -111,13 +112,13 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("parsing failed: status '%s' is not an integer", form.Status)) } - msgStatus := courier.MsgSent + msgStatus := courier.MsgStatusSent if statusInt >= 10 && statusInt <= 12 { - msgStatus = courier.MsgDelivered + msgStatus = courier.MsgStatusDelivered } if statusInt > 20 { - msgStatus = courier.MsgFailed + msgStatus = courier.MsgStatusFailed } msgID, err := strconv.ParseInt(strings.Split(form.MessageID, ".")[0], 10, 64) @@ -126,26 +127,26 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // write our status - status := h.Backend().NewMsgStatusForID(channel, courier.MsgID(msgID), msgStatus, clog) + status := h.Backend().NewStatusUpdate(channel, courier.MsgID(msgID), msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } // DartMedia expects "000" from a message receive request -func (h *handler) WriteStatusSuccessResponse(ctx context.Context, w http.ResponseWriter, statuses []courier.MsgStatus) error { +func (h *handler) WriteStatusSuccessResponse(ctx context.Context, w http.ResponseWriter, statuses []courier.StatusUpdate) error { w.WriteHeader(200) _, err := fmt.Fprint(w, "000") return err } // DartMedia expects "000" from a status request -func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { w.WriteHeader(200) _, err := fmt.Fprint(w, "000") return err } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for %s channel", msg.Channel().ChannelType()) @@ -156,7 +157,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no password set for %s channel", msg.Channel().ChannelType()) } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), h.maxLength) for i, part := range parts { form := url.Values{ @@ -185,7 +186,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -196,7 +197,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil diff --git a/handlers/dart/dart_test.go b/handlers/dart/handler_test.go similarity index 86% rename from handlers/dart/dart_test.go rename to handlers/dart/handler_test.go index 13723bedd..df0bd0188 100644 --- a/handlers/dart/dart_test.go +++ b/handlers/dart/handler_test.go @@ -18,22 +18,24 @@ const ( statusURL = "/c/da/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/delivered/" ) -var daTestCases = []ChannelHandleTestCase{ +var daTestCases = []IncomingTestCase{ { Label: "Receive Valid", - URL: receiveURL + "?userid=testusr&password=test&original=6289881134560&sendto=2020&message=Msg", + URL: receiveURL + "?userid=testusr&password=test&original=6289881134560&sendto=2020&message=Msg&messageid=foo", ExpectedRespStatus: 200, ExpectedBodyContains: "000", ExpectedMsgText: Sp("Msg"), ExpectedURN: "tel:+6289881134560", + ExpectedExternalID: "foo", }, { Label: "Receive Valid", - URL: receiveURL + "?userid=testusr&password=test&original=cmp-oodddqddwdwdcd&sendto=2020&message=Msg", + URL: receiveURL + "?userid=testusr&password=test&original=cmp-oodddqddwdwdcd&sendto=2020&message=Msg&messageid=bar", ExpectedRespStatus: 200, ExpectedBodyContains: "000", ExpectedMsgText: Sp("Msg"), ExpectedURN: "ext:cmp-oodddqddwdwdcd", + ExpectedExternalID: "bar", }, { Label: "Receive Invalid", @@ -47,21 +49,21 @@ var daTestCases = []ChannelHandleTestCase{ URL: statusURL + "?status=10&messageid=12345", ExpectedRespStatus: 200, ExpectedBodyContains: "000", - ExpectedMsgStatus: "D", + ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusDelivered}}, }, { Label: "Valid Status", URL: statusURL + "?status=10&messageid=12345.2", ExpectedRespStatus: 200, ExpectedBodyContains: "000", - ExpectedMsgStatus: "D", + ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusDelivered}}, }, { Label: "Failed Status", URL: statusURL + "?status=30&messageid=12345", ExpectedRespStatus: 200, ExpectedBodyContains: "000", - ExpectedMsgStatus: "F", + ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusFailed}}, }, { Label: "Missing Status", @@ -83,8 +85,8 @@ var daTestCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, daTestChannels, NewHandler("DA", "DartMedia", sendURL, maxMsgLength), daTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, daTestChannels, NewHandler("DA", "DartMedia", sendURL, maxMsgLength), daTestCases) } func BenchmarkHandler(b *testing.B) { @@ -92,12 +94,12 @@ func BenchmarkHandler(b *testing.B) { } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { daHandler := h.(*handler) daHandler.sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -163,13 +165,13 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultDAChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "DA", "2020", "ID", - map[string]interface{}{ + map[string]any{ courier.ConfigUsername: "Username", courier.ConfigPassword: "Password", }) - RunChannelSendTestCases(t, defaultDAChannel, NewHandler("DA", "Dartmedia", sendURL, maxMsgLength), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultDAChannel, NewHandler("DA", "Dartmedia", sendURL, maxMsgLength), defaultSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/dialog360/dialog360.go b/handlers/dialog360/dialog360.go deleted file mode 100644 index 52377f320..000000000 --- a/handlers/dialog360/dialog360.go +++ /dev/null @@ -1,961 +0,0 @@ -package dialog360 - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "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.MsgStatusValue{ - "sent": courier.MsgSent, - "delivered": courier.MsgDelivered, - "read": courier.MsgDelivered, - "failed": courier.MsgFailed, -} - -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 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"` - 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 []interface{} - - 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, []interface{}, error) { - // the list of events we deal with - events := make([]courier.Event, 0, 2) - - // the list of data we will return in our response - data := make([]interface{}, 0, 2) - - 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 == "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().NewMsgStatusForExternalID(channel, status.ID, msgStatus, clog) - err := h.Backend().WriteMsgStatus(ctx, event) - - // we don't know about this message, just tell them we ignored it - if err == courier.ErrMsgNotFound { - data = append(data, courier.NewInfoData(fmt.Sprintf("message id: %s not found, ignored", status.ID))) - continue - } - - if err != nil { - return nil, nil, err - } - - events = append(events, event) - data = append(data, courier.NewStatusData(event)) - - } - - 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 - } - - 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("/whatsapp_business/attachments/") - mediaEndpoint := url.ResolveReference(mediaPath).String() - - fileURL := fmt.Sprintf("%s?mid=%s", mediaEndpoint, mediaID) - - 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"` - - 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.MsgStatus, 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().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, 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]) - attType = strings.Split(attType, "/")[0] - 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" { - 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.MsgStatus, wacPhoneURL *url.URL, zeroIndex bool, clog *courier.ChannelLog) (courier.MsgStatus, 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.MsgWired) - 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/handler.go b/handlers/dialog360/handler.go new file mode 100644 index 000000000..36f58610e --- /dev/null +++ b/handlers/dialog360/handler.go @@ -0,0 +1,622 @@ +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/handlers/meta/whatsapp" + "github.com/nyaruka/courier/utils" + "github.com/nyaruka/gocommon/jsonx" + "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 +} + +// { +// "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 Notifications struct { + Object string `json:"object"` + Entry []struct { + ID string `json:"id"` + Time int64 `json:"time"` + Changes []whatsapp.Change `json:"changes"` // used by WhatsApp + } `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 *Notifications, 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.processWhatsAppPayload(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) processWhatsAppPayload(ctx context.Context, channel courier.Channel, payload *Notifications, 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 = h.resolveMediaURL(channel, msg.Audio.ID, clog) + } else if msg.Type == "voice" && msg.Voice != nil { + text = msg.Voice.Caption + mediaURL, err = h.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 = h.resolveMediaURL(channel, msg.Document.ID, clog) + } 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 == "video" && msg.Video != nil { + text = msg.Video.Caption + mediaURL, err = h.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 := whatsapp.StatusMapping[status.Status] + if !found { + if whatsapp.IgnoreStatuses[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(d3AuthorizationKey, token) + return req, nil +} + +var _ courier.AttachmentRequestBuilder = (*handler)(nil) + +func (h *handler) 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(d3AuthorizationKey, token) + + resp, respBody, err := h.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 +} + +// Send implements courier.ChannelHandler +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, 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() + menuButton := handlers.GetText("Menu", msg.Locale()) + + var payloadAudio whatsapp.SendRequest + + for i := 0; i < len(msgParts)+len(msg.Attachments()); i++ { + payload := whatsapp.SendRequest{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path()} + + if len(msg.Attachments()) == 0 { + // do we have a template? + templating, err := whatsapp.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 := whatsapp.Template{Name: templating.Template.Name, Language: &whatsapp.Language{Policy: "deterministic", Code: templating.Language}} + payload.Template = &template + + component := &whatsapp.Component{Type: "body"} + + for _, v := range templating.Variables { + component.Params = append(component.Params, &whatsapp.Param{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 := &whatsapp.Text{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 := whatsapp.Interactive{Type: "button", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + btns := make([]whatsapp.Button, len(qrs)) + for i, qr := range qrs { + btns[i] = whatsapp.Button{ + Type: "reply", + } + btns[i].Reply.ID = fmt.Sprint(i) + btns[i].Reply.Title = qr + } + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []whatsapp.Section "json:\"sections,omitempty\"" + Buttons []whatsapp.Button "json:\"buttons,omitempty\"" + }{Buttons: btns} + payload.Interactive = &interactive + } else if len(qrs) <= 10 { + interactive := whatsapp.Interactive{Type: "list", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + section := whatsapp.Section{ + Rows: make([]whatsapp.SectionRow, len(qrs)), + } + for i, qr := range qrs { + section.Rows[i] = whatsapp.SectionRow{ + ID: fmt.Sprint(i), + Title: qr, + } + } + + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []whatsapp.Section "json:\"sections,omitempty\"" + Buttons []whatsapp.Button "json:\"buttons,omitempty\"" + }{Button: menuButton, Sections: []whatsapp.Section{ + 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 := &whatsapp.Text{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]) + attType = strings.Split(attType, "/")[0] + if attType == "application" { + attType = "document" + } + payload.Type = attType + media := whatsapp.Media{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" { + 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 := whatsapp.Interactive{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 := whatsapp.Media{ + Link: attURL, + } + interactive.Header = &struct { + Type string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *whatsapp.Media "json:\"video,omitempty\"" + Image *whatsapp.Media "json:\"image,omitempty\"" + Document *whatsapp.Media "json:\"document,omitempty\"" + }{Type: "image", Image: &image} + } else if attType == "video" { + video := whatsapp.Media{ + Link: attURL, + } + interactive.Header = &struct { + Type string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *whatsapp.Media "json:\"video,omitempty\"" + Image *whatsapp.Media "json:\"image,omitempty\"" + Document *whatsapp.Media "json:\"document,omitempty\"" + }{Type: "video", Video: &video} + } else if attType == "document" { + filename, err := utils.BasePathForURL(attURL) + if err != nil { + return nil, err + } + document := whatsapp.Media{ + Link: attURL, + Filename: filename, + } + interactive.Header = &struct { + Type string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *whatsapp.Media "json:\"video,omitempty\"" + Image *whatsapp.Media "json:\"image,omitempty\"" + Document *whatsapp.Media "json:\"document,omitempty\"" + }{Type: "document", Document: &document} + } else if attType == "audio" { + var zeroIndex bool + if i == 0 { + zeroIndex = true + } + payloadAudio = whatsapp.SendRequest{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path(), Type: "audio", Audio: &whatsapp.Media{Link: attURL}} + status, err := h.requestD3C(payloadAudio, accessToken, status, sendURL, zeroIndex, clog) + if err != nil { + return status, nil + } + } else { + interactive.Type = "button" + interactive.Body.Text = msgParts[i] + } + } + + btns := make([]whatsapp.Button, len(qrs)) + for i, qr := range qrs { + btns[i] = whatsapp.Button{ + Type: "reply", + } + btns[i].Reply.ID = fmt.Sprint(i) + btns[i].Reply.Title = qr + } + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []whatsapp.Section "json:\"sections,omitempty\"" + Buttons []whatsapp.Button "json:\"buttons,omitempty\"" + }{Buttons: btns} + payload.Interactive = &interactive + + } else if len(qrs) <= 10 { + interactive := whatsapp.Interactive{Type: "list", Body: struct { + Text string "json:\"text\"" + }{Text: msgParts[i-len(msg.Attachments())]}} + + section := whatsapp.Section{ + Rows: make([]whatsapp.SectionRow, len(qrs)), + } + for i, qr := range qrs { + section.Rows[i] = whatsapp.SectionRow{ + ID: fmt.Sprint(i), + Title: qr, + } + } + + interactive.Action = &struct { + Button string "json:\"button,omitempty\"" + Sections []whatsapp.Section "json:\"sections,omitempty\"" + Buttons []whatsapp.Button "json:\"buttons,omitempty\"" + }{Button: menuButton, Sections: []whatsapp.Section{ + 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 := &whatsapp.Text{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 := h.requestD3C(payload, accessToken, status, sendURL, zeroIndex, clog) + if err != nil { + return status, err + } + + if hasCaption { + break + } + } + return status, nil +} + +func (h *handler) requestD3C(payload whatsapp.SendRequest, accessToken string, status courier.StatusUpdate, wacPhoneURL *url.URL, zeroIndex bool, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + jsonBody := jsonx.MustMarshal(payload) + + 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, _ := h.RequestHTTP(req, clog) + respPayload := &whatsapp.SendResponse{} + 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 +} diff --git a/handlers/dialog360/dialog360_test.go b/handlers/dialog360/handler_test.go similarity index 81% rename from handlers/dialog360/dialog360_test.go rename to handlers/dialog360/handler_test.go index 23bfcb8a2..ceef82189 100644 --- a/handlers/dialog360/dialog360_test.go +++ b/handlers/dialog360/handler_test.go @@ -3,7 +3,10 @@ package dialog360 import ( "context" "encoding/json" + "fmt" + "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -20,7 +23,7 @@ var testChannels = []courier.Channel{ "D3C", "250788383383", "RW", - map[string]interface{}{ + map[string]any{ "auth_token": "the-auth-token", "base_url": "https://waba-v2.360dialog.io", }), @@ -30,11 +33,11 @@ var ( d3CReceiveURL = "/c/d3c/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive" ) -var testCasesD3C = []ChannelHandleTestCase{ +var testCasesD3C = []IncomingTestCase{ { Label: "Receive Message WAC", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/helloWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/hello.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", NoQueueErrorCheck: true, @@ -47,7 +50,7 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Duplicate Valid Message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/duplicateWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/duplicate.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", NoQueueErrorCheck: true, @@ -60,7 +63,7 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Valid Voice Message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/voiceWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/voice.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", NoQueueErrorCheck: true, @@ -74,7 +77,7 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Valid Button Message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/buttonWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/button.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", NoQueueErrorCheck: true, @@ -87,7 +90,7 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Valid Document Message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/documentWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/document.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", NoQueueErrorCheck: true, @@ -101,7 +104,7 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Valid Image Message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/imageWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/image.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", NoQueueErrorCheck: true, @@ -115,7 +118,7 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Valid Video Message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/videoWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/video.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", NoQueueErrorCheck: true, @@ -129,7 +132,7 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Valid Audio Message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/audioWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/audio.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", NoQueueErrorCheck: true, @@ -143,7 +146,7 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Valid Location Message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/locationWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/location.json")), ExpectedRespStatus: 200, ExpectedBodyContains: `"type":"msg"`, ExpectedMsgText: Sp(""), @@ -162,21 +165,21 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Invalid FROM", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/invalidFrom.json")), + Data: string(test.ReadFile("../meta/testdata/wac/invalid_from.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "invalid whatsapp id", }, { Label: "Receive Invalid timestamp JSON", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/invalidTimestamp.json")), + Data: string(test.ReadFile("../meta/testdata/wac/invalid_timestamp.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "invalid timestamp", }, { Label: "Receive Message WAC with error message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/errorMsg.json")), + Data: string(test.ReadFile("../meta/testdata/wac/error_msg.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("131051", "Unsupported message type")}, @@ -185,7 +188,7 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive error message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/errorErrors.json")), + Data: string(test.ReadFile("../meta/testdata/wac/error_errors.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("0", "We were unable to authenticate the app user")}, @@ -194,40 +197,38 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Valid Status", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/validStatusWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/valid_status.json")), ExpectedRespStatus: 200, ExpectedBodyContains: `"type":"status"`, - ExpectedMsgStatus: "S", - ExpectedExternalID: "external_id", + ExpectedStatuses: []ExpectedStatus{{ExternalID: "external_id", Status: courier.MsgStatusSent}}, }, { Label: "Receive Valid Status with error message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/errorStatus.json")), + Data: string(test.ReadFile("../meta/testdata/wac/error_status.json")), ExpectedRespStatus: 200, ExpectedBodyContains: `"type":"status"`, - ExpectedMsgStatus: "F", - ExpectedExternalID: "external_id", + ExpectedStatuses: []ExpectedStatus{{ExternalID: "external_id", Status: courier.MsgStatusFailed}}, 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")), + Data: string(test.ReadFile("../meta/testdata/wac/invalid_status.json")), ExpectedRespStatus: 200, ExpectedBodyContains: `"unknown status: in_orbit"`, }, { Label: "Receive Ignore Status", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/ignoreStatusWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/ignore_status.json")), ExpectedRespStatus: 200, ExpectedBodyContains: `"ignoring status: deleted"`, }, { Label: "Receive Valid Interactive Button Reply Message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/buttonReplyWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/button_reply.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", NoQueueErrorCheck: true, @@ -240,7 +241,7 @@ var testCasesD3C = []ChannelHandleTestCase{ { Label: "Receive Valid Interactive List Reply Message", URL: d3CReceiveURL, - Data: string(test.ReadFile("./testdata/wac/listReplyWAC.json")), + Data: string(test.ReadFile("../meta/testdata/wac/list_reply.json")), ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", NoQueueErrorCheck: true, @@ -252,11 +253,54 @@ var testCasesD3C = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), testCasesD3C) +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) } @@ -271,11 +315,11 @@ func TestBuildAttachmentRequest(t *testing.T) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { c.(*test.MockChannel).SetConfig("base_url", s.URL) } -var SendTestCasesD3C = []ChannelSendTestCase{ +var SendTestCasesD3C = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -365,38 +409,12 @@ var SendTestCasesD3C = []ChannelSendTestCase{ 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"]}}`), + MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"], "language": "en_US"}}`), 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, }, { @@ -556,26 +574,15 @@ var SendTestCasesD3C = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +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]interface{}{ + 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"} - RunChannelSendTestCases(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"))) + RunOutgoingTestCases(t, ChannelWAC, newWAHandler(courier.ChannelType("D3C"), "360Dialog"), SendTestCasesD3C, checkRedacted, nil) } diff --git a/handlers/discord/discord.go b/handlers/discord/handler.go similarity index 90% rename from handlers/discord/discord.go rename to handlers/discord/handler.go index befde7c58..a7a6c0b35 100644 --- a/handlers/discord/discord.go +++ b/handlers/discord/handler.go @@ -103,7 +103,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w } // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // buildStatusHandler deals with building a handler that takes what status is received in the URL @@ -117,10 +117,10 @@ type statusForm struct { ID int64 `name:"id" validate:"required"` } -var statusMappings = map[string]courier.MsgStatusValue{ - "failed": courier.MsgFailed, - "sent": courier.MsgSent, - "delivered": courier.MsgDelivered, +var statusMappings = map[string]courier.MsgStatus{ + "failed": courier.MsgStatusFailed, + "sent": courier.MsgStatusSent, + "delivered": courier.MsgStatusDelivered, } // receiveStatus is our HTTP handler function for status updates @@ -138,12 +138,12 @@ func (h *handler) receiveStatus(ctx context.Context, statusString string, channe } // write our status - status := h.Backend().NewMsgStatusForID(channel, courier.MsgID(form.ID), msgStatus, clog) + status := h.Backend().NewStatusUpdate(channel, courier.MsgID(form.ID), msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { sendURL := msg.Channel().StringConfigForKey(courier.ConfigSendURL, "") if sendURL == "" { return nil, fmt.Errorf("no send url set for DS channel") @@ -154,7 +154,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // sendBody := msg.Channel().StringConfigForKey(courier.ConfigSendBody, "") contentTypeHeader := jsonMimeTypeType - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) attachmentURLs := []string{} for _, attachment := range msg.Attachments() { _, attachmentURL := handlers.SplitAttachment(attachment) @@ -197,13 +197,13 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Authorization", authorization) } - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } // If we don't have an error, set the message as wired and move on - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/discord/discord_test.go b/handlers/discord/handler_test.go similarity index 89% rename from handlers/discord/discord_test.go rename to handlers/discord/handler_test.go index 3ce02df0a..398be68e1 100644 --- a/handlers/discord/discord_test.go +++ b/handlers/discord/handler_test.go @@ -10,8 +10,8 @@ import ( "github.com/nyaruka/courier/utils" ) -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -19,10 +19,10 @@ func BenchmarkHandler(b *testing.B) { } var testChannels = []courier.Channel{ - test.NewMockChannel("bac782c2-7aeb-4389-92f5-97887744f573", "DS", "discord", "US", map[string]interface{}{courier.ConfigSendAuthorization: "sesame"}), + test.NewMockChannel("bac782c2-7aeb-4389-92f5-97887744f573", "DS", "discord", "US", map[string]any{courier.ConfigSendAuthorization: "sesame"}), } -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Recieve Message", URL: "/c/ds/bac782c2-7aeb-4389-92f5-97887744f573/receive", @@ -67,7 +67,7 @@ var testCases = []ChannelHandleTestCase{ Data: `id=12345`, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, - ExpectedMsgStatus: courier.MsgSent, + ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusSent}}, }, { Label: "Message Sent Handler Garbage", @@ -77,7 +77,7 @@ var testCases = []ChannelHandleTestCase{ }, } -var sendTestCases = []ChannelSendTestCase{ +var sendTestCases = []OutgoingTestCase{ { Label: "Simple Send", MsgText: "Hello World", @@ -111,13 +111,13 @@ var sendTestCases = []ChannelSendTestCase{ } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { // this is actually a path, which we'll combine with the test server URL sendURL := c.StringConfigForKey("send_path", "/discord/rp/send") sendURL, _ = utils.AddURLPath(s.URL, sendURL) c.(*test.MockChannel).SetConfig(courier.ConfigSendURL, sendURL) } -func TestSending(t *testing.T) { - RunChannelSendTestCases(t, testChannels[0], newHandler(), sendTestCases, []string{"sesame"}, nil) +func TestOutgoing(t *testing.T) { + RunOutgoingTestCases(t, testChannels[0], newHandler(), sendTestCases, []string{"sesame"}, nil) } diff --git a/handlers/dmark/dmark.go b/handlers/dmark/handler.go similarity index 86% rename from handlers/dmark/dmark.go rename to handlers/dmark/handler.go index aa15077b6..02b442047 100644 --- a/handlers/dmark/dmark.go +++ b/handlers/dmark/handler.go @@ -70,7 +70,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w msg := h.Backend().NewIncomingMsg(channel, urn, form.Text, "", clog).WithReceivedOn(date) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } type statusForm struct { @@ -78,12 +78,12 @@ type statusForm struct { Status string `validate:"required" name:"status"` } -var statusMapping = map[string]courier.MsgStatusValue{ - "1": courier.MsgDelivered, - "2": courier.MsgErrored, - "4": courier.MsgSent, - "8": courier.MsgSent, - "16": courier.MsgErrored, +var statusMapping = map[string]courier.MsgStatus{ + "1": courier.MsgStatusDelivered, + "2": courier.MsgStatusErrored, + "4": courier.MsgStatusSent, + "8": courier.MsgStatusSent, + "16": courier.MsgStatusErrored, } // receiveStatus is our HTTP handler function for status updates @@ -101,12 +101,12 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, form.ID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, form.ID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { // get our authentication token auth := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") if auth == "" { @@ -116,7 +116,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann callbackDomain := msg.Channel().CallbackDomain(h.Server().Config().Domain) dlrURL := fmt.Sprintf("https://%s%s%s/status?id=%s&status=%%s", callbackDomain, "/c/dk/", msg.Channel().UUID(), msg.ID().String()) - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) for i, part := range parts { form := url.Values{ @@ -134,7 +134,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Token %s", auth)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -152,7 +152,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } // this was wired successfully - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil diff --git a/handlers/dmark/dmark_test.go b/handlers/dmark/handler_test.go similarity index 90% rename from handlers/dmark/dmark_test.go rename to handlers/dmark/handler_test.go index 4ab893e66..3962ce45a 100644 --- a/handlers/dmark/dmark_test.go +++ b/handlers/dmark/handler_test.go @@ -19,7 +19,7 @@ const ( statusURL = "/c/dk/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status/" ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -78,12 +78,12 @@ var testCases = []ChannelHandleTestCase{ Data: "id=12345&status=1", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusDelivered}}, }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -91,11 +91,11 @@ func BenchmarkHandler(b *testing.B) { } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -133,11 +133,11 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "AT", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigAuthToken: "Authy", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Authy"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Authy"}, nil) } diff --git a/handlers/external/external.go b/handlers/external/handler.go similarity index 94% rename from handlers/external/external.go rename to handlers/external/handler.go index 0ec48d03c..99aecfd24 100644 --- a/handlers/external/external.go +++ b/handlers/external/handler.go @@ -111,7 +111,7 @@ func (h *handler) receiveStopContact(ctx context.Context, channel courier.Channe urn = urn.Normalize("") // create a stop channel event - channelEvent := h.Backend().NewChannelEvent(channel, courier.StopContact, urn, clog) + channelEvent := h.Backend().NewChannelEvent(channel, courier.EventTypeStopContact, urn, clog) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) if err != nil { return nil, err @@ -216,11 +216,11 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w msg := h.Backend().NewIncomingMsg(channel, urn, text, "", clog).WithReceivedOn(date) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // WriteMsgSuccessResponse writes our response in TWIML format -func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { moResponse := msgs[0].Channel().StringConfigForKey(configMOResponse, "") if moResponse == "" { return courier.WriteMsgSuccess(w, msgs) @@ -245,10 +245,10 @@ type statusForm struct { ID int64 `name:"id" validate:"required"` } -var statusMappings = map[string]courier.MsgStatusValue{ - "failed": courier.MsgFailed, - "sent": courier.MsgSent, - "delivered": courier.MsgDelivered, +var statusMappings = map[string]courier.MsgStatus{ + "failed": courier.MsgStatusFailed, + "sent": courier.MsgStatusSent, + "delivered": courier.MsgStatusDelivered, } // receiveStatus is our HTTP handler function for status updates @@ -266,12 +266,12 @@ func (h *handler) receiveStatus(ctx context.Context, statusString string, channe } // write our status - status := h.Backend().NewMsgStatusForID(channel, courier.MsgID(form.ID), msgStatus, clog) + status := h.Backend().NewStatusUpdate(channel, courier.MsgID(form.ID), msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { channel := msg.Channel() sendURL := channel.StringConfigForKey(courier.ConfigSendURL, "") @@ -291,7 +291,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann contentTypeHeader = contentType } - status := h.Backend().NewMsgStatusForID(channel, msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(channel, msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(channel, handlers.GetTextAndAttachments(msg), sendMaxLength) for i, part := range parts { // build our request @@ -359,18 +359,18 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Authorization", authorization) } - headers := channel.ConfigForKey(courier.ConfigSendHeaders, map[string]interface{}{}).(map[string]interface{}) + headers := channel.ConfigForKey(courier.ConfigSendHeaders, map[string]any{}).(map[string]any) for hKey, hValue := range headers { req.Header.Set(hKey, fmt.Sprint(hValue)) } - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } if responseCheck == "" || strings.Contains(string(respBody), responseCheck) { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } else { clog.Error(courier.ErrorResponseUnexpected(responseCheck)) } diff --git a/handlers/external/external_test.go b/handlers/external/handler_test.go similarity index 88% rename from handlers/external/external_test.go rename to handlers/external/handler_test.go index 12146607c..2684e30fa 100644 --- a/handlers/external/external_test.go +++ b/handlers/external/handler_test.go @@ -24,7 +24,7 @@ var gmChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "GM", nil), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL + "?sender=%2B2349067554729&text=Join", @@ -128,20 +128,21 @@ var handleTestCases = []ChannelHandleTestCase{ URL: "/c/ex/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/failed/?id=12345", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, + ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusFailed}}, }, { Label: "Invalid Status", URL: "/c/ex/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/wired/", ExpectedRespStatus: 404, ExpectedBodyContains: `page not found`, + NoLogsExpected: true, }, { Label: "Sent Valid", URL: "/c/ex/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/sent/?id=12345", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, - ExpectedMsgStatus: courier.MsgSent, + ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusSent}}, }, { Label: "Delivered Valid", @@ -149,7 +150,7 @@ var handleTestCases = []ChannelHandleTestCase{ Data: "nothing", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, + ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusDelivered}}, }, { Label: "Delivered Valid Post", @@ -157,7 +158,7 @@ var handleTestCases = []ChannelHandleTestCase{ Data: "id=12345", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, + ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusDelivered}}, }, { Label: "Stopped Event", @@ -165,8 +166,9 @@ var handleTestCases = []ChannelHandleTestCase{ Data: "nothing", ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", - ExpectedEvent: "stop_contact", - ExpectedURN: "tel:+2349067554729", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeStopContact, URN: "tel:+2349067554729"}, + }, }, { Label: "Stopped Event Post", @@ -174,8 +176,9 @@ var handleTestCases = []ChannelHandleTestCase{ Data: "from=%2B2349067554729", ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", - ExpectedEvent: "stop_contact", - ExpectedURN: "tel:+2349067554729", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeStopContact, URN: "tel:+2349067554729"}, + }, }, { Label: "Stopped Event Invalid URN", @@ -194,7 +197,7 @@ var handleTestCases = []ChannelHandleTestCase{ var testSOAPReceiveChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ configTextXPath: "//content", configFromXPath: "//source", configMOResponse: "0", @@ -203,7 +206,7 @@ var testSOAPReceiveChannels = []courier.Channel{ ), } -var handleSOAPReceiveTestCases = []ChannelHandleTestCase{ +var handleSOAPReceiveTestCases = []IncomingTestCase{ { Label: "Receive Valid Post SOAP", URL: "/c/ex/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/", @@ -222,7 +225,7 @@ var handleSOAPReceiveTestCases = []ChannelHandleTestCase{ }, } -var gmTestCases = []ChannelHandleTestCase{ +var gmTestCases = []IncomingTestCase{ { Label: "Receive Non Plus Message", URL: "/c/ex/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?sender=2207222333&text=Join", @@ -236,7 +239,7 @@ var gmTestCases = []ChannelHandleTestCase{ var customChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ configMOFromField: "from_number", configMODateField: "timestamp", configMOTextField: "messageText", @@ -244,7 +247,7 @@ var customChannels = []courier.Channel{ ), } -var customTestCases = []ChannelHandleTestCase{ +var customTestCases = []IncomingTestCase{ { Label: "Receive Custom Message", URL: "/c/ex/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?from_number=12067799192&messageText=Join×tamp=2017-06-23T12:30:00Z", @@ -264,11 +267,11 @@ var customTestCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) - RunChannelTestCases(t, testSOAPReceiveChannels, newHandler(), handleSOAPReceiveTestCases) - RunChannelTestCases(t, gmChannels, newHandler(), gmTestCases) - RunChannelTestCases(t, customChannels, newHandler(), customTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) + RunIncomingTestCases(t, testSOAPReceiveChannels, newHandler(), handleSOAPReceiveTestCases) + RunIncomingTestCases(t, gmChannels, newHandler(), gmTestCases) + RunIncomingTestCases(t, customChannels, newHandler(), customTestCases) } func BenchmarkHandler(b *testing.B) { @@ -277,14 +280,14 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { // this is actually a path, which we'll combine with the test server URL sendURL := c.StringConfigForKey("send_path", "") sendURL, _ = utils.AddURLPath(s.URL, sendURL) c.(*test.MockChannel).SetConfig(courier.ConfigSendURL, sendURL) } -var longSendTestCases = []ChannelSendTestCase{ +var longSendTestCases = []OutgoingTestCase{ { Label: "Long Send", MsgText: "This is a long message that will be longer than 30....... characters", MsgURN: "tel:+250788383383", @@ -297,7 +300,7 @@ var longSendTestCases = []ChannelSendTestCase{ }, } -var getSendSmartEncodingTestCases = []ChannelSendTestCase{ +var getSendSmartEncodingTestCases = []OutgoingTestCase{ { Label: "Smart Encoding", MsgText: "Fancy “Smart” Quotes", @@ -311,7 +314,7 @@ var getSendSmartEncodingTestCases = []ChannelSendTestCase{ }, } -var postSendSmartEncodingTestCases = []ChannelSendTestCase{ +var postSendSmartEncodingTestCases = []OutgoingTestCase{ { Label: "Smart Encoding", MsgText: "Fancy “Smart” Quotes", @@ -325,7 +328,7 @@ var postSendSmartEncodingTestCases = []ChannelSendTestCase{ }, } -var getSendTestCases = []ChannelSendTestCase{ +var getSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -372,7 +375,7 @@ var getSendTestCases = []ChannelSendTestCase{ }, } -var postSendTestCases = []ChannelSendTestCase{ +var postSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -419,7 +422,7 @@ var postSendTestCases = []ChannelSendTestCase{ }, } -var postSendCustomContentTypeTestCases = []ChannelSendTestCase{ +var postSendCustomContentTypeTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -433,7 +436,7 @@ var postSendCustomContentTypeTestCases = []ChannelSendTestCase{ }, } -var jsonSendTestCases = []ChannelSendTestCase{ +var jsonSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -492,7 +495,7 @@ var jsonSendTestCases = []ChannelSendTestCase{ }, } -var jsonLongSendTestCases = []ChannelSendTestCase{ +var jsonLongSendTestCases = []OutgoingTestCase{ { Label: "Send Quick Replies", MsgText: "This is a long message that will be longer than 30....... characters", @@ -507,7 +510,7 @@ var jsonLongSendTestCases = []ChannelSendTestCase{ }, } -var xmlSendTestCases = []ChannelSendTestCase{ +var xmlSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -567,7 +570,7 @@ var xmlSendTestCases = []ChannelSendTestCase{ }, } -var xmlLongSendTestCases = []ChannelSendTestCase{ +var xmlLongSendTestCases = []OutgoingTestCase{ { Label: "Send Quick Replies", MsgText: "This is a long message that will be longer than 30....... characters", @@ -582,7 +585,7 @@ var xmlLongSendTestCases = []ChannelSendTestCase{ }, } -var xmlSendWithResponseContentTestCases = []ChannelSendTestCase{ +var xmlSendWithResponseContentTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -654,7 +657,7 @@ var xmlSendWithResponseContentTestCases = []ChannelSendTestCase{ }, } -var nationalGetSendTestCases = []ChannelSendTestCase{ +var nationalGetSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -668,49 +671,49 @@ var nationalGetSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var getChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "?to={{to}}&text={{text}}&from={{from}}{{quick_replies}}", courier.ConfigSendMethod: http.MethodGet}) var getSmartChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "?to={{to}}&text={{text}}&from={{from}}{{quick_replies}}", configEncoding: encodingSmart, courier.ConfigSendMethod: http.MethodGet}) var postChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "", courier.ConfigSendBody: "to={{to}}&text={{text}}&from={{from}}{{quick_replies}}", courier.ConfigSendMethod: http.MethodPost}) var postChannelCustomContentType = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "", courier.ConfigSendBody: "to={{to_no_plus}}&text={{text}}&from={{from_no_plus}}{{quick_replies}}", courier.ConfigContentType: "application/x-www-form-urlencoded; charset=utf-8", courier.ConfigSendMethod: http.MethodPost}) var postSmartChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "", courier.ConfigSendBody: "to={{to}}&text={{text}}&from={{from}}{{quick_replies}}", configEncoding: encodingSmart, courier.ConfigSendMethod: http.MethodPost}) var jsonChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "", courier.ConfigSendBody: `{ "to":{{to}}, "text":{{text}}, "from":{{from}}, "quick_replies":{{quick_replies}} }`, courier.ConfigContentType: contentJSON, courier.ConfigSendMethod: http.MethodPost, - courier.ConfigSendHeaders: map[string]interface{}{"Authorization": "Token ABCDEF", "foo": "bar"}, + courier.ConfigSendHeaders: map[string]any{"Authorization": "Token ABCDEF", "foo": "bar"}, }) var xmlChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "", courier.ConfigSendBody: `{{to}}{{text}}{{from}}{{quick_replies}}`, courier.ConfigContentType: contentXML, @@ -718,7 +721,7 @@ func TestSending(t *testing.T) { }) var xmlChannelWithResponseContent = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "", courier.ConfigSendBody: `{{to}}{{text}}{{from}}{{quick_replies}}`, configMTResponseCheck: "0", @@ -726,70 +729,70 @@ func TestSending(t *testing.T) { courier.ConfigSendMethod: http.MethodPut, }) - RunChannelSendTestCases(t, getChannel, newHandler(), getSendTestCases, nil, nil) - RunChannelSendTestCases(t, getSmartChannel, newHandler(), getSendTestCases, nil, nil) - RunChannelSendTestCases(t, getSmartChannel, newHandler(), getSendSmartEncodingTestCases, nil, nil) - RunChannelSendTestCases(t, postChannel, newHandler(), postSendTestCases, nil, nil) - RunChannelSendTestCases(t, postChannelCustomContentType, newHandler(), postSendCustomContentTypeTestCases, nil, nil) - RunChannelSendTestCases(t, postSmartChannel, newHandler(), postSendTestCases, nil, nil) - RunChannelSendTestCases(t, postSmartChannel, newHandler(), postSendSmartEncodingTestCases, nil, nil) - RunChannelSendTestCases(t, jsonChannel, newHandler(), jsonSendTestCases, nil, nil) - RunChannelSendTestCases(t, xmlChannel, newHandler(), xmlSendTestCases, nil, nil) - RunChannelSendTestCases(t, xmlChannelWithResponseContent, newHandler(), xmlSendWithResponseContentTestCases, nil, nil) + RunOutgoingTestCases(t, getChannel, newHandler(), getSendTestCases, nil, nil) + RunOutgoingTestCases(t, getSmartChannel, newHandler(), getSendTestCases, nil, nil) + RunOutgoingTestCases(t, getSmartChannel, newHandler(), getSendSmartEncodingTestCases, nil, nil) + RunOutgoingTestCases(t, postChannel, newHandler(), postSendTestCases, nil, nil) + RunOutgoingTestCases(t, postChannelCustomContentType, newHandler(), postSendCustomContentTypeTestCases, nil, nil) + RunOutgoingTestCases(t, postSmartChannel, newHandler(), postSendTestCases, nil, nil) + RunOutgoingTestCases(t, postSmartChannel, newHandler(), postSendSmartEncodingTestCases, nil, nil) + RunOutgoingTestCases(t, jsonChannel, newHandler(), jsonSendTestCases, nil, nil) + RunOutgoingTestCases(t, xmlChannel, newHandler(), xmlSendTestCases, nil, nil) + RunOutgoingTestCases(t, xmlChannelWithResponseContent, newHandler(), xmlSendWithResponseContentTestCases, nil, nil) var getChannel30IntLength = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "max_length": 30, "send_path": "?to={{to}}&text={{text}}&from={{from}}{{quick_replies}}", courier.ConfigSendMethod: http.MethodGet}) var getChannel30StrLength = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "max_length": "30", "send_path": "?to={{to}}&text={{text}}&from={{from}}{{quick_replies}}", courier.ConfigSendMethod: http.MethodGet}) var jsonChannel30IntLength = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "", "max_length": 30, courier.ConfigSendBody: `{ "to":{{to}}, "text":{{text}}, "from":{{from}}, "quick_replies":{{quick_replies}} }`, courier.ConfigContentType: contentJSON, courier.ConfigSendMethod: http.MethodPost, - courier.ConfigSendHeaders: map[string]interface{}{"Authorization": "Token ABCDEF", "foo": "bar"}, + courier.ConfigSendHeaders: map[string]any{"Authorization": "Token ABCDEF", "foo": "bar"}, }) var xmlChannel30IntLength = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "", "max_length": 30, courier.ConfigSendBody: `{{to}}{{text}}{{from}}{{quick_replies}}`, courier.ConfigContentType: contentXML, courier.ConfigSendMethod: http.MethodPost, - courier.ConfigSendHeaders: map[string]interface{}{"Authorization": "Token ABCDEF", "foo": "bar"}, + courier.ConfigSendHeaders: map[string]any{"Authorization": "Token ABCDEF", "foo": "bar"}, }) - RunChannelSendTestCases(t, getChannel30IntLength, newHandler(), longSendTestCases, nil, nil) - RunChannelSendTestCases(t, getChannel30StrLength, newHandler(), longSendTestCases, nil, nil) - RunChannelSendTestCases(t, jsonChannel30IntLength, newHandler(), jsonLongSendTestCases, nil, nil) - RunChannelSendTestCases(t, xmlChannel30IntLength, newHandler(), xmlLongSendTestCases, nil, nil) + RunOutgoingTestCases(t, getChannel30IntLength, newHandler(), longSendTestCases, nil, nil) + RunOutgoingTestCases(t, getChannel30StrLength, newHandler(), longSendTestCases, nil, nil) + RunOutgoingTestCases(t, jsonChannel30IntLength, newHandler(), jsonLongSendTestCases, nil, nil) + RunOutgoingTestCases(t, xmlChannel30IntLength, newHandler(), xmlLongSendTestCases, nil, nil) var nationalChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "?to={{to}}&text={{text}}&from={{from}}{{quick_replies}}", "use_national": true, courier.ConfigSendMethod: http.MethodGet}) - RunChannelSendTestCases(t, nationalChannel, newHandler(), nationalGetSendTestCases, nil, nil) + RunOutgoingTestCases(t, nationalChannel, newHandler(), nationalGetSendTestCases, nil, nil) var jsonChannelWithSendAuthorization = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "EX", "2020", "US", - map[string]interface{}{ + map[string]any{ "send_path": "", courier.ConfigSendBody: `{ "to":{{to}}, "text":{{text}}, "from":{{from}}, "quick_replies":{{quick_replies}} }`, courier.ConfigContentType: contentJSON, courier.ConfigSendMethod: http.MethodPost, courier.ConfigSendAuthorization: "Token ABCDEF", }) - RunChannelSendTestCases(t, jsonChannelWithSendAuthorization, newHandler(), jsonSendTestCases, []string{"Token ABCDEF"}, nil) + RunOutgoingTestCases(t, jsonChannelWithSendAuthorization, newHandler(), jsonSendTestCases, []string{"Token ABCDEF"}, nil) } diff --git a/handlers/facebook/facebook.go b/handlers/facebook_legacy/handler.go similarity index 91% rename from handlers/facebook/facebook.go rename to handlers/facebook_legacy/handler.go index 09bf1adc3..45683f28b 100644 --- a/handlers/facebook/facebook.go +++ b/handlers/facebook_legacy/handler.go @@ -1,10 +1,10 @@ -package facebook +package facebook_legacy import ( "bytes" "context" - "encoding/json" "fmt" + "log/slog" "net/http" "net/url" "strings" @@ -14,9 +14,9 @@ import ( "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" "github.com/nyaruka/courier/utils" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" "github.com/pkg/errors" - "github.com/sirupsen/logrus" ) // Endpoints we hit @@ -119,12 +119,12 @@ func (h *handler) subscribeToEvents(ctx context.Context, channel courier.Channel req, _ := http.NewRequest(http.MethodPost, subscribeURL, strings.NewReader(form.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) // log if we get any kind of error success, _ := jsonparser.GetBoolean(respBody, "success") if err != nil || resp.StatusCode/100 != 2 || !success { - logrus.WithField("channel_uuid", channel.UUID()).Error("error subscribing to Facebook page events") + slog.Error("error subscribing to Facebook page events", "channel_uuid", channel.UUID()) } h.Backend().WriteChannelLog(ctx, clog) @@ -229,7 +229,7 @@ func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w events := make([]courier.Event, 0, 2) // the list of data we will return in our response - data := make([]interface{}, 0, 2) + data := make([]any, 0, 2) seenMsgIDs := make(map[string]bool, 2) @@ -270,12 +270,10 @@ func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w } } - event := h.Backend().NewChannelEvent(channel, courier.Referral, urn, clog).WithOccurredOn(date) + event := h.Backend().NewChannelEvent(channel, courier.EventTypeReferral, urn, clog).WithOccurredOn(date) // build our extra - extra := map[string]interface{}{ - referrerIDKey: msg.OptIn.Ref, - } + extra := map[string]string{referrerIDKey: msg.OptIn.Ref} event = event.WithExtra(extra) err := h.Backend().WriteChannelEvent(ctx, event, clog) @@ -288,20 +286,20 @@ func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w } else if msg.Postback != nil { // by default postbacks are treated as new conversations, unless we have referral information - eventType := courier.NewConversation + eventType := courier.EventTypeNewConversation if msg.Postback.Referral.Ref != "" { - eventType = courier.Referral + eventType = courier.EventTypeReferral } event := h.Backend().NewChannelEvent(channel, eventType, urn, clog).WithOccurredOn(date) // build our extra - extra := map[string]interface{}{ + extra := map[string]string{ titleKey: msg.Postback.Title, payloadKey: msg.Postback.Payload, } // add in referral information if we have it - if eventType == courier.Referral { + if eventType == courier.EventTypeReferral { extra[referrerIDKey] = msg.Postback.Referral.Ref extra[sourceKey] = msg.Postback.Referral.Source extra[typeKey] = msg.Postback.Referral.Type @@ -323,13 +321,10 @@ func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w } else if msg.Referral != nil { // this is an incoming referral - event := h.Backend().NewChannelEvent(channel, courier.Referral, urn, clog).WithOccurredOn(date) + event := h.Backend().NewChannelEvent(channel, courier.EventTypeReferral, urn, clog).WithOccurredOn(date) // build our extra - extra := map[string]interface{}{ - sourceKey: msg.Referral.Source, - typeKey: msg.Referral.Type, - } + extra := map[string]string{sourceKey: msg.Referral.Source, typeKey: msg.Referral.Type} // add referrer id if present if msg.Referral.Ref != "" { @@ -409,15 +404,8 @@ func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w } else if msg.Delivery != nil { // this is a delivery report for _, mid := range msg.Delivery.MIDs { - event := h.Backend().NewMsgStatusForExternalID(channel, mid, courier.MsgDelivered, clog) - err := h.Backend().WriteMsgStatus(ctx, event) - - // we don't know about this message, just tell them we ignored it - if err == courier.ErrMsgNotFound { - data = append(data, courier.NewInfoData("message not found, ignored")) - continue - } - + event := h.Backend().NewStatusUpdateByExternalID(channel, mid, courier.MsgStatusDelivered, clog) + err := h.Backend().WriteStatusUpdate(ctx, event) if err != nil { return nil, err } @@ -478,7 +466,7 @@ type mtQuickReply struct { ContentType string `json:"content_type"` } -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { // can't do anything without an access token accessToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") if accessToken == "" { @@ -510,7 +498,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann query.Set("access_token", accessToken) msgURL.RawQuery = query.Encode() - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) msgParts := make([]string, 0) if msg.Text() != "" { @@ -547,10 +535,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann payload.Message.QuickReplies = nil } - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, err - } + jsonBody := jsonx.MustMarshal(payload) req, err := http.NewRequest(http.MethodPost, msgURL.String(), bytes.NewReader(jsonBody)) if err != nil { @@ -560,7 +545,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -588,11 +573,11 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann clog.RawError(errors.Errorf("unable to make facebook urn from %s", recipientID)) } - contact, err := h.Backend().GetContact(ctx, msg.Channel(), msg.URN(), "", "", clog) + contact, err := h.Backend().GetContact(ctx, msg.Channel(), msg.URN(), nil, "", 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) + realURN, err := h.Backend().AddURNtoContact(ctx, msg.Channel(), contact, realIDURN, nil) if err != nil { clog.RawError(errors.Errorf("unable to add real facebook URN %s to contact with uuid %s", realURN.String(), contact.UUID())) } @@ -600,7 +585,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann 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) + extURN, err := h.Backend().AddURNtoContact(ctx, msg.Channel(), contact, referralIDExtURN, nil) if err != nil { clog.RawError(errors.Errorf("unable to add URN %s to contact with uuid %s", extURN.String(), contact.UUID())) } @@ -613,7 +598,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } // this was wired successfully - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil @@ -642,7 +627,7 @@ func (h *handler) DescribeURN(ctx context.Context, channel courier.Channel, urn u.RawQuery = query.Encode() req, _ := http.NewRequest(http.MethodGet, u.String(), nil) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return nil, errors.New("unable to look up contact data") } diff --git a/handlers/facebook/facebook_test.go b/handlers/facebook_legacy/handler_test.go similarity index 88% rename from handlers/facebook/facebook_test.go rename to handlers/facebook_legacy/handler_test.go index 62e7b6195..58c7cfe31 100644 --- a/handlers/facebook/facebook_test.go +++ b/handlers/facebook_legacy/handler_test.go @@ -1,4 +1,4 @@ -package facebook +package facebook_legacy import ( "context" @@ -18,7 +18,7 @@ import ( var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "FB", "1234", "", - map[string]interface{}{courier.ConfigAuthToken: "a123", courier.ConfigSecret: "mysecret"}), + map[string]any{courier.ConfigAuthToken: "a123", courier.ConfigSecret: "mysecret"}), } const ( @@ -417,7 +417,7 @@ var unkownMessagingEntry = `{ }] }` -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Message", URL: receiveURL, @@ -481,10 +481,9 @@ var testCases = []ChannelHandleTestCase{ Data: optInUserRef, 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]interface{}{"referrer_id": "optin_ref"}, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:ref:optin_user_ref", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"referrer_id": "optin_ref"}}, + }, }, { Label: "Receive OptIn", @@ -492,10 +491,9 @@ var testCases = []ChannelHandleTestCase{ Data: optIn, 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]interface{}{"referrer_id": "optin_ref"}, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"referrer_id": "optin_ref"}}, + }, }, { Label: "Receive Get Started", @@ -503,10 +501,9 @@ var testCases = []ChannelHandleTestCase{ Data: postbackGetStarted, 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]interface{}{"title": "postback title", "payload": "get_started"}, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeNewConversation, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"title": "postback title", "payload": "get_started"}}, + }, }, { Label: "Receive Referral Postback", @@ -514,10 +511,9 @@ var testCases = []ChannelHandleTestCase{ Data: postback, 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]interface{}{"title": "postback title", "payload": "postback payload", "referrer_id": "postback ref", "source": "postback source", "type": "postback type"}, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"title": "postback title", "payload": "postback payload", "referrer_id": "postback ref", "source": "postback source", "type": "postback type"}}, + }, }, { Label: "Receive Referral", @@ -525,10 +521,9 @@ var testCases = []ChannelHandleTestCase{ Data: postbackReferral, 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]interface{}{"title": "postback title", "payload": "get_started", "referrer_id": "postback ref", "source": "postback source", "type": "postback type", "ad_id": "ad id"}, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"title": "postback title", "payload": "get_started", "referrer_id": "postback ref", "source": "postback source", "type": "postback type", "ad_id": "ad id"}}, + }, }, { Label: "Receive Referral", @@ -536,10 +531,9 @@ var testCases = []ChannelHandleTestCase{ Data: referral, 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]interface{}{"referrer_id": "referral id", "source": "referral source", "type": "referral type", "ad_id": "ad id"}, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"referrer_id": "referral id", "source": "referral source", "type": "referral type", "ad_id": "ad id"}}, + }, }, { Label: "Receive DLR", @@ -547,8 +541,7 @@ var testCases = []ChannelHandleTestCase{ Data: dlr, ExpectedRespStatus: 200, ExpectedBodyContains: "Handled", - ExpectedMsgStatus: courier.MsgDelivered, - ExpectedExternalID: "mid.1458668856218:ed81099e15d3f4f233", + ExpectedStatuses: []ExpectedStatus{{ExternalID: "mid.1458668856218:ed81099e15d3f4f233", Status: courier.MsgStatusDelivered}}, }, { Label: "Different Page", @@ -609,7 +602,7 @@ var testCases = []ChannelHandleTestCase{ } // mocks the call to the Facebook graph API -func buildMockFBGraph(testCases []ChannelHandleTestCase) *httptest.Server { +func buildMockFBGraph(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() @@ -639,6 +632,7 @@ func TestDescribeURN(t *testing.T) { channel := testChannels[0] handler := newHandler() + handler.Initialize(test.NewMockServer(courier.NewConfig(), test.NewMockBackend())) clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, channel, handler.RedactValues(channel)) tcs := []struct { @@ -658,8 +652,8 @@ func TestDescribeURN(t *testing.T) { AssertChannelLogRedaction(t, clog, []string{"a123", "mysecret"}) } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -691,7 +685,7 @@ func TestVerify(t *testing.T) { subscribeURL = server.URL subscribeTimeout = time.Millisecond - RunChannelTestCases(t, testChannels, newHandler(), []ChannelHandleTestCase{ + RunIncomingTestCases(t, testChannels, newHandler(), []IncomingTestCase{ { Label: "Receive Message", URL: receiveURL, @@ -735,11 +729,11 @@ func TestVerify(t *testing.T) { } // setSendURL takes care of setting the send_url to our test server host -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -860,7 +854,7 @@ var defaultSendTestCases = []ChannelSendTestCase{ func TestSending(t *testing.T) { // shorter max msg length for testing maxMsgLength = 100 - var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FB", "2020", "US", map[string]interface{}{courier.ConfigAuthToken: "access_token"}) + var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FB", "2020", "US", map[string]any{courier.ConfigAuthToken: "access_token"}) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"access_token"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"access_token"}, nil) } diff --git a/handlers/facebookapp/facebookapp_test.go b/handlers/facebookapp/facebookapp_test.go deleted file mode 100644 index e40e9bc36..000000000 --- a/handlers/facebookapp/facebookapp_test.go +++ /dev/null @@ -1,1607 +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/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]interface{}{courier.ConfigAuthToken: "a123"}), -} - -var testChannelsIG = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "IG", "12345", "", map[string]interface{}{courier.ConfigAuthToken: "a123"}), -} - -var testChannelsWAC = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "WAC", "12345", "", map[string]interface{}{courier.ConfigAuthToken: "a123"}), -} - -var testCasesFBA = []ChannelHandleTestCase{ - { - 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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{"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.MsgDelivered, - 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", - PrepRequest: addValidSignature, - }, - { - Label: "No Entries", - URL: "/c/fba/receive", - Data: string(test.ReadFile("./testdata/fba/noEntriesFBA.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "no entries found", - 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", - 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 = []ChannelHandleTestCase{ - { - 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]interface{}{"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", - 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", - 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", - 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 addValidSignatureWAC(r *http.Request) { - body, _ := handlers.ReadBody(r, 100000) - sig, _ := fbCalculateSignature("wac_app_secret", body) - r.Header.Set(signatureHeader, fmt.Sprintf("sha1=%s", string(sig))) -} - -func addInvalidSignature(r *http.Request) { - r.Header.Set(signatureHeader, "invalidsig") -} - -// mocks the call to the Facebook graph API -func buildMockFBGraphFBA(testCases []ChannelHandleTestCase) *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 []ChannelHandleTestCase) *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 = []ChannelHandleTestCase{ - { - 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 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", - PrepRequest: addValidSignature, - }, - { - Label: "Receive Invalid JSON", - URL: wacReceiveURL, - Data: string(test.ReadFile("./testdata/wac/invalidFrom.json")), - ExpectedRespStatus: 200, - ExpectedBodyContains: "invalid whatsapp id", - PrepRequest: addValidSignature, - }, - { - Label: "Receive Invalid JSON", - 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 TestHandler(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - accessToken := r.Header.Get("Authorization") - defer r.Body.Close() - - // invalid auth token - if accessToken != "Bearer a123" && accessToken != "Bearer wac_admin_system_user_token" { - fmt.Printf("Access token: %s\n", accessToken) - http.Error(w, "invalid auth token", 403) - return - } - - if strings.HasSuffix(r.URL.Path, "image") { - w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Image"}`)) - return - } - - if strings.HasSuffix(r.URL.Path, "audio") { - w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Audio"}`)) - return - } - - if strings.HasSuffix(r.URL.Path, "voice") { - w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Voice"}`)) - return - } - - if strings.HasSuffix(r.URL.Path, "video") { - w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Video"}`)) - return - } - - if strings.HasSuffix(r.URL.Path, "document") { - w.Write([]byte(`{"url": "https://foo.bar/attachmentURL_Document"}`)) - return - } - - // valid token - w.Write([]byte(`{"url": "https://foo.bar/attachmentURL"}`)) - - })) - graphURL = server.URL - - RunChannelTestCases(t, testChannelsWAC, newHandler("WAC", "Cloud API WhatsApp", false), testCasesWAC) - RunChannelTestCases(t, testChannelsFBA, newHandler("FBA", "Facebook", false), testCasesFBA) - RunChannelTestCases(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) { - RunChannelTestCases(t, testChannelsFBA, newHandler("FBA", "Facebook", false), []ChannelHandleTestCase{ - { - Label: "Valid Secret", - URL: "/c/fba/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", - ExpectedRespStatus: 200, - ExpectedBodyContains: "yarchallenge", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - }, - { - Label: "Verify No Mode", - URL: "/c/fba/receive", - ExpectedRespStatus: 200, - ExpectedBodyContains: "unknown request", - }, - { - Label: "Verify No Secret", - URL: "/c/fba/receive?hub.mode=subscribe", - ExpectedRespStatus: 200, - ExpectedBodyContains: "token does not match secret", - }, - { - Label: "Invalid Secret", - URL: "/c/fba/receive?hub.mode=subscribe&hub.verify_token=blah", - ExpectedRespStatus: 200, - ExpectedBodyContains: "token does not match secret", - }, - { - Label: "Valid Secret", - URL: "/c/fba/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", - ExpectedRespStatus: 200, - ExpectedBodyContains: "yarchallenge", - }, - }) - - RunChannelTestCases(t, testChannelsIG, newHandler("IG", "Instagram", false), []ChannelHandleTestCase{ - { - Label: "Valid Secret", - URL: "/c/ig/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", - ExpectedRespStatus: 200, - ExpectedBodyContains: "yarchallenge", - NoQueueErrorCheck: true, - NoInvalidChannelCheck: true, - }, - { - Label: "Verify No Mode", - URL: "/c/ig/receive", - ExpectedRespStatus: 200, - ExpectedBodyContains: "unknown request", - }, - { - Label: "Verify No Secret", - URL: "/c/ig/receive?hub.mode=subscribe", - ExpectedRespStatus: 200, - ExpectedBodyContains: "token does not match secret", - }, - { - Label: "Invalid Secret", - URL: "/c/ig/receive?hub.mode=subscribe&hub.verify_token=blah", - ExpectedRespStatus: 200, - ExpectedBodyContains: "token does not match secret", - }, - { - Label: "Valid Secret", - URL: "/c/ig/receive?hub.mode=subscribe&hub.verify_token=fb_webhook_secret&hub.challenge=yarchallenge", - ExpectedRespStatus: 200, - ExpectedBodyContains: "yarchallenge", - }, - }) -} - -// setSendURL takes care of setting the send_url to our test server host -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { - sendURL = s.URL - graphURL = s.URL -} - -var SendTestCasesFBA = []ChannelSendTestCase{ - { - 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 = []ChannelSendTestCase{ - { - 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 = []ChannelSendTestCase{ - { - 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: "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","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","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","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 TestSending(t *testing.T) { - // shorter max msg length for testing - maxMsgLengthFBA = 100 - maxMsgLengthIG = 100 - maxMsgLengthWAC = 100 - - var ChannelFBA = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FBA", "12345", "", map[string]interface{}{courier.ConfigAuthToken: "a123"}) - var ChannelIG = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "IG", "12345", "", map[string]interface{}{courier.ConfigAuthToken: "a123"}) - var ChannelWAC = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "WAC", "12345_ID", "", map[string]interface{}{courier.ConfigAuthToken: "a123"}) - - checkRedacted := []string{"wac_admin_system_user_token", "missing_facebook_app_secret", "missing_facebook_webhook_secret", "a123"} - - RunChannelSendTestCases(t, ChannelFBA, newHandler("FBA", "Facebook", false), SendTestCasesFBA, checkRedacted, nil) - RunChannelSendTestCases(t, ChannelIG, newHandler("IG", "Instagram", false), SendTestCasesIG, checkRedacted, nil) - RunChannelSendTestCases(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/audioWAC.json b/handlers/facebookapp/testdata/wac/audioWAC.json deleted file mode 100644 index f578e5fc9..000000000 --- a/handlers/facebookapp/testdata/wac/audioWAC.json +++ /dev/null @@ -1,43 +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", - "audio": { - "file": "/usr/local/wamedia/shared/b1cf38-8734-4ad3-b4a1-ef0c10d0d683", - "id": "id_audio", - "mime_type": "image/jpeg", - "sha256": "29ed500fa64eb55fc19dc4124acb300e5dcc54a0f822a301ae99944db", - "caption": "Check out my new phone!" - }, - "timestamp": "1454119029", - "type": "audio" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/buttonReplyWAC.json b/handlers/facebookapp/testdata/wac/buttonReplyWAC.json deleted file mode 100644 index e44859b0d..000000000 --- a/handlers/facebookapp/testdata/wac/buttonReplyWAC.json +++ /dev/null @@ -1,43 +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", - "interactive": { - "type": "button_reply", - "button_reply": { - "id": "id_button_reply", - "title": "Yes" - } - }, - "timestamp": "1454119029", - "type": "interactive" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/buttonWAC.json b/handlers/facebookapp/testdata/wac/buttonWAC.json deleted file mode 100644 index 10f592773..000000000 --- a/handlers/facebookapp/testdata/wac/buttonWAC.json +++ /dev/null @@ -1,44 +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": [ - { - "button": { - "payload": "No-Button-Payload", - "text": "No" - }, - "context": { - "from": "5678", - "id": "gBGGFmkiWVVPAgkgQkwi7IORac0" - }, - "from": "5678", - "id": "external_id", - "timestamp": "1454119029", - "type": "button" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/documentWAC.json b/handlers/facebookapp/testdata/wac/documentWAC.json deleted file mode 100644 index 1c5f08eab..000000000 --- a/handlers/facebookapp/testdata/wac/documentWAC.json +++ /dev/null @@ -1,43 +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", - "timestamp": "1454119029", - "type": "document", - "document": { - "caption": "80skaraokesonglistartist", - "file": "/usr/local/wamedia/shared/fc233119-733f-49c-bcbd-b2f68f798e33", - "id": "id_document", - "mime_type": "application/pdf", - "sha256": "3b11fa6ef2bde1dd14726e09d3edaf782120919d06f6484f32d5d5caa4b8e" - } - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/duplicateWAC.json b/handlers/facebookapp/testdata/wac/duplicateWAC.json deleted file mode 100644 index 69463fb0f..000000000 --- a/handlers/facebookapp/testdata/wac/duplicateWAC.json +++ /dev/null @@ -1,48 +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", - "timestamp": "1454119029", - "text": { - "body": "Hello World" - }, - "type": "text" - }, - { - "from": "5678", - "id": "external_id", - "timestamp": "1454119029", - "text": { - "body": "Hello World" - }, - "type": "text" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/errorErrors.json b/handlers/facebookapp/testdata/wac/errorErrors.json deleted file mode 100644 index 42e86216f..000000000 --- a/handlers/facebookapp/testdata/wac/errorErrors.json +++ /dev/null @@ -1,34 +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" - } - ], - "errors": [ - { - "code": 0, - "title": "We were unable to authenticate the app user" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/errorMsg.json b/handlers/facebookapp/testdata/wac/errorMsg.json deleted file mode 100644 index 099dc62aa..000000000 --- a/handlers/facebookapp/testdata/wac/errorMsg.json +++ /dev/null @@ -1,46 +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", - "timestamp": "1454119029", - "text": { - "body": "Hello World" - }, - "type": "unsupported", - "errors": [ - { - "code": 131051, - "details": "Message type is not currently supported", - "title": "Unsupported message type" - } - ] - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/errorStatus.json b/handlers/facebookapp/testdata/wac/errorStatus.json deleted file mode 100644 index 95f03649d..000000000 --- a/handlers/facebookapp/testdata/wac/errorStatus.json +++ /dev/null @@ -1,55 +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" - } - ], - "statuses": [ - { - "id": "external_id", - "recipient_id": "5678", - "status": "failed", - "timestamp": "1454119029", - "type": "message", - "conversation": { - "id": "CONVERSATION_ID", - "expiration_timestamp": 1454119029, - "origin": { - "type": "referral_conversion" - } - }, - "pricing": { - "pricing_model": "CBP", - "billable": false, - "category": "referral_conversion" - }, - "errors": [ - { - "code": 131014, - "title": "Request for url https://URL.jpg failed with error: 404 (Not Found)" - } - ] - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/helloWAC.json b/handlers/facebookapp/testdata/wac/helloWAC.json deleted file mode 100644 index d7cf38ee8..000000000 --- a/handlers/facebookapp/testdata/wac/helloWAC.json +++ /dev/null @@ -1,39 +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", - "timestamp": "1454119029", - "text": { - "body": "Hello World" - }, - "type": "text" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/ignoreStatusWAC.json b/handlers/facebookapp/testdata/wac/ignoreStatusWAC.json deleted file mode 100644 index 2b2e583a1..000000000 --- a/handlers/facebookapp/testdata/wac/ignoreStatusWAC.json +++ /dev/null @@ -1,49 +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" - } - ], - "statuses": [ - { - "id": "external_id", - "recipient_id": "5678", - "status": "deleted", - "timestamp": "1454119029", - "type": "message", - "conversation": { - "id": "CONVERSATION_ID", - "expiration_timestamp": 1454119029, - "origin": { - "type": "referral_conversion" - } - }, - "pricing": { - "pricing_model": "CBP", - "billable": false, - "category": "referral_conversion" - } - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/imageWAC.json b/handlers/facebookapp/testdata/wac/imageWAC.json deleted file mode 100644 index 7d3728e5b..000000000 --- a/handlers/facebookapp/testdata/wac/imageWAC.json +++ /dev/null @@ -1,43 +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", - "image": { - "file": "/usr/local/wamedia/shared/b1cf38-8734-4ad3-b4a1-ef0c10d0d683", - "id": "id_image", - "mime_type": "image/jpeg", - "sha256": "29ed500fa64eb55fc19dc4124acb300e5dcc54a0f822a301ae99944db", - "caption": "Check out my new phone!" - }, - "timestamp": "1454119029", - "type": "image" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/invalidFrom.json b/handlers/facebookapp/testdata/wac/invalidFrom.json deleted file mode 100644 index 12a28cc54..000000000 --- a/handlers/facebookapp/testdata/wac/invalidFrom.json +++ /dev/null @@ -1,39 +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": "bla" - } - ], - "messages": [ - { - "from": "bla", - "id": "external_id", - "timestamp": "1454119029", - "text": { - "body": "Hello World" - }, - "type": "text" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/invalidStatusWAC.json b/handlers/facebookapp/testdata/wac/invalidStatusWAC.json deleted file mode 100644 index 6a3a4fbcc..000000000 --- a/handlers/facebookapp/testdata/wac/invalidStatusWAC.json +++ /dev/null @@ -1,49 +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" - } - ], - "statuses": [ - { - "id": "external_id", - "recipient_id": "5678", - "status": "in_orbit", - "timestamp": "1454119029", - "type": "message", - "conversation": { - "id": "CONVERSATION_ID", - "expiration_timestamp": 1454119029, - "origin": { - "type": "referral_conversion" - } - }, - "pricing": { - "pricing_model": "CBP", - "billable": false, - "category": "referral_conversion" - } - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/invalidTimestamp.json b/handlers/facebookapp/testdata/wac/invalidTimestamp.json deleted file mode 100644 index dc0dd66d5..000000000 --- a/handlers/facebookapp/testdata/wac/invalidTimestamp.json +++ /dev/null @@ -1,39 +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": "bla" - } - ], - "messages": [ - { - "from": "bla", - "id": "external_id", - "timestamp": "asdf", - "text": { - "body": "Hello World" - }, - "type": "text" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/listReplyWAC.json b/handlers/facebookapp/testdata/wac/listReplyWAC.json deleted file mode 100644 index 1f3d2981c..000000000 --- a/handlers/facebookapp/testdata/wac/listReplyWAC.json +++ /dev/null @@ -1,43 +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", - "interactive": { - "type": "list_reply", - "list_reply": { - "id": "id_list_reply", - "title": "Yes" - } - }, - "timestamp": "1454119029", - "type": "interactive" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/locationWAC.json b/handlers/facebookapp/testdata/wac/locationWAC.json deleted file mode 100644 index 09a721c8d..000000000 --- a/handlers/facebookapp/testdata/wac/locationWAC.json +++ /dev/null @@ -1,43 +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", - "location": { - "address": "Main Street Beach, Santa Cruz, CA", - "latitude": 0.000000, - "longitude": 1.000000, - "name": "Main Street Beach", - "url": "https://foursquare.com/v/4d7031d35b5df7744" - }, - "timestamp": "1454119029", - "type": "location" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/validStatusWAC.json b/handlers/facebookapp/testdata/wac/validStatusWAC.json deleted file mode 100644 index 8a3360787..000000000 --- a/handlers/facebookapp/testdata/wac/validStatusWAC.json +++ /dev/null @@ -1,49 +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" - } - ], - "statuses": [ - { - "id": "external_id", - "recipient_id": "5678", - "status": "sent", - "timestamp": "1454119029", - "type": "message", - "conversation": { - "id": "CONVERSATION_ID", - "expiration_timestamp": 1454119029, - "origin": { - "type": "referral_conversion" - } - }, - "pricing": { - "pricing_model": "CBP", - "billable": false, - "category": "referral_conversion" - } - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/videoWAC.json b/handlers/facebookapp/testdata/wac/videoWAC.json deleted file mode 100644 index 234422efe..000000000 --- a/handlers/facebookapp/testdata/wac/videoWAC.json +++ /dev/null @@ -1,43 +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", - "video": { - "file": "/usr/local/wamedia/shared/b1cf38-8734-4ad3-b4a1-ef0c10d0d683", - "id": "id_video", - "mime_type": "image/jpeg", - "sha256": "29ed500fa64eb55fc19dc4124acb300e5dcc54a0f822a301ae99944db", - "caption": "Check out my new phone!" - }, - "timestamp": "1454119029", - "type": "video" - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/wac/voiceWAC.json b/handlers/facebookapp/testdata/wac/voiceWAC.json deleted file mode 100644 index 03e03375f..000000000 --- a/handlers/facebookapp/testdata/wac/voiceWAC.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", - "timestamp": "1454119029", - "type": "voice", - "voice": { - "file": "/usr/local/wamedia/shared/463e/b7ec/ff4e4d9bb1101879cbd411b2", - "id": "id_voice", - "mime_type": "audio/ogg; codecs=opus", - "sha256": "fa9e1807d936b7cebe63654ea3a7912b1fa9479220258d823590521ef53b0710" - } - } - ] - }, - "field": "messages" - } - ] - } - ] -} \ No newline at end of file diff --git a/handlers/firebase/firebase.go b/handlers/firebase/handler.go similarity index 86% rename from handlers/firebase/firebase.go rename to handlers/firebase/handler.go index 1d954d51b..a38276673 100644 --- a/handlers/firebase/firebase.go +++ b/handlers/firebase/handler.go @@ -11,6 +11,7 @@ import ( "github.com/buger/jsonparser" "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" ) @@ -34,7 +35,7 @@ type handler struct { } func newHandler() courier.ChannelHandler { - return &handler{handlers.NewBaseHandlerWithParams(courier.ChannelType("FCM"), "Firebase", true, []string{configKey})} + return &handler{handlers.NewBaseHandler(courier.ChannelType("FCM"), "Firebase", handlers.WithRedactConfigKeys(configKey))} } func (h *handler) Initialize(s courier.Server) error { @@ -74,11 +75,17 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) } + // if a new auth token was provided, record that + var authTokens map[string]string + if form.FCMToken != "" { + authTokens = map[string]string{"default": form.FCMToken} + } + // build our msg - dbMsg := h.Backend().NewIncomingMsg(channel, urn, form.Msg, "", clog).WithReceivedOn(date).WithContactName(form.Name).WithURNAuth(form.FCMToken) + dbMsg := h.Backend().NewIncomingMsg(channel, urn, form.Msg, "", clog).WithReceivedOn(date).WithContactName(form.Name).WithURNAuthTokens(authTokens) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{dbMsg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{dbMsg}, w, r, clog) } type registerForm struct { @@ -102,7 +109,7 @@ func (h *handler) registerContact(ctx context.Context, channel courier.Channel, } // create our contact - contact, err := h.Backend().GetContact(ctx, channel, urn, form.FCMToken, form.Name, clog) + contact, err := h.Backend().GetContact(ctx, channel, urn, map[string]string{"default": form.FCMToken}, form.Name, clog) if err != nil { return nil, err } @@ -134,7 +141,7 @@ type mtNotification struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { title := msg.Channel().StringConfigForKey(configTitle, "") if title == "" { return nil, fmt.Errorf("no FCM_TITLE set for FCM channel") @@ -153,7 +160,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann msgParts = handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) for i, part := range msgParts { payload := mtPayload{} @@ -179,10 +186,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann payload.ContentAvailable = true } - jsonPayload, err := json.Marshal(payload) - if err != nil { - return nil, err - } + jsonPayload := jsonx.MustMarshal(payload) req, err := http.NewRequest(http.MethodPost, sendURL, bytes.NewReader(jsonPayload)) if err != nil { @@ -193,7 +197,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("key=%s", fcmKey)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -216,6 +220,6 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/firebase/firebase_test.go b/handlers/firebase/handler_test.go similarity index 85% rename from handlers/firebase/firebase_test.go rename to handlers/firebase/handler_test.go index fe44a8fb2..f90dd9611 100644 --- a/handlers/firebase/firebase_test.go +++ b/handlers/firebase/handler_test.go @@ -8,6 +8,7 @@ import ( "github.com/nyaruka/courier" . "github.com/nyaruka/courier/handlers" "github.com/nyaruka/courier/test" + "github.com/nyaruka/gocommon/urns" ) const ( @@ -31,30 +32,30 @@ Donec euismod dapibus ligula, sit amet hendrerit neque vulputate ac.` var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "FCM", "1234", "", - map[string]interface{}{ + map[string]any{ configKey: "FCMKey", configTitle: "FCMTitle", }), test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "FCM", "1234", "", - map[string]interface{}{ + map[string]any{ configKey: "FCMKey", configNotification: true, configTitle: "FCMTitle", }), } -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { - Label: "Receive Valid Message", - URL: receiveURL, - Data: "from=12345&date=2017-01-01T08:50:00.000&fcm_token=token&name=fred&msg=hello+world", - ExpectedRespStatus: 200, - ExpectedBodyContains: "Accepted", - ExpectedMsgText: Sp("hello world"), - ExpectedURN: "fcm:12345", - ExpectedDate: time.Date(2017, 1, 1, 8, 50, 0, 0, time.UTC), - ExpectedURNAuth: "token", - ExpectedContactName: Sp("fred"), + Label: "Receive Valid Message", + URL: receiveURL, + Data: "from=12345&date=2017-01-01T08:50:00.000&fcm_token=token&name=fred&msg=hello+world", + ExpectedRespStatus: 200, + ExpectedBodyContains: "Accepted", + ExpectedMsgText: Sp("hello world"), + ExpectedURN: "fcm:12345", + ExpectedDate: time.Date(2017, 1, 1, 8, 50, 0, 0, time.UTC), + ExpectedURNAuthTokens: map[urns.URN]map[string]string{"fcm:12345": {"default": "token"}}, + ExpectedContactName: Sp("fred"), }, { Label: "Receive Invalid Date", @@ -86,8 +87,8 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -95,11 +96,11 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var notificationSendTestCases = []ChannelSendTestCase{ +var notificationSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -115,7 +116,7 @@ var notificationSendTestCases = []ChannelSendTestCase{ }, } -var sendTestCases = []ChannelSendTestCase{ +var sendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -191,7 +192,7 @@ var sendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { - RunChannelSendTestCases(t, testChannels[0], newHandler(), sendTestCases, []string{"FCMKey"}, nil) - RunChannelSendTestCases(t, testChannels[1], newHandler(), notificationSendTestCases, []string{"FCMKey"}, nil) +func TestOutgoing(t *testing.T) { + RunOutgoingTestCases(t, testChannels[0], newHandler(), sendTestCases, []string{"FCMKey"}, nil) + RunOutgoingTestCases(t, testChannels[1], newHandler(), notificationSendTestCases, []string{"FCMKey"}, nil) } diff --git a/handlers/freshchat/freshchat.go b/handlers/freshchat/handler.go similarity index 92% rename from handlers/freshchat/freshchat.go rename to handlers/freshchat/handler.go index 8fa2b1d9a..827aaa76b 100644 --- a/handlers/freshchat/freshchat.go +++ b/handlers/freshchat/handler.go @@ -11,7 +11,6 @@ import ( "crypto/sha256" "crypto/x509" "encoding/base64" - "encoding/json" "encoding/pem" "fmt" "io" @@ -21,6 +20,7 @@ import ( "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" ) @@ -86,17 +86,17 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w } } // build our msg - msg := h.Backend().NewIncomingMsg(channel, urn, text, "", clog).WithReceivedOn(date) + msg := h.Backend().NewIncomingMsg(channel, urn, text, payload.Data.Message.ID, clog).WithReceivedOn(date) //add image if mediaURL != "" { msg.WithAttachment(mediaURL) } // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { agentID := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if agentID == "" { @@ -109,7 +109,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } user := strings.Split(msg.URN().Path(), "/") - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) url := apiURL + "/conversations" // create base payload @@ -147,10 +147,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } } - jsonBody, err := json.Marshal(payload) - if err != nil { - return nil, err - } + jsonBody := jsonx.MustMarshal(payload) req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(jsonBody)) @@ -162,12 +159,12 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann var bearer = "Bearer " + authToken req.Header.Set("Authorization", bearer) - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/freshchat/freshchat_test.go b/handlers/freshchat/handler_test.go similarity index 90% rename from handlers/freshchat/freshchat_test.go rename to handlers/freshchat/handler_test.go index 904abb5cf..a52241b60 100644 --- a/handlers/freshchat/freshchat_test.go +++ b/handlers/freshchat/handler_test.go @@ -11,7 +11,7 @@ import ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FC", "2020", "US", map[string]interface{}{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FC", "2020", "US", map[string]any{ "username": "c8fddfaf-622a-4a0e-b060-4f3ccbeab606", //agent_id "secret": cert, // public_key for sig "auth_token": "authtoken", //API bearer token @@ -28,7 +28,7 @@ const ( invalidSignature = `f7wMD1BBhcj60U0z3dCY519qmxQ8qfVUU212Dapw9vpZfRBfjjmukUK2GwbAb0Nc+TGQHxN4iP4WD+Y/mSx6f4bmkBsvCy3l4OCQ/FEK0y5R7f+GLLDhgbTh90MwuLDHhvxB5dxIeu59leL+4yO+l/8M3Tm48aQurVBi9IAlzFsMtc1S1CiRxsDUb/rD6IRekPa0pUAbkno9qJ/CGXh0kZMdsYzRkzZmKCs79OWrvU94ha0ptyt5wArfmD1oSzY3PjeL2w8LWDc0QV21H/Hvj42azIUqebiNRtZ2E+f34AfQsyfcPuy1k/6qLuYGOdU1uZidPuPcGpeSIm0GW6k9HQ==` ) -var sigtestCases = []ChannelHandleTestCase{ +var sigtestCases = []IncomingTestCase{ { Label: "Receive Valid w Signature", Headers: map[string]string{"Content-Type": "application/json", "X-FreshChat-Signature": validSignature}, @@ -39,6 +39,7 @@ var sigtestCases = []ChannelHandleTestCase{ ExpectedMsgText: Sp("Test 2"), ExpectedURN: "freshchat:c8fddfaf-622a-4a0e-b060-4f3ccbeab606/882f3926-b292-414b-a411-96380db373cd", ExpectedDate: time.Date(2019, 6, 21, 17, 43, 20, 866000000, time.UTC), + ExpectedExternalID: "7a454fde-c720-4c97-a61d-0ffe70449eb6", }, { Label: "Bad Signature", @@ -50,7 +51,7 @@ var sigtestCases = []ChannelHandleTestCase{ }, } -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid w Sig", Headers: map[string]string{"Content-Type": "application/json", "X-FreshChat-Signature": validSignature}, @@ -72,20 +73,20 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler("FC", "FreshChat", true), sigtestCases) - RunChannelTestCases(t, testChannels, newHandler("FC", "FreshChat", false), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler("FC", "FreshChat", true), sigtestCases) + RunIncomingTestCases(t, testChannels, newHandler("FC", "FreshChat", false), testCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler("FC", "FreshChat", false), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { apiURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -125,11 +126,11 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { - var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FC", "2020", "US", map[string]interface{}{ +func TestOutgoing(t *testing.T) { + var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FC", "2020", "US", map[string]any{ "username": "c8fddfaf-622a-4a0e-b060-4f3ccbeab606", "secret": cert, "auth_token": "enYtdXNlcm5hbWU6enYtcGFzc3dvcmQ=", }) - RunChannelSendTestCases(t, defaultChannel, newHandler("FC", "FreshChat", false), defaultSendTestCases, []string{cert, "enYtdXNlcm5hbWU6enYtcGFzc3dvcmQ="}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler("FC", "FreshChat", false), defaultSendTestCases, []string{cert, "enYtdXNlcm5hbWU6enYtcGFzc3dvcmQ="}, nil) } diff --git a/handlers/generic.go b/handlers/generic.go index 08f3556e7..5926b11b5 100644 --- a/handlers/generic.go +++ b/handlers/generic.go @@ -29,12 +29,12 @@ func NewTelReceiveHandler(h courier.ChannelHandler, fromField string, bodyField } // build our msg msg := h.Server().Backend().NewIncomingMsg(c, urn, body, "", clog).WithReceivedOn(time.Now().UTC()) - return WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } } // NewExternalIDStatusHandler creates a new status handler given the passed in status map and fields -func NewExternalIDStatusHandler(h courier.ChannelHandler, statuses map[string]courier.MsgStatusValue, externalIDField string, statusField string) courier.ChannelHandleFunc { +func NewExternalIDStatusHandler(h courier.ChannelHandler, statuses map[string]courier.MsgStatus, externalIDField string, statusField string) courier.ChannelHandleFunc { return func(ctx context.Context, c courier.Channel, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, error) { err := r.ParseForm() if err != nil { @@ -53,7 +53,7 @@ func NewExternalIDStatusHandler(h courier.ChannelHandler, statuses map[string]co } // create our status - status := h.Server().Backend().NewMsgStatusForExternalID(c, externalID, sValue, clog) + status := h.Server().Backend().NewStatusUpdateByExternalID(c, externalID, sValue, clog) return WriteMsgStatusAndResponse(ctx, h, c, status, w, r) } } diff --git a/handlers/globe/globe.go b/handlers/globe/handler.go similarity index 90% rename from handlers/globe/globe.go rename to handlers/globe/handler.go index cb13ae83b..2f39be542 100644 --- a/handlers/globe/globe.go +++ b/handlers/globe/handler.go @@ -33,7 +33,7 @@ type handler struct { } func newHandler() courier.ChannelHandler { - return &handler{handlers.NewBaseHandlerWithParams(courier.ChannelType("GL"), "Globe Labs", true, []string{configPassphrase, configAppSecret})} + return &handler{handlers.NewBaseHandler(courier.ChannelType("GL"), "Globe Labs", handlers.WithRedactConfigKeys(configPassphrase, configAppSecret))} } // Initialize is called by the engine once everything is loaded @@ -78,7 +78,7 @@ func (h *handler) receiveMessage(ctx context.Context, c courier.Channel, w http. return nil, handlers.WriteAndLogRequestIgnored(ctx, h, c, w, r, "no messages, ignored") } - msgs := make([]courier.Msg, 0, 1) + msgs := make([]courier.MsgIn, 0, 1) // parse each inbound message for _, glMsg := range payload.InboundSMSMessageList.InboundSMSMessage { @@ -120,7 +120,7 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { appID := msg.Channel().StringConfigForKey(configAppID, "") if appID == "" { return nil, fmt.Errorf("Missing 'app_id' config for GL channel") @@ -136,7 +136,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("Missing 'passphrase' config for GL channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for _, part := range parts { payload := &mtPayload{} @@ -157,12 +157,12 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil diff --git a/handlers/globe/globe_test.go b/handlers/globe/handler_test.go similarity index 93% rename from handlers/globe/globe_test.go rename to handlers/globe/handler_test.go index aeedf55b2..34fc49fd8 100644 --- a/handlers/globe/globe_test.go +++ b/handlers/globe/handler_test.go @@ -109,7 +109,7 @@ var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "GL", "2020", "US", nil), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL, @@ -157,8 +157,8 @@ var handleTestCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { @@ -166,11 +166,11 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL + "?%s" } -var sendTestCases = []ChannelSendTestCase{ +var sendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -213,14 +213,14 @@ var sendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "GL", "2020", "US", - map[string]interface{}{ + map[string]any{ "app_id": "12345", "app_secret": "mysecret", "passphrase": "opensesame", }, ) - RunChannelSendTestCases(t, defaultChannel, newHandler(), sendTestCases, []string{"mysecret", "opensesame"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), sendTestCases, []string{"mysecret", "opensesame"}, nil) } diff --git a/handlers/highconnection/highconnection.go b/handlers/highconnection/handler.go similarity index 79% rename from handlers/highconnection/highconnection.go rename to handlers/highconnection/handler.go index b1a9304dc..77cf30c68 100644 --- a/handlers/highconnection/highconnection.go +++ b/handlers/highconnection/handler.go @@ -6,6 +6,7 @@ import ( "mime" "net/http" "net/url" + "strconv" "time" "github.com/nyaruka/courier" @@ -38,6 +39,7 @@ func (h *handler) Initialize(s courier.Server) error { } type moForm struct { + ID int64 `name:"ID"` To string `name:"TO" validate:"required"` From string `name:"FROM" validate:"required"` Message string `name:"MESSAGE"` @@ -72,11 +74,16 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w text = mime.BEncoding.Encode("ISO-8859-1", text) text, _ = new(mime.WordDecoder).DecodeHeader(text) + msgID := "" + if form.ID != 0 { + msgID = strconv.FormatInt(form.ID, 10) + } + // build our Message - msg := h.Backend().NewIncomingMsg(channel, urn, text, "", clog).WithReceivedOn(date.UTC()) + msg := h.Backend().NewIncomingMsg(channel, urn, text, msgID, clog).WithReceivedOn(date.UTC()) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } type statusForm struct { @@ -84,16 +91,16 @@ type statusForm struct { Status int `name:"status" validate:"required"` } -var statusMapping = map[int]courier.MsgStatusValue{ - 2: courier.MsgFailed, - 4: courier.MsgSent, - 6: courier.MsgDelivered, - 11: courier.MsgFailed, - 12: courier.MsgFailed, - 13: courier.MsgFailed, - 14: courier.MsgFailed, - 15: courier.MsgFailed, - 16: courier.MsgFailed, +var statusMapping = map[int]courier.MsgStatus{ + 2: courier.MsgStatusFailed, + 4: courier.MsgStatusSent, + 6: courier.MsgStatusDelivered, + 11: courier.MsgStatusFailed, + 12: courier.MsgStatusFailed, + 13: courier.MsgStatusFailed, + 14: courier.MsgStatusFailed, + 15: courier.MsgStatusFailed, + 16: courier.MsgStatusFailed, } // receiveStatus is our HTTP handler function for status updates @@ -110,12 +117,12 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // write our status - status := h.Backend().NewMsgStatusForID(channel, courier.MsgID(form.RetID), msgStatus, clog) + status := h.Backend().NewStatusUpdate(channel, courier.MsgID(form.RetID), msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for HX channel") @@ -130,10 +137,15 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann statusURL := fmt.Sprintf("https://%s/c/hx/%s/status", callbackDomain, msg.Channel().UUID()) receiveURL := fmt.Sprintf("https://%s/c/hx/%s/receive", callbackDomain, msg.Channel().UUID()) - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) - for _, part := range parts { + var flowName string + if msg.Flow() != nil { + flowName = msg.Flow().Name + } + + for _, part := range parts { form := url.Values{ "accountid": []string{username}, "password": []string{password}, @@ -141,7 +153,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann "to": []string{msg.URN().Path()}, "ret_id": []string{msg.ID().String()}, "datacoding": []string{"8"}, - "user_data": []string{msg.FlowName()}, + "user_data": []string{flowName}, "ret_url": []string{statusURL}, "ret_mo_url": []string{receiveURL}, } @@ -154,12 +166,12 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, err } - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } diff --git a/handlers/highconnection/highconnection_test.go b/handlers/highconnection/handler_test.go similarity index 91% rename from handlers/highconnection/highconnection_test.go rename to handlers/highconnection/handler_test.go index 2be804227..34169c1c4 100644 --- a/handlers/highconnection/highconnection_test.go +++ b/handlers/highconnection/handler_test.go @@ -19,26 +19,28 @@ const ( statusURL = "/c/hx/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status/" ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL, - Data: "FROM=+33610346460&TO=5151&MESSAGE=Hello+World&RECEPTION_DATE=2015-04-02T14%3A26%3A06", + Data: "FROM=+33610346460&TO=5151&MESSAGE=Hello+World&RECEPTION_DATE=2015-04-02T14%3A26%3A06&ID=123456", ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedMsgText: Sp("Hello World"), ExpectedURN: "tel:+33610346460", ExpectedDate: time.Date(2015, 04, 02, 14, 26, 06, 0, time.UTC), + ExpectedExternalID: "123456", }, { Label: "Receive Valid Message with accents", URL: receiveURL, - Data: "FROM=+33610346460&TO=5151&MESSAGE=je+suis+tr%E8s+satisfait+&RECEPTION_DATE=2015-04-02T14%3A26%3A06", + Data: "FROM=+33610346460&TO=5151&MESSAGE=je+suis+tr%E8s+satisfait+&RECEPTION_DATE=2015-04-02T14%3A26%3A06&ID=123123", ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedMsgText: Sp("je suis très satisfait "), ExpectedURN: "tel:+33610346460", ExpectedDate: time.Date(2015, 04, 02, 14, 26, 06, 0, time.UTC), + ExpectedExternalID: "123123", }, { Label: "Invalid URN", @@ -72,12 +74,12 @@ var testCases = []ChannelHandleTestCase{ URL: statusURL + "?ret_id=12345&status=6", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, + ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusDelivered}}, }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -85,11 +87,11 @@ func BenchmarkHandler(b *testing.B) { } // setSend takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -200,13 +202,13 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "HX", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigPassword: "Password", courier.ConfigUsername: "Username", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/hormuud/hormuud.go b/handlers/hormuud/handler.go similarity index 89% rename from handlers/hormuud/hormuud.go rename to handlers/hormuud/handler.go index 1c30ba811..2e650a2aa 100644 --- a/handlers/hormuud/hormuud.go +++ b/handlers/hormuud/handler.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "net/url" "strings" @@ -14,7 +15,6 @@ import ( "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" "github.com/pkg/errors" - "github.com/sirupsen/logrus" ) var ( @@ -63,7 +63,7 @@ func (h *handler) receiveMessage(ctx context.Context, c courier.Channel, w http. } msg := h.Backend().NewIncomingMsg(c, urn, payload.MessageText, "", clog) - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } type mtPayload struct { @@ -76,8 +76,8 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) token, err := h.FetchToken(ctx, msg.Channel(), msg, clog) if err != nil { @@ -107,12 +107,12 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) // try to get the message id out id, _ := jsonparser.GetString(respBody, "Data", "MessageID") @@ -129,7 +129,7 @@ type tokenResponse struct { } // FetchToken gets the current token for this channel, either from Redis if cached or by requesting it -func (h *handler) FetchToken(ctx context.Context, channel courier.Channel, msg courier.Msg, clog *courier.ChannelLog) (string, error) { +func (h *handler) FetchToken(ctx context.Context, channel courier.Channel, msg courier.MsgOut, clog *courier.ChannelLog) (string, error) { // first check whether we have it in redis conn := h.Backend().RedisPool().Get() token, err := redis.String(conn.Do("GET", fmt.Sprintf("hm_token_%s", channel.UUID()))) @@ -162,7 +162,7 @@ func (h *handler) FetchToken(ctx context.Context, channel courier.Channel, msg c req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return "", errors.Wrapf(err, "error making token request") } @@ -181,7 +181,7 @@ func (h *handler) FetchToken(ctx context.Context, channel courier.Channel, msg c conn.Close() if err != nil { - logrus.WithError(err).Error("error caching HM access token") + slog.Error("error caching HM access token", "error", err) } return token, nil diff --git a/handlers/hormuud/hormuud_test.go b/handlers/hormuud/handler_test.go similarity index 90% rename from handlers/hormuud/hormuud_test.go rename to handlers/hormuud/handler_test.go index bd3ed63c7..2a8f530c1 100644 --- a/handlers/hormuud/hormuud_test.go +++ b/handlers/hormuud/handler_test.go @@ -24,7 +24,7 @@ var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "HM", "2020", "US", nil), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveValidMessage, @@ -62,16 +62,16 @@ var handleTestCases = []ChannelHandleTestCase{ // {Label: "Status Valid", URL: statusValid, Status: 200, Response: `"status":"S"`}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var sendTestCases = []ChannelSendTestCase{ +var sendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -117,7 +117,7 @@ var sendTestCases = []ChannelSendTestCase{ }, } -var tokenTestCases = []ChannelSendTestCase{ +var tokenTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -127,7 +127,7 @@ var tokenTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { // set up a token server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("valid") == "true" { @@ -143,15 +143,15 @@ func TestSending(t *testing.T) { tokenURL = server.URL + "?valid=true" var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "HM", "2020", "US", - map[string]interface{}{ + map[string]any{ "username": "foo@bar.com", "password": "sesame", }, ) - RunChannelSendTestCases(t, defaultChannel, newHandler(), sendTestCases, []string{"sesame"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), sendTestCases, []string{"sesame"}, nil) tokenURL = server.URL + "?invalid=true" - RunChannelSendTestCases(t, defaultChannel, newHandler(), tokenTestCases, []string{"sesame"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), tokenTestCases, []string{"sesame"}, nil) } diff --git a/handlers/http.go b/handlers/http.go deleted file mode 100644 index 0aed1a72c..000000000 --- a/handlers/http.go +++ /dev/null @@ -1,38 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/nyaruka/courier" - "github.com/nyaruka/courier/utils" - "github.com/nyaruka/gocommon/httpx" -) - -// RequestHTTP does the given request, logging the trace, and returns the response -func RequestHTTP(req *http.Request, clog *courier.ChannelLog) (*http.Response, []byte, error) { - return RequestHTTPWithClient(utils.GetHTTPClient(), req, clog) -} - -// RequestHTTPInsecure does the given request using an insecure client that does not validate SSL certificates, -// logging the trace, and returns the response -func RequestHTTPInsecure(req *http.Request, clog *courier.ChannelLog) (*http.Response, []byte, error) { - return RequestHTTPWithClient(utils.GetInsecureHTTPClient(), req, clog) -} - -// RequestHTTP does the given request using the given client, logging the trace, and returns the response -func RequestHTTPWithClient(client *http.Client, req *http.Request, clog *courier.ChannelLog) (*http.Response, []byte, error) { - var resp *http.Response - var body []byte - - trace, err := httpx.DoTrace(client, req, nil, nil, 0) - if trace != nil { - clog.HTTP(trace) - resp = trace.Response - body = trace.ResponseBody - } - if err != nil { - return nil, nil, err - } - - return resp, body, nil -} diff --git a/handlers/http_test.go b/handlers/http_test.go deleted file mode 100644 index 1e9257b9b..000000000 --- a/handlers/http_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package handlers_test - -import ( - "net/http" - "testing" - - "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" -) - -func TestDoHTTPRequest(t *testing.T) { - httpx.SetRequestor(httpx.NewMockRequestor(map[string][]*httpx.MockResponse{ - "https://api.messages.com/send.json": { - httpx.NewMockResponse(200, nil, []byte(`{"status":"success"}`)), - httpx.NewMockResponse(400, nil, []byte(`{"status":"error"}`)), - }, - })) - defer httpx.SetRequestor(httpx.DefaultRequestor) - - mb := test.NewMockBackend() - mc := test.NewMockChannel("7a8ff1d4-f211-4492-9d05-e1905f6da8c8", "NX", "1234", "EC", nil) - mm := mb.NewOutgoingMsg(mc, 123, urns.URN("tel:+1234"), "Hello World", false, nil, "", "", courier.MsgOriginChat, nil) - clog := courier.NewChannelLogForSend(mm, nil) - - req, _ := http.NewRequest("POST", "https://api.messages.com/send.json", nil) - resp, respBody, err := handlers.RequestHTTP(req, clog) - assert.NoError(t, err) - assert.Equal(t, 200, resp.StatusCode) - assert.Equal(t, []byte(`{"status":"success"}`), respBody) - assert.Len(t, clog.HTTPLogs(), 1) - - hlog1 := clog.HTTPLogs()[0] - assert.Equal(t, 200, hlog1.StatusCode) - assert.Equal(t, "https://api.messages.com/send.json", hlog1.URL) - - req, _ = http.NewRequest("POST", "https://api.messages.com/send.json", nil) - resp, _, err = handlers.RequestHTTP(req, clog) - assert.NoError(t, err) - assert.Equal(t, 400, resp.StatusCode) - assert.Len(t, clog.HTTPLogs(), 2) - - hlog2 := clog.HTTPLogs()[1] - assert.Equal(t, 400, hlog2.StatusCode) - assert.Equal(t, "https://api.messages.com/send.json", hlog2.URL) -} diff --git a/handlers/hub9/hub9.go b/handlers/hub9/handler.go similarity index 100% rename from handlers/hub9/hub9.go rename to handlers/hub9/handler.go diff --git a/handlers/i18n.go b/handlers/i18n.go new file mode 100644 index 000000000..0088d9b4e --- /dev/null +++ b/handlers/i18n.go @@ -0,0 +1,25 @@ +package handlers + +import "github.com/nyaruka/gocommon/i18n" + +func GetText(text string, locale i18n.Locale) string { + if set, ok := translations[text]; ok { + lang, _ := locale.Split() + if trans := set[lang]; trans != "" { + return trans + } + } + return text +} + +var translations = map[string]map[i18n.Language]string{ + "Menu": { + "afr": "Kieslys", + "ara": "قائمة", + "zho": "菜单", + "heb": "תפריט", + "gle": "Roghchlár", + "spa": "Menú", + "swa": "Menyu", + }, +} diff --git a/handlers/i18n_test.go b/handlers/i18n_test.go new file mode 100644 index 000000000..f0f0bf98c --- /dev/null +++ b/handlers/i18n_test.go @@ -0,0 +1,16 @@ +package handlers_test + +import ( + "testing" + + "github.com/nyaruka/courier/handlers" + "github.com/stretchr/testify/assert" +) + +func TestGetText(t *testing.T) { + assert.Equal(t, "Menu", handlers.GetText("Menu", "eng")) + assert.Equal(t, "Menú", handlers.GetText("Menu", "spa")) + assert.Equal(t, "Menú", handlers.GetText("Menu", "spa-MX")) + assert.Equal(t, "Menyu", handlers.GetText("Menu", "swa")) + assert.Equal(t, "Foo", handlers.GetText("Foo", "eng")) +} diff --git a/handlers/i2sms/i2sms.go b/handlers/i2sms/handler.go similarity index 86% rename from handlers/i2sms/i2sms.go rename to handlers/i2sms/handler.go index c04555c18..543139963 100644 --- a/handlers/i2sms/i2sms.go +++ b/handlers/i2sms/handler.go @@ -32,7 +32,7 @@ type handler struct { } func newHandler() courier.ChannelHandler { - return &handler{handlers.NewBaseHandlerWithParams(courier.ChannelType("I2"), "I2SMS", true, []string{courier.ConfigPassword, configChannelHash})} + return &handler{handlers.NewBaseHandler(courier.ChannelType("I2"), "I2SMS", handlers.WithRedactConfigKeys(courier.ConfigPassword, configChannelHash))} } // Initialize is called by the engine once everything is loaded @@ -63,7 +63,7 @@ func (h *handler) receive(ctx context.Context, c courier.Channel, w http.Respons // build our msg msg := h.Backend().NewIncomingMsg(c, urn, body, "", clog).WithReceivedOn(time.Now().UTC()) - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // { @@ -84,7 +84,7 @@ type mtResponse struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for I2 channel") @@ -100,7 +100,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no channel_hash set for I2 channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) for _, part := range handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) { form := url.Values{ "action": []string{"send_single"}, @@ -117,7 +117,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -132,10 +132,10 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // we always get 00 on success if response.ErrorCode == "00" { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) status.SetExternalID(response.Result.SessionID) } else { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) clog.Error(courier.ErrorResponseValueUnexpected("error_code", "00")) break } @@ -152,7 +152,7 @@ func (h *handler) RedactValues(ch courier.Channel) []string { } // WriteMsgSuccessResponse writes a success response for the messages, i2SMS expects an empty body in our response -func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { w.Header().Add("Content-type", "text/plain") w.WriteHeader(http.StatusOK) _, err := w.Write([]byte{}) diff --git a/handlers/i2sms/i2sms_test.go b/handlers/i2sms/handler_test.go similarity index 80% rename from handlers/i2sms/i2sms_test.go rename to handlers/i2sms/handler_test.go index ce8e9ae14..799e0fd6d 100644 --- a/handlers/i2sms/i2sms_test.go +++ b/handlers/i2sms/handler_test.go @@ -2,6 +2,7 @@ package i2sms import ( "net/http/httptest" + "net/url" "testing" "github.com/nyaruka/courier" @@ -18,7 +19,7 @@ const ( receiveURL = "/c/i2/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -37,19 +38,19 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -57,11 +58,15 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, MockResponseBody: `{"result":{"session_id":"5b8fc97d58795484819426"}, "error_code": "00", "error_desc": "Success"}`, MockResponseStatus: 200, - ExpectedPostParams: map[string]string{ - "action": "send_single", - "mobile": "250788383383", - "message": "Simple Message ☺\nhttps://foo.bar/image.jpg", - "channel": "hash123", + ExpectedRequests: []ExpectedRequest{ + { + Form: url.Values{ + "action": {"send_single"}, + "mobile": {"250788383383"}, + "message": {"Simple Message ☺\nhttps://foo.bar/image.jpg"}, + "channel": {"hash123"}, + }, + }, }, ExpectedMsgStatus: "W", ExpectedExternalID: "5b8fc97d58795484819426", @@ -98,12 +103,12 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "I2", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigUsername: "user1", courier.ConfigPassword: "pass1", configChannelHash: "hash123", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("user1", "pass1"), "hash123"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("user1", "pass1"), "hash123"}, nil) } diff --git a/handlers/infobip/infobip.go b/handlers/infobip/handler.go similarity index 89% rename from handlers/infobip/infobip.go rename to handlers/infobip/handler.go index 75de6b126..1f6ab666b 100644 --- a/handlers/infobip/infobip.go +++ b/handlers/infobip/handler.go @@ -39,12 +39,12 @@ func (h *handler) Initialize(s courier.Server) error { return nil } -var statusMapping = map[string]courier.MsgStatusValue{ - "PENDING": courier.MsgSent, - "EXPIRED": courier.MsgSent, - "DELIVERED": courier.MsgDelivered, - "REJECTED": courier.MsgFailed, - "UNDELIVERABLE": courier.MsgFailed, +var statusMapping = map[string]courier.MsgStatus{ + "PENDING": courier.MsgStatusSent, + "EXPIRED": courier.MsgStatusSent, + "DELIVERED": courier.MsgStatusDelivered, + "REJECTED": courier.MsgStatusFailed, + "UNDELIVERABLE": courier.MsgStatusFailed, } type statusPayload struct { @@ -59,7 +59,7 @@ type ibStatus struct { // statusMessage is our HTTP handler function for status updates func (h *handler) statusMessage(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, payload *statusPayload, clog *courier.ChannelLog) ([]courier.Event, error) { - data := make([]interface{}, len(payload.Results)) + data := make([]any, len(payload.Results)) statuses := make([]courier.Event, len(payload.Results)) for _, s := range payload.Results { msgStatus, found := statusMapping[s.Status.GroupName] @@ -68,13 +68,8 @@ func (h *handler) statusMessage(ctx context.Context, channel courier.Channel, w } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, s.MessageID, msgStatus, clog) - err := h.Backend().WriteMsgStatus(ctx, status) - if err == courier.ErrMsgNotFound { - data = append(data, courier.NewInfoData(fmt.Sprintf("ignoring status update message id: %s, not found", s.MessageID))) - continue - } - + status := h.Backend().NewStatusUpdateByExternalID(channel, s.MessageID, msgStatus, clog) + err := h.Backend().WriteStatusUpdate(ctx, status) if err != nil { return nil, err } @@ -125,7 +120,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring request, no message") } - msgs := []courier.Msg{} + msgs := []courier.MsgIn{} for _, infobipMessage := range payload.Results { messageID := infobipMessage.MessageID text := infobipMessage.Text @@ -164,7 +159,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for IB channel") @@ -214,9 +209,9 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.SetBasicAuth(username, password) - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -232,7 +227,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann status.SetExternalID(externalID) } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/infobip/infobip_test.go b/handlers/infobip/handler_test.go similarity index 91% rename from handlers/infobip/infobip_test.go rename to handlers/infobip/handler_test.go index 922c1ab1e..d86c831fb 100644 --- a/handlers/infobip/infobip_test.go +++ b/handlers/infobip/handler_test.go @@ -193,7 +193,7 @@ var invalidStatus = `{ ] }` -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL, @@ -246,7 +246,7 @@ var testCases = []ChannelHandleTestCase{ Data: validStatusDelivered, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusDelivered}}, }, { Label: "Status rejected", @@ -254,7 +254,7 @@ var testCases = []ChannelHandleTestCase{ Data: validStatusRejected, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusFailed}}, }, { Label: "Status undeliverable", @@ -262,7 +262,7 @@ var testCases = []ChannelHandleTestCase{ Data: validStatusUndeliverable, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusFailed}}, }, { Label: "Status pending", @@ -270,7 +270,7 @@ var testCases = []ChannelHandleTestCase{ Data: validStatusPending, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, - ExpectedMsgStatus: courier.MsgSent, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusSent}, {ExternalID: "12347", Status: courier.MsgStatusSent}}, }, { Label: "Status expired", @@ -278,7 +278,7 @@ var testCases = []ChannelHandleTestCase{ Data: validStatusExpired, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, - ExpectedMsgStatus: courier.MsgSent, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusSent}}, }, { Label: "Status group name unexpected", @@ -289,8 +289,8 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -298,11 +298,11 @@ func BenchmarkHandler(b *testing.B) { } // setSend takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -383,7 +383,7 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -var transSendTestCases = []ChannelSendTestCase{ +var transSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -402,21 +402,21 @@ var transSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "IB", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigPassword: "Password", courier.ConfigUsername: "Username", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("Username", "Password")}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("Username", "Password")}, nil) var transChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "IB", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigPassword: "Password", courier.ConfigUsername: "Username", configTransliteration: "COLOMBIAN", }) - RunChannelSendTestCases(t, transChannel, newHandler(), transSendTestCases, []string{httpx.BasicAuth("Username", "Password")}, nil) + RunOutgoingTestCases(t, transChannel, newHandler(), transSendTestCases, []string{httpx.BasicAuth("Username", "Password")}, nil) } diff --git a/handlers/jasmin/jasmin.go b/handlers/jasmin/handler.go similarity index 88% rename from handlers/jasmin/jasmin.go rename to handlers/jasmin/handler.go index 2b80583a1..b54365a83 100644 --- a/handlers/jasmin/jasmin.go +++ b/handlers/jasmin/handler.go @@ -53,14 +53,14 @@ func (h *handler) receiveStatus(ctx context.Context, c courier.Channel, w http.R // should have either delivered or err reqStatus := courier.NilMsgStatus if form.Delivered == 1 { - reqStatus = courier.MsgDelivered + reqStatus = courier.MsgStatusDelivered } else if form.Err == 1 { - reqStatus = courier.MsgFailed + reqStatus = courier.MsgStatusFailed } else { return nil, handlers.WriteAndLogRequestError(ctx, h, c, w, r, fmt.Errorf("must have either dlvrd or err set to 1")) } - status := h.Backend().NewMsgStatusForExternalID(c, form.ID, reqStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(c, form.ID, reqStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, c, status, w, r) } @@ -97,14 +97,14 @@ func (h *handler) receiveMessage(ctx context.Context, c courier.Channel, w http. msg := h.Backend().NewIncomingMsg(c, urn, text, form.ID, clog).WithReceivedOn(time.Now().UTC()) // and finally queue our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } -func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { return writeJasminACK(w) } -func (h *handler) WriteStatusSuccessResponse(ctx context.Context, w http.ResponseWriter, statuses []courier.MsgStatus) error { +func (h *handler) WriteStatusSuccessResponse(ctx context.Context, w http.ResponseWriter, statuses []courier.StatusUpdate) error { return writeJasminACK(w) } @@ -119,7 +119,7 @@ func writeJasminACK(w http.ResponseWriter) error { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for JS channel") @@ -160,14 +160,14 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, err } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) // try to read our external id out matches := idRegex.FindSubmatch(respBody) diff --git a/handlers/jasmin/jasmin_test.go b/handlers/jasmin/handler_test.go similarity index 88% rename from handlers/jasmin/jasmin_test.go rename to handlers/jasmin/handler_test.go index 3bd11a6d9..084f38b77 100644 --- a/handlers/jasmin/jasmin_test.go +++ b/handlers/jasmin/handler_test.go @@ -19,7 +19,7 @@ var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JS", "2020", "US", nil), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL, @@ -50,8 +50,7 @@ var handleTestCases = []ChannelHandleTestCase{ Data: "id=external1&dlvrd=1", ExpectedRespStatus: 200, ExpectedBodyContains: "ACK/Jasmin", - ExpectedMsgStatus: "D", - ExpectedExternalID: "external1", + ExpectedStatuses: []ExpectedStatus{{ExternalID: "external1", Status: courier.MsgStatusDelivered}}, }, { Label: "Status Failed", @@ -59,8 +58,7 @@ var handleTestCases = []ChannelHandleTestCase{ Data: "id=external1&err=1", ExpectedRespStatus: 200, ExpectedBodyContains: "ACK/Jasmin", - ExpectedMsgStatus: "F", - ExpectedExternalID: "external1", + ExpectedStatuses: []ExpectedStatus{{ExternalID: "external1", Status: courier.MsgStatusFailed}}, }, { Label: "Status Missing", @@ -78,8 +76,8 @@ var handleTestCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { @@ -87,11 +85,11 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { c.(*test.MockChannel).SetConfig("send_url", s.URL) } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -157,12 +155,12 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JS", "2020", "US", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/jiochat/jiochat.go b/handlers/jiochat/handler.go similarity index 94% rename from handlers/jiochat/jiochat.go rename to handlers/jiochat/handler.go index 3785fc444..058343edc 100644 --- a/handlers/jiochat/jiochat.go +++ b/handlers/jiochat/handler.go @@ -122,7 +122,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w // subscribe event, trigger a new conversation if payload.MsgType == "event" && payload.Event == "subscribe" { - channelEvent := h.Backend().NewChannelEvent(channel, courier.NewConversation, urn, clog) + channelEvent := h.Backend().NewChannelEvent(channel, courier.EventTypeNewConversation, urn, clog) err := h.Backend().WriteChannelEvent(ctx, channelEvent, clog) if err != nil { @@ -145,7 +145,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w } // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } func buildMediaURL(mediaID string) string { @@ -163,13 +163,13 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { accessToken, err := h.getAccessToken(ctx, msg.Channel(), clog) if err != nil { return nil, err } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for _, part := range parts { jcMsg := &mtPayload{} @@ -186,12 +186,12 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil @@ -216,7 +216,7 @@ func (h *handler) DescribeURN(ctx context.Context, channel courier.Channel, urn req, _ := http.NewRequest(http.MethodGet, reqURL.String(), nil) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return nil, errors.New("unable to look up contact data") } @@ -304,7 +304,7 @@ func (h *handler) fetchAccessToken(ctx context.Context, channel courier.Channel, req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return "", 0, err } diff --git a/handlers/jiochat/jiochat_test.go b/handlers/jiochat/handler_test.go similarity index 93% rename from handlers/jiochat/jiochat_test.go rename to handlers/jiochat/handler_test.go index cb429d809..77af22f35 100644 --- a/handlers/jiochat/jiochat_test.go +++ b/handlers/jiochat/handler_test.go @@ -5,6 +5,8 @@ import ( "crypto/sha1" "encoding/hex" "io" + "log" + "log/slog" "net/http" "net/http/httptest" "net/url" @@ -18,12 +20,11 @@ import ( "github.com/nyaruka/courier/test" "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/urns" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JC", "2020", "US", map[string]interface{}{configAppSecret: "secret123", configAppID: "app-id"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JC", "2020", "US", map[string]any{configAppSecret: "secret123", configAppID: "app-id"}), } var ( @@ -141,7 +142,7 @@ func addInvalidSignature(r *http.Request) { r.URL.RawQuery = query.Encode() } -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Message", URL: receiveURL, @@ -192,8 +193,9 @@ var testCases = []ChannelHandleTestCase{ Data: subscribeEvent, ExpectedRespStatus: 200, ExpectedBodyContains: "Event Accepted", - ExpectedEvent: courier.NewConversation, - ExpectedURN: "jiochat:1234", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeNewConversation, URN: "jiochat:1234"}, + }, }, { Label: "Unsubscribe Event", @@ -218,8 +220,8 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -227,7 +229,7 @@ func BenchmarkHandler(b *testing.B) { } // mocks the call to the Jiochat API -func buildMockJCAPI(testCases []ChannelHandleTestCase) *httptest.Server { +func buildMockJCAPI(testCases []IncomingTestCase) *httptest.Server { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authorizationHeader := r.Header.Get("Authorization") defer r.Body.Close() @@ -259,9 +261,8 @@ func buildMockJCAPI(testCases []ChannelHandleTestCase) *httptest.Server { func newServer(backend courier.Backend) courier.Server { // for benchmarks, log to null - logger := logrus.New() - logger.Out = io.Discard - logrus.SetOutput(io.Discard) + logger := slog.Default() + log.SetOutput(io.Discard) config := courier.NewConfig() config.DB = "postgres://courier_test:temba@localhost:5432/courier_test?sslmode=disable" config.Redis = "redis://localhost:6379/0" @@ -344,11 +345,11 @@ func TestBuildAttachmentRequest(t *testing.T) { } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -412,9 +413,9 @@ func setupBackend(mb *test.MockBackend) { rc.Do("SET", "channel-token:8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ACCESS_TOKEN") } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 - var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JC", "2020", "US", map[string]interface{}{configAppSecret: "secret123", configAppID: "app-id"}) + var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JC", "2020", "US", map[string]any{configAppSecret: "secret123", configAppID: "app-id"}) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"secret123"}, setupBackend) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"secret123"}, setupBackend) } diff --git a/handlers/junebug/junebug.go b/handlers/junebug/junebug.go deleted file mode 100644 index d783f9b1d..000000000 --- a/handlers/junebug/junebug.go +++ /dev/null @@ -1,219 +0,0 @@ -package junebug - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/buger/jsonparser" - "github.com/nyaruka/courier" - "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/gocommon/httpx" -) - -var ( - maxMsgLength = 1530 -) - -func init() { - courier.RegisterHandler(newHandler()) -} - -type handler struct { - handlers.BaseHandler -} - -func newHandler() courier.ChannelHandler { - return &handler{handlers.NewBaseHandler(courier.ChannelType("JN"), "Junebug")} -} - -// 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, "event", courier.ChannelLogTypeEventReceive, handlers.JSONPayload(h, h.receiveEvent)) - s.AddHandlerRoute(h, http.MethodPost, "inbound", courier.ChannelLogTypeMsgReceive, handlers.JSONPayload(h, h.receiveMessage)) - return nil -} - -// { -// "from": "+27123456789", -// "timestamp": "2017-01-01 00:00:00.00", -// "content": "content", -// "to": "to-addr", -// "reply_to": null, -// "message_id": "message-id" -// } -type moPayload struct { - From string `json:"from" validate:"required"` - Timestamp string `json:"timestamp" validate:"required"` - Content string `json:"content"` - To string `json:"to" validate:"required"` - ReplyTo string `json:"reply_to"` - MessageID string `json:"message_id" validate:"required"` -} - -// receiveMessage is our HTTP handler function for incoming messages -func (h *handler) receiveMessage(ctx context.Context, c courier.Channel, w http.ResponseWriter, r *http.Request, payload *moPayload, clog *courier.ChannelLog) ([]courier.Event, error) { - // check authentication - secret := c.StringConfigForKey(courier.ConfigSecret, "") - if secret != "" { - authorization := r.Header.Get("Authorization") - if authorization != fmt.Sprintf("Token %s", secret) { - return nil, courier.WriteAndLogUnauthorized(w, r, c, fmt.Errorf("invalid Authorization header")) - } - } - - // parse our date - date, err := time.Parse("2006-01-02 15:04:05", payload.Timestamp) - if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, c, w, r, fmt.Errorf("unable to parse date: %s", payload.Timestamp)) - } - - urn, err := handlers.StrictTelForCountry(payload.From, c.Country()) - if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, c, w, r, err) - } - - msg := h.Backend().NewIncomingMsg(c, urn, payload.Content, payload.MessageID, clog).WithReceivedOn(date.UTC()) - - // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) -} - -// { -// 'event_type': 'submitted', -// 'message_id': 'message-id', -// 'timestamp': '2017-01-01 00:00:00+0000', -// } -type eventPayload struct { - EventType string `json:"event_type" validate:"required"` - MessageID string `json:"message_id" validate:"required"` -} - -var statusMapping = map[string]courier.MsgStatusValue{ - "submitted": courier.MsgSent, - "delivery_pending": courier.MsgWired, - "delivery_succeeded": courier.MsgDelivered, - "delivery_failed": courier.MsgFailed, - "rejected": courier.MsgFailed, -} - -// receiveEvent is our HTTP handler function for incoming events -func (h *handler) receiveEvent(ctx context.Context, c courier.Channel, w http.ResponseWriter, r *http.Request, payload *eventPayload, clog *courier.ChannelLog) ([]courier.Event, error) { - // check authentication - secret := c.StringConfigForKey(courier.ConfigSecret, "") - if secret != "" { - authorization := r.Header.Get("Authorization") - if authorization != fmt.Sprintf("Token %s", secret) { - return nil, courier.WriteAndLogUnauthorized(w, r, c, fmt.Errorf("invalid Authorization header")) - } - } - - // look up our status - msgStatus, found := statusMapping[payload.EventType] - if !found { - return nil, handlers.WriteAndLogRequestIgnored(ctx, h, c, w, r, "ignoring unknown event_type") - } - - // ignore pending, same status we are already in - if msgStatus == courier.MsgWired { - return nil, handlers.WriteAndLogRequestIgnored(ctx, h, c, w, r, "ignoring existing pending status") - } - - status := h.Backend().NewMsgStatusForExternalID(c, payload.MessageID, msgStatus, clog) - return handlers.WriteMsgStatusAndResponse(ctx, h, c, status, w, r) -} - -// { -// "event_url": "https://callback.com/event", -// "content": "hello world", -// "from": "2020", -// "to": "+250788383383", -// "event_auth_token": "secret", -// } -type mtPayload struct { - EventURL string `json:"event_url"` - Content string `json:"content"` - From string `json:"from"` - To string `json:"to"` - EventAuthToken string `json:"event_auth_token,omitempty"` -} - -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { - sendURL := msg.Channel().StringConfigForKey(courier.ConfigSendURL, "") - if sendURL == "" { - return nil, fmt.Errorf("No send_url set for JN channel") - } - - username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") - password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "") - if username == "" || password == "" { - return nil, fmt.Errorf("Missing username or password for JN channel") - } - - secret := msg.Channel().StringConfigForKey(courier.ConfigSecret, "") - - callbackDomain := msg.Channel().CallbackDomain(h.Server().Config().Domain) - eventURL := fmt.Sprintf("https://%s/c/jn/%s/event", callbackDomain, msg.Channel().UUID()) - - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) - for i, part := range handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) { - payload := mtPayload{ - EventURL: eventURL, - Content: part, - From: msg.Channel().Address(), - To: msg.URN().Path(), - } - - if secret != "" { - payload.EventAuthToken = secret - } - - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, err - } - - req, err := http.NewRequest(http.MethodPost, sendURL, bytes.NewReader(jsonBody)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.SetBasicAuth(username, password) - - resp, respBody, err := handlers.RequestHTTP(req, clog) - if err != nil || resp.StatusCode/100 != 2 { - return status, nil - } - - externalID, err := jsonparser.GetString(respBody, "result", "message_id") - if err != nil { - clog.Error(courier.ErrorResponseValueMissing("message_id")) - return status, nil - } - - // if this is our first message, record the external id - if i == 0 { - status.SetExternalID(externalID) - } - } - - // this was wired successfully - status.SetStatus(courier.MsgWired) - return status, nil -} - -func (h *handler) RedactValues(ch courier.Channel) []string { - vals := []string{ - httpx.BasicAuth(ch.StringConfigForKey(courier.ConfigUsername, ""), ch.StringConfigForKey(courier.ConfigPassword, "")), - } - secret := ch.StringConfigForKey(courier.ConfigSecret, "") - if secret != "" { - vals = append(vals, secret) - } - return vals -} diff --git a/handlers/junebug/junebug_test.go b/handlers/junebug/junebug_test.go deleted file mode 100644 index 6664ad2b4..000000000 --- a/handlers/junebug/junebug_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package junebug - -import ( - "net/http/httptest" - "testing" - "time" - - "github.com/nyaruka/courier" - . "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/test" - "github.com/nyaruka/gocommon/httpx" -) - -var testChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JN", "2020", "US", map[string]interface{}{ - "username": "user1", - "password": "pass1", - "send_url": "https://foo.bar/", -}) - -var authenticatedTestChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JN", "2020", "US", map[string]interface{}{ - "username": "user1", - "password": "pass1", - "send_url": "https://foo.bar/", - "secret": "sesame", -}) - -var ( - inboundURL = "/c/jn/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/inbound" - validMsg = `{ - "from": "+250788383383", - "timestamp": "2017-01-01 01:02:03.05", - "content": "hello world", - "to": "2020", - "message_id": "external-id" - } - ` - - invalidURN = `{ - "from": "MTN", - "timestamp": "2017-01-01 01:02:03.05", - "content": "hello world", - "to": "2020", - "message_id": "external-id" - } - ` - - invalidTimestamp = `{ - "from": "+250788383383", - "timestamp": "20170101T01:02:03.05", - "content": "hello world", - "to": "2020", - "message_id": "external-id" - } - ` - - missingMessageID = `{ - "from": "+250788383383", - "timestamp": "2017-01-01 01:02:03.05", - "content": "hello world", - "to": "2020" - } - ` - - eventURL = "/c/jn/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/event" - - pendingEvent = `{ - "event_type": "delivery_pending", - "message_id": "xx12345" - }` - - sentEvent = `{ - "event_type": "submitted", - "message_id": "xx12345" - }` - - deliveredEvent = `{ - "event_type": "delivery_succeeded", - "message_id": "xx12345" - }` - - failedEvent = `{ - "event_type": "rejected", - "message_id": "xx12345" - }` - - unknownEvent = `{ - "event_type": "unknown", - "message_id": "xx12345" - }` - - missingEventType = `{ - "message_id": "xx12345" - }` -) - -var testCases = []ChannelHandleTestCase{ - { - Label: "Receive Valid Message", - URL: inboundURL, - Data: validMsg, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Accepted", - ExpectedMsgText: Sp("hello world"), - ExpectedURN: "tel:+250788383383", - ExpectedDate: time.Date(2017, 01, 01, 1, 2, 3, 50000000, time.UTC), - }, - { - Label: "Invalid URN", - URL: inboundURL, - Data: invalidURN, - ExpectedRespStatus: 400, - ExpectedBodyContains: "phone number supplied is not a number", - }, - { - Label: "Invalid Timestamp", - URL: inboundURL, - Data: invalidTimestamp, - ExpectedRespStatus: 400, - ExpectedBodyContains: "unable to parse date", - }, - { - Label: "Missing Message ID", - URL: inboundURL, - Data: missingMessageID, - ExpectedRespStatus: 400, - ExpectedBodyContains: "'MessageID' failed on the 'required'", - }, - { - Label: "Receive Pending Event", - URL: eventURL, - Data: pendingEvent, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Ignored", - }, - { - Label: "Receive Sent Event", - URL: eventURL, - Data: sentEvent, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Accepted", - ExpectedExternalID: "xx12345", - ExpectedMsgStatus: "S", - }, - { - Label: "Receive Delivered Event", - URL: eventURL, - Data: deliveredEvent, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Accepted", - ExpectedExternalID: "xx12345", - ExpectedMsgStatus: "D", - }, - { - Label: "Receive Failed Event", - URL: eventURL, - Data: failedEvent, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Accepted", - ExpectedExternalID: "xx12345", - ExpectedMsgStatus: "F", - }, - { - Label: "Receive Unknown Event", - URL: eventURL, - Data: unknownEvent, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Ignored", - }, - { - Label: "Receive Invalid JSON", - URL: eventURL, - Data: "not json", - ExpectedRespStatus: 400, - ExpectedBodyContains: "Error", - }, - { - Label: "Receive Missing Event Type", - URL: eventURL, - Data: missingEventType, - ExpectedRespStatus: 400, - ExpectedBodyContains: "Error", - }, -} - -var authenticatedTestCases = []ChannelHandleTestCase{ - { - Label: "Receive Valid Message", - URL: inboundURL, - Data: validMsg, - Headers: map[string]string{"Authorization": "Token sesame"}, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Accepted", - ExpectedMsgText: Sp("hello world"), - ExpectedURN: "tel:+250788383383", - ExpectedDate: time.Date(2017, 01, 01, 1, 2, 3, 50000000, time.UTC), - }, - { - Label: "Invalid Incoming Authorization", - URL: inboundURL, - Data: validMsg, - Headers: map[string]string{"Authorization": "Token foo"}, - ExpectedRespStatus: 401, - ExpectedBodyContains: "Unauthorized", - }, - - { - Label: "Receive Sent Event", - URL: eventURL, - Data: sentEvent, - Headers: map[string]string{"Authorization": "Token sesame"}, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Accepted", - ExpectedExternalID: "xx12345", - ExpectedMsgStatus: "S", - }, - { - Label: "Invalid Incoming Authorization", - URL: eventURL, - Data: sentEvent, - Headers: map[string]string{"Authorization": "Token foo"}, - ExpectedRespStatus: 401, - ExpectedBodyContains: "Unauthorized", - }, -} - -func TestHandler(t *testing.T) { - RunChannelTestCases(t, []courier.Channel{testChannel}, newHandler(), testCases) - RunChannelTestCases(t, []courier.Channel{authenticatedTestChannel}, newHandler(), authenticatedTestCases) -} - -func BenchmarkHandler(b *testing.B) { - RunChannelBenchmarks(b, []courier.Channel{testChannel}, newHandler(), testCases) -} - -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { - c.(*test.MockChannel).SetConfig("send_url", s.URL) -} - -var sendTestCases = []ChannelSendTestCase{ - { - Label: "Plain Send", - MsgText: "Simple Message", - MsgURN: "tel:+250788383383", - MockResponseBody: `{"result":{"message_id":"externalID"}}`, - MockResponseStatus: 200, - ExpectedHeaders: map[string]string{"Authorization": "Basic dXNlcjE6cGFzczE="}, - ExpectedRequestBody: `{"event_url":"https://localhost/c/jn/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/event","content":"Simple Message","from":"2020","to":"+250788383383"}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "externalID", - SendPrep: setSendURL, - }, - { - Label: "Send Attachement", - MsgText: "My pic!", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, - MsgURN: "tel:+250788383383", - MockResponseBody: `{"result":{"message_id":"externalID"}}`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"event_url":"https://localhost/c/jn/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/event","content":"My pic!\nhttps://foo.bar/image.jpg","from":"2020","to":"+250788383383"}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "externalID", - SendPrep: setSendURL, - }, - { - Label: "Invalid JSON Response", - MsgText: "Error Sending", - MsgURN: "tel:+250788383383", - MockResponseStatus: 200, - MockResponseBody: "not json", - ExpectedMsgStatus: "E", - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("message_id")}, - SendPrep: setSendURL, - }, - { - Label: "Missing External ID", - MsgText: "Error Sending", - MsgURN: "tel:+250788383383", - MockResponseStatus: 200, - MockResponseBody: "{}", - ExpectedMsgStatus: "E", - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("message_id")}, - SendPrep: setSendURL, - }, - { - Label: "Error Sending", - MsgText: "Error Sending", - MsgURN: "tel:+250788383383", - MockResponseStatus: 403, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, -} - -var authenticatedSendTestCases = []ChannelSendTestCase{ - { - Label: "Plain Send", - MsgText: "Simple Message", - MsgURN: "tel:+250788383383", - MockResponseBody: `{"result":{"message_id":"externalID"}}`, - MockResponseStatus: 200, - ExpectedHeaders: map[string]string{"Authorization": "Basic dXNlcjE6cGFzczE="}, - ExpectedRequestBody: `{"event_url":"https://localhost/c/jn/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/event","content":"Simple Message","from":"2020","to":"+250788383383","event_auth_token":"sesame"}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "externalID", - SendPrep: setSendURL, - }, -} - -func TestSending(t *testing.T) { - RunChannelSendTestCases(t, testChannel, newHandler(), sendTestCases, []string{httpx.BasicAuth("user1", "pass1"), "sesame"}, nil) - RunChannelSendTestCases(t, authenticatedTestChannel, newHandler(), authenticatedSendTestCases, []string{httpx.BasicAuth("user1", "pass1"), "sesame"}, nil) -} diff --git a/handlers/justcall/justcall.go b/handlers/justcall/handler.go similarity index 89% rename from handlers/justcall/justcall.go rename to handlers/justcall/handler.go index 03e00e310..2f89150f7 100644 --- a/handlers/justcall/justcall.go +++ b/handlers/justcall/handler.go @@ -121,14 +121,14 @@ func (h *handler) receiveMessage(ctx context.Context, c courier.Channel, w http. } // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } -var statusMapping = map[string]courier.MsgStatusValue{ - "delivered": courier.MsgDelivered, - "sent": courier.MsgSent, - "undelivered": courier.MsgErrored, - "failed": courier.MsgFailed, +var statusMapping = map[string]courier.MsgStatus{ + "delivered": courier.MsgStatusDelivered, + "sent": courier.MsgStatusSent, + "undelivered": courier.MsgStatusErrored, + "failed": courier.MsgStatusFailed, } func (h *handler) statusMessage(ctx context.Context, c courier.Channel, w http.ResponseWriter, r *http.Request, payload *moPayload, clog *courier.ChannelLog) ([]courier.Event, error) { @@ -141,7 +141,7 @@ func (h *handler) statusMessage(ctx context.Context, c courier.Channel, w http.R return nil, handlers.WriteAndLogRequestError(ctx, h, c, w, r, fmt.Errorf("unknown status '%s', must be one of send, delivered, undelivered, failed", payload.Data.Status)) } // write our status - status := h.Backend().NewMsgStatusForExternalID(c, fmt.Sprint(payload.Data.MessageID), msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(c, fmt.Sprint(payload.Data.MessageID), msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, c, status, w, r) } @@ -153,7 +153,7 @@ type mtPayload struct { } // Send implements courier.ChannelHandler -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { apiKey := msg.Channel().StringConfigForKey(courier.ConfigAPIKey, "") if apiKey == "" { return nil, fmt.Errorf("no API key set for JCL channel") @@ -164,7 +164,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no API secret set for JCL channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) mediaURLs := make([]string, 0, 5) text := msg.Text() @@ -190,7 +190,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("%s:%s", apiKey, apiSecret)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -215,7 +215,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann status.SetExternalID(fmt.Sprintf("%d", externalID)) } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/justcall/justcall_test.go b/handlers/justcall/handler_test.go similarity index 92% rename from handlers/justcall/justcall_test.go rename to handlers/justcall/handler_test.go index de13f08b8..3e59da44b 100644 --- a/handlers/justcall/justcall_test.go +++ b/handlers/justcall/handler_test.go @@ -11,7 +11,7 @@ import ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JCL", "2020", "US", map[string]interface{}{courier.ConfigAPIKey: "api_key", courier.ConfigSecret: "api_secret"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JCL", "2020", "US", map[string]any{courier.ConfigAPIKey: "api_key", courier.ConfigSecret: "api_secret"}), } var ( @@ -193,7 +193,7 @@ var unknownStatus = `{ } }` -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL, @@ -241,8 +241,7 @@ var testCases = []ChannelHandleTestCase{ Data: validStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `"type":"status"`, - ExpectedMsgStatus: "S", - ExpectedExternalID: "26523491", + ExpectedStatuses: []ExpectedStatus{{ExternalID: "26523491", Status: courier.MsgStatusSent}}, }, { Label: "Receive invalid status direction", @@ -260,8 +259,8 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -269,11 +268,11 @@ func BenchmarkHandler(b *testing.B) { } // setSend takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -322,8 +321,8 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { - var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JCL", "2020", "US", map[string]interface{}{courier.ConfigAPIKey: "api_key", courier.ConfigSecret: "api_secret"}) +func TestOutgoing(t *testing.T) { + var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "JCL", "2020", "US", map[string]any{courier.ConfigAPIKey: "api_key", courier.ConfigSecret: "api_secret"}) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"api_key", "api_secret"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"api_key", "api_secret"}, nil) } diff --git a/handlers/kaleyra/kaleyra.go b/handlers/kaleyra/handler.go similarity index 89% rename from handlers/kaleyra/kaleyra.go rename to handlers/kaleyra/handler.go index 952065fdd..13d116934 100644 --- a/handlers/kaleyra/kaleyra.go +++ b/handlers/kaleyra/handler.go @@ -100,14 +100,14 @@ func (h *handler) receiveMsg(ctx context.Context, channel courier.Channel, w htt } // write msg - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } -var statusMapping = map[string]courier.MsgStatusValue{ - "0": courier.MsgFailed, - "sent": courier.MsgWired, - "delivered": courier.MsgDelivered, - "read": courier.MsgDelivered, +var statusMapping = map[string]courier.MsgStatus{ + "0": courier.MsgStatusFailed, + "sent": courier.MsgStatusWired, + "delivered": courier.MsgStatusDelivered, + "read": courier.MsgStatusDelivered, } // receiveStatus is our HTTP handler function for outgoing messages statuses @@ -125,7 +125,7 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // msg not found? ignore this - status := h.Backend().NewMsgStatusForExternalID(channel, form.ID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, form.ID, msgStatus, clog) if status == nil { return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, fmt.Sprintf("ignoring request, message %s not found", form.ID)) } @@ -135,7 +135,7 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { accountSID := msg.Channel().StringConfigForKey(configAccountSID, "") apiKey := msg.Channel().StringConfigForKey(configApiKey, "") @@ -143,7 +143,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, errors.New("no account_sid or api_key config") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) sendURL := fmt.Sprintf("%s/v1/%s/messages", baseURL, accountSID) var kwaResp *http.Response @@ -158,7 +158,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // download media req, _ := http.NewRequest(http.MethodGet, attachmentURL, nil) - resp, attBody, err := handlers.RequestHTTP(req, clog) + resp, attBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { kwaErr = errors.New("unable to fetch media") break @@ -203,7 +203,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // send multipart form req, _ = http.NewRequest(http.MethodPost, sendURL, body) req.Header.Set("Content-Type", writer.FormDataContentType()) - kwaResp, kwaRespBody, kwaErr = handlers.RequestHTTP(req, clog) + kwaResp, kwaRespBody, kwaErr = h.RequestHTTP(req, clog) } } else { form := url.Values{} @@ -219,11 +219,11 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req, _ := http.NewRequest(http.MethodPost, sendURL, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - kwaResp, kwaRespBody, kwaErr = handlers.RequestHTTP(req, clog) + kwaResp, kwaRespBody, kwaErr = h.RequestHTTP(req, clog) } if kwaErr != nil || kwaResp.StatusCode/100 != 2 { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) return status, nil } @@ -233,7 +233,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann status.SetExternalID(externalID) } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/kaleyra/kaleyra_test.go b/handlers/kaleyra/handler_test.go similarity index 93% rename from handlers/kaleyra/kaleyra_test.go rename to handlers/kaleyra/handler_test.go index 2311284a5..cb14e8836 100644 --- a/handlers/kaleyra/kaleyra_test.go +++ b/handlers/kaleyra/handler_test.go @@ -20,14 +20,14 @@ const ( var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "KWA", "250788383383", "", - map[string]interface{}{ + map[string]any{ configAccountSID: "SID", configApiKey: "123456", }, ), } -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Msg", URL: receiveMsgURL + "?created_at=1603914166&type=text&from=14133881111&name=John%20Cruz&body=Hello%20World", @@ -83,10 +83,9 @@ var testCases = []ChannelHandleTestCase{ { Label: "Receive Valid Status", URL: receiveStatusURL + "?id=58f86fab-85c5-4f7c-9b68-9c323248afc4%3A0&status=read", - ExpectedExternalID: "58f86fab-85c5-4f7c-9b68-9c323248afc4:0", - ExpectedMsgStatus: "D", ExpectedRespStatus: 200, ExpectedBodyContains: `"type":"status"`, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "58f86fab-85c5-4f7c-9b68-9c323248afc4:0", Status: courier.MsgStatusDelivered}}, }, { Label: "Receive Invalid Status", @@ -102,19 +101,19 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { baseURL = s.URL } -var sendTestCases = []ChannelSendTestCase{ +var sendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -209,8 +208,8 @@ var sendTestCases = []ChannelSendTestCase{ }, } -func mockAttachmentURLs(mediaServer *httptest.Server, testCases []ChannelSendTestCase) []ChannelSendTestCase { - casesWithMockedUrls := make([]ChannelSendTestCase, len(testCases)) +func mockAttachmentURLs(mediaServer *httptest.Server, testCases []OutgoingTestCase) []OutgoingTestCase { + casesWithMockedUrls := make([]OutgoingTestCase, len(testCases)) for i, testCase := range testCases { mockedCase := testCase @@ -223,7 +222,7 @@ func mockAttachmentURLs(mediaServer *httptest.Server, testCases []ChannelSendTes return casesWithMockedUrls } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { mediaServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { defer req.Body.Close() res.WriteHeader(200) @@ -239,5 +238,5 @@ func TestSending(t *testing.T) { })) mockedSendTestCases := mockAttachmentURLs(mediaServer, sendTestCases) - RunChannelSendTestCases(t, testChannels[0], newHandler(), mockedSendTestCases, []string{"123456"}, nil) + RunOutgoingTestCases(t, testChannels[0], newHandler(), mockedSendTestCases, []string{"123456"}, nil) } diff --git a/handlers/kannel/kannel.go b/handlers/kannel/handler.go similarity index 89% rename from handlers/kannel/kannel.go rename to handlers/kannel/handler.go index 00d638f01..42780fd09 100644 --- a/handlers/kannel/kannel.go +++ b/handlers/kannel/handler.go @@ -77,15 +77,15 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w msg := h.Backend().NewIncomingMsg(channel, urn, form.Message, form.ID, clog).WithReceivedOn(date) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } -var statusMapping = map[int]courier.MsgStatusValue{ - 1: courier.MsgDelivered, - 2: courier.MsgErrored, - 4: courier.MsgSent, - 8: courier.MsgSent, - 16: courier.MsgErrored, +var statusMapping = map[int]courier.MsgStatus{ + 1: courier.MsgStatusDelivered, + 2: courier.MsgStatusErrored, + 4: courier.MsgStatusSent, + 8: courier.MsgStatusSent, + 16: courier.MsgStatusErrored, } type statusForm struct { @@ -115,12 +115,12 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w // } // // write our status - // status := h.Backend().NewMsgStatusForID(channel, form.ID, msgStatus, clog) + // status := h.Backend().NewStatusUpdate(channel, form.ID, msgStatus, clog) // return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for KN channel") @@ -203,20 +203,19 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann var resp *http.Response if verifySSL { - resp, _, err = handlers.RequestHTTP(req, clog) + resp, _, err = h.RequestHTTP(req, clog) } else { - resp, _, err = handlers.RequestHTTPInsecure(req, clog) + resp, _, err = h.RequestHTTPInsecure(req, clog) } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgFailed, clog) - + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) if err == nil && resp.StatusCode/100 == 2 { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } // kannel will respond with a 403 for non-routable numbers, fail permanently in these cases if resp != nil && resp.StatusCode == 403 { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) } return status, nil diff --git a/handlers/kannel/kannel_test.go b/handlers/kannel/handler_test.go similarity index 90% rename from handlers/kannel/kannel_test.go rename to handlers/kannel/handler_test.go index 31ca0403a..64840a2a9 100644 --- a/handlers/kannel/kannel_test.go +++ b/handlers/kannel/handler_test.go @@ -15,10 +15,10 @@ var testChannels = []courier.Channel{ } var ignoreChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "KN", "2020", "US", map[string]interface{}{"ignore_sent": true}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "KN", "2020", "US", map[string]any{"ignore_sent": true}), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: "/c/kn/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?backend=NIG_MTN&sender=%2B2349067554729&message=Join&ts=1493735509&id=asdf-asdf&to=24453", @@ -82,11 +82,11 @@ var handleTestCases = []ChannelHandleTestCase{ // URL: "/c/kn/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status/?id=12345&status=4", // ExpectedRespStatus: 200, // ExpectedBodyContains: `"status":"S"`, - // ExpectedMsgStatus: courier.MsgSent, + // ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusSent}}, // }, } -var ignoreTestCases = []ChannelHandleTestCase{ +var ignoreTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: "/c/kn/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?backend=NIG_MTN&sender=%2B2349067554729&message=Join&ts=1493735509&id=asdf-asdf&to=24453", @@ -103,7 +103,7 @@ var ignoreTestCases = []ChannelHandleTestCase{ // URL: "/c/kn/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status/?id=12345&status=1", // ExpectedRespStatus: 200, // ExpectedBodyContains: `"status":"D"`, - // ExpectedMsgStatus: courier.MsgDelivered, + // ExpectedStatuses: []ExpectedStatus{{MsgID: 12345, Status: courier.MsgStatusDelivered}}, // }, { Label: "Ignore Status Wired", @@ -119,9 +119,9 @@ var ignoreTestCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) - RunChannelTestCases(t, ignoreChannels, newHandler(), ignoreTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) + RunIncomingTestCases(t, ignoreChannels, newHandler(), ignoreTestCases) } func BenchmarkHandler(b *testing.B) { @@ -129,16 +129,16 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { c.(*test.MockChannel).SetConfig("send_url", s.URL) } // setSendURLWithQuery takes care of setting the send_url to our test server host -func setSendURLWithQuery(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURLWithQuery(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { c.(*test.MockChannel).SetConfig("send_url", s.URL+"?auth=foo") } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -224,7 +224,7 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -var nationalSendTestCases = []ChannelSendTestCase{ +var nationalSendTestCases = []OutgoingTestCase{ { Label: "National Send", MsgText: "success", @@ -238,14 +238,14 @@ var nationalSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "KN", "2020", "US", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username"}) var nationalChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "KN", "2020", "US", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username", "use_national": true, @@ -253,6 +253,6 @@ func TestSending(t *testing.T) { "dlr_mask": "3", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) - RunChannelSendTestCases(t, nationalChannel, newHandler(), nationalSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, nationalChannel, newHandler(), nationalSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/line/line.go b/handlers/line/handler.go similarity index 97% rename from handlers/line/line.go rename to handlers/line/handler.go index 127889ef7..fce0a690e 100644 --- a/handlers/line/line.go +++ b/handlers/line/handler.go @@ -116,7 +116,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w return nil, err } - msgs := []courier.Msg{} + msgs := []courier.MsgIn{} for _, lineEvent := range payload.Events { if lineEvent.ReplyToken == "" || (lineEvent.Source.Type == "" && lineEvent.Source.UserID == "") || (lineEvent.Message.Type == "" && lineEvent.Message.ID == "") { @@ -283,12 +283,12 @@ type mtResponse struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { authToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") if authToken == "" { return nil, fmt.Errorf("no auth token set for LN channel: %s", msg.Channel().UUID()) } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) // all msg parts in JSON var jsonMsgs []string @@ -360,7 +360,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return status, err } - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err == nil && resp.StatusCode/100 == 2 { batch = []string{} @@ -382,7 +382,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return status, err } - resp, respBody, _ := handlers.RequestHTTP(req, clog) + resp, respBody, _ := h.RequestHTTP(req, clog) respPayload := &mtResponse{} err = json.Unmarshal(respBody, respPayload) @@ -401,7 +401,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } } } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/line/line_test.go b/handlers/line/handler_test.go similarity index 97% rename from handlers/line/line_test.go rename to handlers/line/handler_test.go index 467c31432..3b894665e 100644 --- a/handlers/line/line_test.go +++ b/handlers/line/handler_test.go @@ -251,13 +251,13 @@ var noEvent = `{ var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "LN", "2020", "US", - map[string]interface{}{ + map[string]any{ "secret": "Secret", "auth_token": "the-auth-token", }), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL, @@ -383,8 +383,8 @@ func addInvalidSignature(r *http.Request) { r.Header.Set(signatureHeader, "invalidsig") } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { @@ -392,7 +392,7 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { replySendURL = s.URL + "/v2/bot/message/reply" pushSendURL = s.URL + "/v2/bot/message/push" } @@ -408,7 +408,7 @@ Ut tincidunt massa eu purus lacinia sodales a volutpat neque. Cras dolor quam, e Vivamus justo dolor, gravida at quam eu, hendrerit rutrum justo. Sed hendrerit nisi vitae nisl ornare tristique. Proin vulputate id justo non aliquet.` -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -604,16 +604,16 @@ func setupMedia(mb *test.MockBackend) { mb.MockMedia(filePDF) } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "LN", "2020", "US", - map[string]interface{}{ + map[string]any{ "auth_token": "AccessToken", }, ) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"AccessToken"}, setupMedia) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"AccessToken"}, setupMedia) } func TestBuildAttachmentRequest(t *testing.T) { diff --git a/handlers/m3tech/m3tech.go b/handlers/m3tech/handler.go similarity index 88% rename from handlers/m3tech/m3tech.go rename to handlers/m3tech/handler.go index 0079b2508..8cdfb39db 100644 --- a/handlers/m3tech/m3tech.go +++ b/handlers/m3tech/handler.go @@ -58,18 +58,18 @@ func (h *handler) receiveMessage(ctx context.Context, c courier.Channel, w http. // create and write the message msg := h.Backend().NewIncomingMsg(c, urn, body, "", clog).WithReceivedOn(time.Now().UTC()) - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // WriteMsgSuccessResponse writes a success response for the messages -func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { w.Header().Set("Content-Type", "application/json") _, err := fmt.Fprintf(w, "SMS Accepted: %d", msgs[0].ID()) return err } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for M3 channel") @@ -88,7 +88,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } // send our message - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) for _, part := range handlers.SplitMsgByChannel(msg.Channel(), text, maxMsgLength) { // build our request params := url.Values{ @@ -113,13 +113,13 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, err } - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { break } // all went well, set ourselves to wired - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil diff --git a/handlers/m3tech/m3tech_test.go b/handlers/m3tech/handler_test.go similarity index 89% rename from handlers/m3tech/m3tech_test.go rename to handlers/m3tech/handler_test.go index 1556fbbc0..12d84196f 100644 --- a/handlers/m3tech/m3tech_test.go +++ b/handlers/m3tech/handler_test.go @@ -13,7 +13,7 @@ var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "M3", "2020", "US", nil), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: "/c/m3/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive?from=+923161909799&text=hello+world", @@ -39,8 +39,8 @@ var handleTestCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { @@ -48,11 +48,11 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ {Label: "Plain Send", MsgText: "Simple Message", MsgURN: "tel:+250788383383", ExpectedMsgStatus: "W", @@ -95,13 +95,13 @@ var defaultSendTestCases = []ChannelSendTestCase{ SendPrep: setSendURL}, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "M3", "2020", "US", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username", }, ) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/macrokiosk/macrokiosk.go b/handlers/macrokiosk/handler.go similarity index 88% rename from handlers/macrokiosk/macrokiosk.go rename to handlers/macrokiosk/handler.go index f2617a8ba..aa4ffb5dd 100644 --- a/handlers/macrokiosk/macrokiosk.go +++ b/handlers/macrokiosk/handler.go @@ -53,11 +53,11 @@ type statusForm struct { Status string `name:"status" validate:"required"` } -var statusMapping = map[string]courier.MsgStatusValue{ - "ACCEPTED": courier.MsgSent, - "DELIVERED": courier.MsgDelivered, - "UNDELIVERED": courier.MsgFailed, - "PROCESSING": courier.MsgWired, +var statusMapping = map[string]courier.MsgStatus{ + "ACCEPTED": courier.MsgStatusSent, + "DELIVERED": courier.MsgStatusDelivered, + "UNDELIVERED": courier.MsgStatusFailed, + "PROCESSING": courier.MsgStatusWired, } // receiveStatus is our HTTP handler function for status updates @@ -73,7 +73,7 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, fmt.Sprintf("ignoring unknown status '%s'", form.Status)) } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, form.MsgID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, form.MsgID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } @@ -129,11 +129,11 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w // create and write the message msg := h.Backend().NewIncomingMsg(channel, urn, form.Text, form.MsgID, clog).WithReceivedOn(date.UTC()) - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // WriteMsgSuccessResponse -func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { w.WriteHeader(200) _, err := fmt.Fprint(w, "-1") // MacroKiosk expects "-1" back for successful requests return err @@ -150,7 +150,7 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "") servID := msg.Channel().StringConfigForKey(configMacrokioskServiceID, "") @@ -166,7 +166,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann encoding = "5" } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), text, maxMsgLength) for i, part := range parts { payload := &mtPayload{ @@ -189,7 +189,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -204,6 +204,6 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann status.SetExternalID(externalID) } } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/macrokiosk/macrokiosk_test.go b/handlers/macrokiosk/handler_test.go similarity index 81% rename from handlers/macrokiosk/macrokiosk_test.go rename to handlers/macrokiosk/handler_test.go index d534f50b9..16fb64389 100644 --- a/handlers/macrokiosk/macrokiosk_test.go +++ b/handlers/macrokiosk/handler_test.go @@ -30,7 +30,7 @@ var ( unknownStatus = "msgid=12345&status=UNKNOWN" ) -var testCases = []ChannelHandleTestCase{ +var incomingTestCases = []IncomingTestCase{ {Label: "Receive Valid", URL: receiveURL, Data: validReceive, ExpectedRespStatus: 200, ExpectedBodyContains: "-1", ExpectedMsgText: Sp("Hello"), ExpectedURN: "tel:+60124361111", ExpectedDate: time.Date(2016, 3, 30, 11, 33, 06, 0, time.UTC), ExpectedExternalID: "abc1234"}, @@ -45,26 +45,41 @@ var testCases = []ChannelHandleTestCase{ {Label: "Invalid Params", URL: receiveURL, Data: invalidParamsReceive, ExpectedRespStatus: 400, ExpectedBodyContains: "missing shortcode, longcode, from or msisdn parameters"}, {Label: "Invalid Address Params", URL: receiveURL, Data: invalidAddress, ExpectedRespStatus: 400, ExpectedBodyContains: "invalid to number [1515], expecting [2020]"}, - {Label: "Valid Status", URL: statusURL, Data: validStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, ExpectedMsgStatus: courier.MsgSent}, - {Label: "Wired Status", URL: statusURL, Data: processingStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"W"`, ExpectedMsgStatus: courier.MsgWired}, + { + Label: "Valid Status", + URL: statusURL, + Data: validStatus, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"S"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "12345", Status: courier.MsgStatusSent}, + }, + }, + { + Label: "Wired Status", + URL: statusURL, + Data: processingStatus, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"W"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "12345", Status: courier.MsgStatusWired}, + }, + }, {Label: "Unknown Status", URL: statusURL, Data: unknownStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `ignoring unknown status 'UNKNOWN'`}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) -} - -func BenchmarkHandler(b *testing.B) { - RunChannelBenchmarks(b, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), incomingTestCases) } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ - {Label: "Plain Send", +var outgoingTestCases = []OutgoingTestCase{ + { + Label: "Plain Send", MsgText: "Simple Message ☺", MsgURN: "tel:+250788383383", ExpectedMsgStatus: "W", @@ -76,8 +91,10 @@ var defaultSendTestCases = []ChannelSendTestCase{ "Accept": "application/json", }, ExpectedRequestBody: `{"user":"Username","pass":"Password","to":"250788383383","text":"Simple Message ☺","from":"macro","servid":"service-id","type":"5"}`, - SendPrep: setSendURL}, - {Label: "Long Send", + SendPrep: setSendURL, + }, + { + Label: "Long Send", MsgText: "This is a longer message than 160 characters and will cause us to split it into two separate parts, isn't that right but it is even longer than before I say, I need to keep adding more things to make it work", MsgURN: "tel:+250788383383", ExpectedMsgStatus: "W", @@ -89,8 +106,10 @@ var defaultSendTestCases = []ChannelSendTestCase{ "Accept": "application/json", }, ExpectedRequestBody: `{"user":"Username","pass":"Password","to":"250788383383","text":"I need to keep adding more things to make it work","from":"macro","servid":"service-id","type":"0"}`, - SendPrep: setSendURL}, - {Label: "Send Attachment", + SendPrep: setSendURL, + }, + { + Label: "Send Attachment", MsgText: "My pic!", MsgURN: "tel:+250788383383", MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, @@ -103,8 +122,10 @@ var defaultSendTestCases = []ChannelSendTestCase{ "Accept": "application/json", }, ExpectedRequestBody: `{"user":"Username","pass":"Password","to":"250788383383","text":"My pic!\nhttps://foo.bar/image.jpg","from":"macro","servid":"service-id","type":"0"}`, - SendPrep: setSendURL}, - {Label: "No External Id", + SendPrep: setSendURL, + }, + { + Label: "No External Id", MsgText: "No External ID", MsgURN: "tel:+250788383383", ExpectedMsgStatus: "E", @@ -116,21 +137,24 @@ var defaultSendTestCases = []ChannelSendTestCase{ "Accept": "application/json", }, ExpectedRequestBody: `{"user":"Username","pass":"Password","to":"250788383383","text":"No External ID","from":"macro","servid":"service-id","type":"0"}`, - SendPrep: setSendURL}, - {Label: "Error Sending", + SendPrep: setSendURL, + }, + { + Label: "Error Sending", MsgText: "Error Message", MsgURN: "tel:+250788383383", ExpectedMsgStatus: "E", MockResponseBody: `{ "error": "failed" }`, MockResponseStatus: 401, ExpectedRequestBody: `{"user":"Username","pass":"Password","to":"250788383383","text":"Error Message","from":"macro","servid":"service-id","type":"0"}`, - SendPrep: setSendURL}, + SendPrep: setSendURL, + }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MK", "2020", "US", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username", configMacrokioskSenderID: "macro", @@ -138,5 +162,5 @@ func TestSending(t *testing.T) { }, ) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), outgoingTestCases, []string{"Password"}, nil) } diff --git a/handlers/mblox/mblox.go b/handlers/mblox/handler.go similarity index 85% rename from handlers/mblox/mblox.go rename to handlers/mblox/handler.go index 86dd56458..3d1254c6d 100644 --- a/handlers/mblox/mblox.go +++ b/handlers/mblox/handler.go @@ -50,13 +50,13 @@ type eventPayload struct { ReceivedAt string `json:"received_at"` } -var statusMapping = map[string]courier.MsgStatusValue{ - "Delivered": courier.MsgDelivered, - "Dispatched": courier.MsgSent, - "Aborted": courier.MsgFailed, - "Rejected": courier.MsgFailed, - "Failed": courier.MsgFailed, - "Expired": courier.MsgFailed, +var statusMapping = map[string]courier.MsgStatus{ + "Delivered": courier.MsgStatusDelivered, + "Dispatched": courier.MsgStatusSent, + "Aborted": courier.MsgStatusFailed, + "Rejected": courier.MsgStatusFailed, + "Failed": courier.MsgStatusFailed, + "Expired": courier.MsgStatusFailed, } // receiveEvent is our HTTP handler function for incoming messages @@ -74,7 +74,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, payload.BatchID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, payload.BatchID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } else if payload.Type == "mo_text" { @@ -99,7 +99,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h msg := h.Backend().NewIncomingMsg(channel, urn, payload.Body, payload.ID, clog).WithReceivedOn(date.UTC()) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("not handled, unknown type: %s", payload.Type)) @@ -113,14 +113,14 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "") if username == "" || password == "" { return nil, fmt.Errorf("Missing username or password for MB channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for _, part := range parts { payload := &mtPayload{} @@ -141,7 +141,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", password)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -151,7 +151,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return status, fmt.Errorf("unable to parse response body from MBlox") } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) status.SetExternalID(externalID) } diff --git a/handlers/mblox/mblox_test.go b/handlers/mblox/handler_test.go similarity index 88% rename from handlers/mblox/mblox_test.go rename to handlers/mblox/handler_test.go index bd1b21dc8..cceb910bc 100644 --- a/handlers/mblox/mblox_test.go +++ b/handlers/mblox/handler_test.go @@ -11,7 +11,7 @@ import ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MB", "2020", "BR", map[string]interface{}{"username": "zv-username", "password": "zv-password"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MB", "2020", "BR", map[string]any{"username": "zv-username", "password": "zv-password"}), } var ( @@ -61,7 +61,7 @@ var ( }` ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ {Label: "Receive Valid", URL: receiveURL, Data: validReceive, ExpectedRespStatus: 200, ExpectedBodyContains: "Message Accepted", ExpectedMsgText: Sp("Hello World"), ExpectedURN: "tel:+12067799294", ExpectedDate: time.Date(2016, 3, 30, 19, 33, 06, 643000000, time.UTC), ExpectedExternalID: "OzQ5UqIOdoY8"}, @@ -69,13 +69,20 @@ var testCases = []ChannelHandleTestCase{ {Label: "Receive Missing Params", URL: receiveURL, Data: missingParamsRecieve, ExpectedRespStatus: 400, ExpectedBodyContains: "missing one of 'id', 'from', 'to', 'body' or 'received_at' in request body"}, {Label: "Invalid URN", URL: receiveURL, Data: invalidURN, ExpectedRespStatus: 400, ExpectedBodyContains: "phone number supplied is not a number"}, - {Label: "Status Valid", URL: receiveURL, Data: validStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered}, + { + Label: "Status Valid", + URL: receiveURL, + Data: validStatus, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusDelivered}}, + }, {Label: "Status Unknown", URL: receiveURL, Data: unknownStatus, ExpectedRespStatus: 400, ExpectedBodyContains: `unknown status 'INVALID'`}, {Label: "Status Missing Batch ID", URL: receiveURL, Data: missingBatchID, ExpectedRespStatus: 400, ExpectedBodyContains: "missing one of 'batch_id' or 'status' in request body"}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -83,11 +90,11 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ {Label: "Plain Send", MsgText: "Simple Message ☺", MsgURN: "tel:+250788383383", @@ -155,14 +162,14 @@ var defaultSendTestCases = []ChannelSendTestCase{ SendPrep: setSendURL}, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MB", "2020", "US", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username", }, ) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/messagebird/handler.go b/handlers/messagebird/handler.go new file mode 100644 index 000000000..401e1a008 --- /dev/null +++ b/handlers/messagebird/handler.go @@ -0,0 +1,316 @@ +package messagebird + +/* + * Handler for MessageBird + */ +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "log/slog" + "strconv" + + "fmt" + + "net/http" + "time" + + "github.com/buger/jsonparser" + "github.com/golang-jwt/jwt/v5" + "github.com/nyaruka/courier" + "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/gocommon/jsonx" + "github.com/nyaruka/gocommon/urns" +) + +var ( + smsURL = "https://rest.messagebird.com/messages" + mmsURL = "https://rest.messagebird.com/mms" + signatureHeader = "Messagebird-Signature-Jwt" + maxRequestBodyBytes int64 = 1024 * 1024 + // error code messagebird returns when a contact has sent "stop" + errorStopped = 103 +) + +type Message struct { + Recipients []string `json:"recipients"` + Reference string `json:"reference,omitempty"` + Originator string `json:"originator"` + Subject string `json:"subject,omitempty"` + Body string `json:"body,omitempty"` + MediaURLs []string `json:"mediaUrls,omitempty"` +} + +type ReceivedStatus struct { + ID string `schema:"id"` + Reference string `schema:"reference"` + Recipient string `schema:"recipient,required"` + Status string `schema:"status,required"` + StatusReason string `schema:"statusReason"` + StatusDatetime time.Time `schema:"statusDatetime"` + StatusErrorCode int `schema:"statusErrorCode"` +} + +var statusMapping = map[string]courier.MsgStatus{ + "scheduled": courier.MsgStatusSent, + "delivery_failed": courier.MsgStatusFailed, + "sent": courier.MsgStatusSent, + "buffered": courier.MsgStatusSent, + "delivered": courier.MsgStatusDelivered, + "expired": courier.MsgStatusFailed, +} + +type ReceivedMessage struct { + ID string `json:"id"` + Recipient string `json:"recipient"` + Originator string `json:"originator"` + Body string `json:"body"` + CreatedDatetime string `json:"createdDatetime"` + MediaURLs []string `json:"mediaUrls"` + MMS bool `json:"mms"` +} + +func init() { + courier.RegisterHandler(newHandler("MBD", "Messagebird", true)) +} + +type handler struct { + handlers.BaseHandler + validateSignatures bool +} + +func newHandler(channelType courier.ChannelType, name string, validateSignatures bool) courier.ChannelHandler { + return &handler{handlers.NewBaseHandler(courier.ChannelType("MBD"), "Messagebird"), validateSignatures} +} + +// 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.ChannelLogTypeMsgReceive, handlers.JSONPayload(h, h.receiveMessage)) + s.AddHandlerRoute(h, http.MethodGet, "status", courier.ChannelLogTypeMsgStatus, h.receiveStatus) + + return nil +} + +func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, error) { + + // get our params + receivedStatus := &ReceivedStatus{} + err := handlers.DecodeAndValidateForm(receivedStatus, r) + if err != nil { + return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "no msg status, ignoring") + } + + msgStatus, found := statusMapping[receivedStatus.Status] + if !found { + return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("unknown status '%s', must be one of 'queued', 'failed', 'sent', 'delivered', or 'undelivered'", receivedStatus.Status)) + } + + // if the message id was passed explicitely, use that + var status courier.StatusUpdate + if receivedStatus.Reference != "" { + msgID, err := strconv.ParseInt(receivedStatus.Reference, 10, 64) + if err != nil { + slog.Error("error converting Messagebird status id to integer", "error", err, "id", receivedStatus.Reference) + } else { + status = h.Backend().NewStatusUpdate(channel, courier.MsgID(msgID), msgStatus, clog) + } + } + + // if we have no status, then build it from the external (messagebird) id + if status == nil { + status = h.Backend().NewStatusUpdateByExternalID(channel, receivedStatus.ID, msgStatus, clog) + } + + if receivedStatus.StatusErrorCode == errorStopped { + urn, err := urns.NewTelURNForCountry(receivedStatus.Recipient, "") + if err != nil { + return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + } + // create a stop channel event + channelEvent := h.Backend().NewChannelEvent(channel, courier.EventTypeStopContact, urn, clog) + err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) + if err != nil { + return nil, err + } + clog.Error(courier.ErrorExternal(fmt.Sprint(receivedStatus.StatusErrorCode), "Contact has sent 'stop'")) + } + + return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) +} + +func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, payload *ReceivedMessage, clog *courier.ChannelLog) ([]courier.Event, error) { + err := h.validateSignature(channel, r) + if err != nil { + return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + } + + // no message? ignore this + if payload.Body == "" && !payload.MMS { + return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "Ignoring request, no message") + } + + // create our date from the timestamp + standardDateLayout := "2006-01-02T15:04:05+00:00" + date, err := time.Parse(standardDateLayout, payload.CreatedDatetime) + if err != nil { + //try shortcode format + shortCodeDateLayout := "20060102150405" + date, err = time.Parse(shortCodeDateLayout, payload.CreatedDatetime) + if err != nil { + return nil, fmt.Errorf("unable to parse date '%s': %v", payload.CreatedDatetime, err) + } + } + + // create our URN + urn, err := handlers.StrictTelForCountry(payload.Originator, channel.Country()) + if err != nil { + return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) + } + text := payload.Body + + // build our msg + msg := h.Backend().NewIncomingMsg(channel, urn, text, payload.ID, clog).WithReceivedOn(date.UTC()) + + // process any attached media + if payload.MMS { + for _, mediaURL := range payload.MediaURLs { + msg.WithAttachment(mediaURL) + } + } + // and finally write our message + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) +} + +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + + authToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") + if authToken == "" { + return nil, fmt.Errorf("missing config 'auth_token' for Messagebird channel") + } + + user := msg.URN().Path() + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) + + // create base payload + payload := &Message{ + Recipients: []string{user}, + Originator: msg.Channel().Address(), + Reference: msg.ID().String(), + } + // build message payload + + if len(msg.Text()) > 0 { + payload.Body = msg.Text() + } + sendUrl := "" + if len(msg.Attachments()) > 0 { + sendUrl = mmsURL + } else { + sendUrl = smsURL + } + for _, attachment := range msg.Attachments() { + _, mediaURL := handlers.SplitAttachment(attachment) + payload.MediaURLs = append(payload.MediaURLs, mediaURL) + } + + jsonBody := jsonx.MustMarshal(payload) + + req, err := http.NewRequest(http.MethodPost, sendUrl, bytes.NewReader(jsonBody)) + + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + var bearer = "AccessKey " + authToken + req.Header.Set("Authorization", bearer) + + resp, respBody, err := h.RequestHTTP(req, clog) + if err != nil || resp.StatusCode/100 != 2 { + return status, nil + } + status.SetStatus(courier.MsgStatusWired) + + externalID, err := jsonparser.GetString(respBody, "id") + if err != nil { + clog.Error(courier.ErrorResponseUnparseable("JSON")) + return status, nil + } + status.SetExternalID(externalID) + return status, nil +} + +func verifyToken(tokenString string, secret string) (jwt.MapClaims, error) { + // Parse the token with the provided secret to get the claims + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { + // Validate the signing method + // We only allow HS256 + // ref: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + // Return the secret used to sign the token + return []byte(secret), nil + }) + + if err != nil { + return nil, err + } + + // Check if the token is valid + if token.Valid { + tokenClaims := token.Claims.(jwt.MapClaims) + return tokenClaims, nil + } + + return nil, fmt.Errorf("Invalid token or missing payload_hash claim") +} + +func calculateSignature(body []byte) string { + preHashSignature := sha256.Sum256(body) + return hex.EncodeToString(preHashSignature[:]) +} + +func (h *handler) validateSignature(c courier.Channel, r *http.Request) error { + if !h.validateSignatures { + return nil + } + headerSignature := r.Header.Get(signatureHeader) + if headerSignature == "" { + return fmt.Errorf("missing request signature") + } + configsecret := c.StringConfigForKey(courier.ConfigSecret, "") + if configsecret == "" { + return fmt.Errorf("missing configsecret") + } + verifiedToken, err := verifyToken(headerSignature, configsecret) + if err != nil { + return err + } + CalledURL := fmt.Sprintf("https://%s%s", c.CallbackDomain(h.Server().Config().Domain), r.URL.Path) + expectedURLHash := calculateSignature([]byte(CalledURL)) + URLHash := verifiedToken["url_hash"].(string) + + if !hmac.Equal([]byte(expectedURLHash), []byte(URLHash)) { + return fmt.Errorf("invalid request signature, signature doesn't match expected signature for URL.") + } + + if verifiedToken["payload_hash"] != nil { + payloadHash := verifiedToken["payload_hash"].(string) + + body, err := handlers.ReadBody(r, maxRequestBodyBytes) + if err != nil { + return fmt.Errorf("unable to read request body: %s", err) + } + + expectedSignature := calculateSignature(body) + if !hmac.Equal([]byte(expectedSignature), []byte(payloadHash)) { + return fmt.Errorf("invalid request signature, signature doesn't match expected signature for body.") + } + } + + return nil +} diff --git a/handlers/messagebird/handler_test.go b/handlers/messagebird/handler_test.go new file mode 100644 index 000000000..51745bcb0 --- /dev/null +++ b/handlers/messagebird/handler_test.go @@ -0,0 +1,304 @@ +package messagebird + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/nyaruka/courier" + . "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/test" +) + +var testChannels = []courier.Channel{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MBD", "18005551212", "US", map[string]any{ + "secret": "my_super_secret", // secret key to sign for sig + "auth_token": "authtoken", //API bearer token + }), +} + +const ( + receiveURL = "/c/mbd/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive" + validReceive = `{"receiver":"18005551515","sender":"188885551515","message":"Test again","date":1690386569,"date_utc":1690418969,"reference":"1","id":"b6aae1b5dfb2427a8f7ea6a717ba31a9","message_id":"3b53c137369242138120d6b0b2122607","recipient":"18005551515","originator":"188885551515","body":"Test 3","createdDatetime":"2023-07-27T00:49:29+00:00","mms":false}` + validReceiveShortCode = `{"receiver":"18005551515","sender":"188885551515","message":"Test again","date":1690386569,"date_utc":1690418969,"reference":"1","id":"b6aae1b5dfb2427a8f7ea6a717ba31a9","message_id":"3b53c137369242138120d6b0b2122607","recipient":"18005551515","originator":"188885551515","body":"Test 3","createdDatetime":"20230727004929","mms":false}` + validReceiveMMS = `{"receiver":"18005551515","sender":"188885551515","message":"Test again","date":1690386569,"date_utc":1690418969,"reference":"1","id":"b6aae1b5dfb2427a8f7ea6a717ba31a9","message_id":"3b53c137369242138120d6b0b2122607","recipient":"18005551515","originator":"188885551515","mediaURLs":["https://foo.bar/image.jpg"],"createdDatetime":"2023-07-27T00:49:29+00:00","mms":true}` + statusBaseURL = "/c/mbd/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?datacoding=plain&id=b6aae1b5dfb2427a8f7ea6a717ba31a9&mccmnc=310010&messageLength=4&messagePartCount=1&ported=0&price%5Bamount%5D=0.000&price%5Bcurrency%5D=USD&recipient=188885551515&reference=26&statusDatetime=2023-07-28T17%3A57%3A12%2B00%3A00" + validSecret = "my_super_secret" + validResponse = `{"id":"efa6405d518d4c0c88cce11f7db775fb","href":"https://rest.messagebird.com/mms/efa6405d518d4c0c88cce11f7db775fb","direction":"mt","originator":"+188885551515","subject":"Great logo","body":"Hi! Please have a look at this very nice logo of this cool company.","reference":"the-customers-reference","mediaUrls":["https://www.messagebird.com/assets/images/og/messagebird.gif"],"scheduledDatetime":null,"createdDatetime":"2017-09-01T10:00:00+00:00","recipients":{"totalCount":1,"totalSentCount":1,"totalDeliveredCount":0,"totalDeliveryFailedCount":0,"items":[{"recipient":18005551515,"status":"sent","statusDatetime":"2017-09-01T10:00:00+00:00"}]}}` + invalidSecret = "bad_secret" +) + +func addValidSignature(r *http.Request) { + body, _ := ReadBody(r, maxRequestBodyBytes) + bodysig := calculateSignature(body) + urlsig := calculateSignature([]byte("https://localhost" + r.URL.Path)) + t := jwt.NewWithClaims(jwt.SigningMethodHS256, + jwt.MapClaims{ + "iss": "MessageBird", + "nbf": 1690306305, + "jti": "e92cf079-362d-4813-ab40-bbdd938bdc6d", + "payload_hash": bodysig, + "url_hash": urlsig, + }) + + signedJWT, _ := t.SignedString([]byte(validSecret)) + r.Header.Set(signatureHeader, signedJWT) +} + +func addInvalidSignature(r *http.Request) { + body, _ := ReadBody(r, maxRequestBodyBytes) + bodysig := calculateSignature(body) + urlsig := calculateSignature([]byte("https://localhost" + r.URL.Path)) + t := jwt.NewWithClaims(jwt.SigningMethodHS256, + jwt.MapClaims{ + "iss": "MessageBird", + "nbf": 1690306305, + "jti": "e92cf079-362d-4813-ab40-bbdd938bdc6d", + "payload_hash": bodysig, + "url_hash": urlsig, + }) + + signedJWT, _ := t.SignedString([]byte(invalidSecret)) + r.Header.Set("Messagebird-Signature-Jwt", signedJWT) +} + +func addInvalidBodyHash(r *http.Request) { + body, _ := ReadBody(r, maxRequestBodyBytes) + bad_bytes := []byte("bad") + body = append(body, bad_bytes[:]...) + urlsig := calculateSignature([]byte("https://localhost" + r.URL.Path)) + bodysig := calculateSignature(body) + t := jwt.NewWithClaims(jwt.SigningMethodHS256, + jwt.MapClaims{ + "iss": "MessageBird", + "nbf": 1690306305, + "jti": "e92cf079-362d-4813-ab40-bbdd938bdc6d", + "payload_hash": bodysig, + "url_hash": urlsig, + }) + + signedJWT, _ := t.SignedString([]byte(validSecret)) + r.Header.Set("Messagebird-Signature-Jwt", signedJWT) +} + +var defaultReceiveTestCases = []IncomingTestCase{ + { + Label: "Receive Valid text w Signature", + Headers: map[string]string{"Content-Type": "application/json"}, + URL: receiveURL, + Data: validReceive, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Message Accepted", + ExpectedMsgText: Sp("Test 3"), + ExpectedURN: "tel:188885551515", + ExpectedDate: time.Date(2023, time.July, 27, 00, 49, 29, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid text w shortcode date", + Headers: map[string]string{"Content-Type": "application/json"}, + URL: receiveURL, + Data: validReceiveShortCode, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Message Accepted", + ExpectedMsgText: Sp("Test 3"), + ExpectedURN: "tel:188885551515", + ExpectedDate: time.Date(2023, time.July, 27, 00, 49, 29, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid w image w Signature", + Headers: map[string]string{"Content-Type": "application/json"}, + URL: receiveURL, + Data: validReceiveMMS, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Message Accepted", + ExpectedAttachments: []string{"https://foo.bar/image.jpg"}, + ExpectedURN: "tel:188885551515", + ExpectedDate: time.Date(2023, time.July, 27, 00, 49, 29, 0, time.UTC), + PrepRequest: addValidSignature, + }, + { + Label: "Bad JWT Signature", + Headers: map[string]string{"Content-Type": "application/json"}, + URL: receiveURL, + Data: validReceive, + ExpectedRespStatus: 400, + ExpectedBodyContains: `{"message":"Error","data":[{"type":"error","error":"token signature is invalid: signature is invalid"}]}`, + PrepRequest: addInvalidSignature, + }, + { + Label: "Missing JWT Signature Header", + Headers: map[string]string{"Content-Type": "application/json"}, + URL: receiveURL, + Data: validReceive, + ExpectedRespStatus: 400, + ExpectedBodyContains: `{"message":"Error","data":[{"type":"error","error":"missing request signature"}]}`, + }, + { + Label: "Receive Valid w Signature but non-matching body hash", + Headers: map[string]string{"Content-Type": "application/json"}, + URL: receiveURL, + Data: validReceive, + ExpectedRespStatus: 400, + ExpectedBodyContains: `{"message":"Error","data":[{"type":"error","error":"invalid request signature, signature doesn't match expected signature for body."}]}`, + PrepRequest: addInvalidBodyHash, + }, + { + Label: "Bad JSON", + Headers: map[string]string{"Content-Type": "application/json"}, + URL: receiveURL, + Data: "empty", + ExpectedRespStatus: 400, + ExpectedBodyContains: `{"message":"Error","data":[{"type":"error","error":"unable to parse request JSON: invalid character 'e' looking for beginning of value"}]}`, + PrepRequest: addValidSignature, + }, + { + Label: "Status Valid", + URL: statusBaseURL + "&status=sent", + ExpectedRespStatus: 200, + ExpectedStatuses: []ExpectedStatus{{MsgID: 26, Status: courier.MsgStatusSent}}, + }, + { + Label: "Status- Stop Received", + URL: statusBaseURL + "&status=delivery_failed&statusErrorCode=103", + ExpectedRespStatus: 200, + ExpectedStatuses: []ExpectedStatus{{MsgID: 26, Status: courier.MsgStatusFailed}}, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeStopContact, URN: "tel:188885551515"}, + }, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("103", "Contact has sent 'stop'")}, + }, + { + Label: "Receive Invalid Status", + URL: statusBaseURL + "&status=expiryttd", + ExpectedRespStatus: 400, + ExpectedBodyContains: `{"message":"Error","data":[{"type":"error","error":"unknown status 'expiryttd', must be one of 'queued', 'failed', 'sent', 'delivered', or 'undelivered'"}]}`, + }, +} + +func TestReceiving(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler("MBD", "Messagebird", true), defaultReceiveTestCases) +} + +func BenchmarkHandler(b *testing.B) { + RunChannelBenchmarks(b, testChannels, newHandler("MBD", "Messagebird", true), defaultReceiveTestCases) +} + +func setSmsSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { + smsURL = s.URL +} + +func setMmsSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { + mmsURL = s.URL +} + +var defaultSendTestCases = []OutgoingTestCase{ + { + Label: "Plain Send", + MsgText: "Simple Message ☺", + MsgURN: "tel:188885551515", + MockResponseBody: validResponse, + MockResponseStatus: 200, + ExpectedHeaders: map[string]string{"Content-Type": "application/json", "Authorization": "AccessKey authtoken"}, + ExpectedRequestBody: `{"recipients":["188885551515"],"reference":"10","originator":"18005551212","body":"Simple Message ☺"}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "efa6405d518d4c0c88cce11f7db775fb", + SendPrep: setSmsSendURL, + }, + { + Label: "Send with text and image", + MsgText: "Simple Message ☺", + MsgURN: "tel:188885551515", + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponseBody: validResponse, + MockResponseStatus: 200, + ExpectedHeaders: map[string]string{"Content-Type": "application/json", "Authorization": "AccessKey authtoken"}, + ExpectedRequestBody: `{"recipients":["188885551515"],"reference":"10","originator":"18005551212","body":"Simple Message ☺","mediaUrls":["https://foo.bar/image.jpg"]}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "efa6405d518d4c0c88cce11f7db775fb", + SendPrep: setMmsSendURL, + }, + { + Label: "Send with image only", + MsgURN: "tel:188885551515", + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, + MockResponseBody: validResponse, + MockResponseStatus: 200, + ExpectedHeaders: map[string]string{"Content-Type": "application/json", "Authorization": "AccessKey authtoken"}, + ExpectedRequestBody: `{"recipients":["188885551515"],"reference":"10","originator":"18005551212","mediaUrls":["https://foo.bar/image.jpg"]}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "efa6405d518d4c0c88cce11f7db775fb", + SendPrep: setMmsSendURL, + }, + { + Label: "Send with two images", + MsgURN: "tel:188885551515", + MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg", "image/jpeg:https://foo.bar/image2.jpg"}, + MockResponseBody: validResponse, + MockResponseStatus: 200, + ExpectedHeaders: map[string]string{"Content-Type": "application/json", "Authorization": "AccessKey authtoken"}, + ExpectedRequestBody: `{"recipients":["188885551515"],"reference":"10","originator":"18005551212","mediaUrls":["https://foo.bar/image.jpg","https://foo.bar/image2.jpg"]}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "efa6405d518d4c0c88cce11f7db775fb", + SendPrep: setMmsSendURL, + }, + { + Label: "Send with video only", + MsgURN: "tel:188885551515", + MsgAttachments: []string{"video/mp4:https://foo.bar/movie.mp4"}, + MockResponseBody: validResponse, + MockResponseStatus: 200, + ExpectedHeaders: map[string]string{"Content-Type": "application/json", "Authorization": "AccessKey authtoken"}, + ExpectedRequestBody: `{"recipients":["188885551515"],"reference":"10","originator":"18005551212","mediaUrls":["https://foo.bar/movie.mp4"]}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "efa6405d518d4c0c88cce11f7db775fb", + SendPrep: setMmsSendURL, + }, + { + Label: "Send with pdf", + MsgURN: "tel:188885551515", + MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, + MockResponseBody: validResponse, + MockResponseStatus: 200, + ExpectedHeaders: map[string]string{"Content-Type": "application/json", "Authorization": "AccessKey authtoken"}, + ExpectedRequestBody: `{"recipients":["188885551515"],"reference":"10","originator":"18005551212","mediaUrls":["https://foo.bar/document.pdf"]}`, + ExpectedMsgStatus: "W", + ExpectedExternalID: "efa6405d518d4c0c88cce11f7db775fb", + SendPrep: setMmsSendURL, + }, + { + Label: "500 on Send", + MsgText: "Simple Message ☺", + MsgURN: "tel:188885551515", + MockResponseBody: validResponse, + MockResponseStatus: 500, + ExpectedHeaders: map[string]string{"Content-Type": "application/json", "Authorization": "AccessKey authtoken"}, + ExpectedRequestBody: `{"recipients":["188885551515"],"reference":"10","originator":"18005551212","body":"Simple Message ☺"}`, + ExpectedMsgStatus: "E", + ExpectedExternalID: "", + SendPrep: setSmsSendURL, + }, + { + Label: "404 on Send", + MsgText: "Simple Message ☺", + MsgURN: "tel:188885551515", + MockResponseBody: validResponse, + MockResponseStatus: 404, + ExpectedHeaders: map[string]string{"Content-Type": "application/json", "Authorization": "AccessKey authtoken"}, + ExpectedRequestBody: `{"recipients":["188885551515"],"reference":"10","originator":"18005551212","body":"Simple Message ☺"}`, + ExpectedMsgStatus: "E", + ExpectedExternalID: "", + SendPrep: setSmsSendURL, + }, +} + +func TestOutgoing(t *testing.T) { + var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MBD", "18005551212", "US", map[string]any{ + "secret": "my_super_secret", // secret key to sign for sig + "auth_token": "authtoken", + }) + RunOutgoingTestCases(t, defaultChannel, newHandler("MBD", "Messagebird", false), defaultSendTestCases, []string{"my_super_secret", "authtoken"}, nil) +} diff --git a/handlers/messangi/messangi.go b/handlers/messangi/handler.go similarity index 89% rename from handlers/messangi/messangi.go rename to handlers/messangi/handler.go index 1f67d392a..8fb105c58 100644 --- a/handlers/messangi/messangi.go +++ b/handlers/messangi/handler.go @@ -60,7 +60,7 @@ type mtResponse struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { publicKey := msg.Channel().StringConfigForKey(configPublicKey, "") if publicKey == "" { return nil, fmt.Errorf("no public_key set for MG channel") @@ -81,7 +81,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no carrier_id set for MG channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for _, part := range parts { shortcode := strings.TrimPrefix(msg.Channel().Address(), "+") @@ -96,7 +96,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, err } - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -111,9 +111,9 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // we always get 204 on success if response.Status == "OK" { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } else { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) clog.Error(courier.ErrorResponseValueUnexpected("status", "OK")) break } diff --git a/handlers/messangi/messangi_test.go b/handlers/messangi/handler_test.go similarity index 89% rename from handlers/messangi/messangi_test.go rename to handlers/messangi/handler_test.go index 36693567f..5ea8f2ccb 100644 --- a/handlers/messangi/messangi_test.go +++ b/handlers/messangi/handler_test.go @@ -17,7 +17,7 @@ const ( receiveURL = "/c/mg/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -34,19 +34,19 @@ var testCases = []ChannelHandleTestCase{ ExpectedBodyContains: "required field 'mobile'"}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -96,14 +96,14 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MG", "2020", "JM", - map[string]interface{}{ + map[string]any{ "public_key": "my-public-key", "private_key": "my-private-key", "instance_id": 7, "carrier_id": 2, }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"my-private-key"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"my-private-key"}, nil) } diff --git a/handlers/meta/facebook_test.go b/handlers/meta/facebook_test.go new file mode 100644 index 000000000..c6fdb6f34 --- /dev/null +++ b/handlers/meta/facebook_test.go @@ -0,0 +1,661 @@ +package meta + +import ( + "context" + "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/urns" + "github.com/stretchr/testify/assert" +) + +var facebookTestChannels = []courier.Channel{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "FBA", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}), +} + +var facebookIncomingTests = []IncomingTestCase{ + { + Label: "Receive Message FBA", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/hello_msg.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/hello_msg.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid request signature", + PrepRequest: addInvalidSignature, + }, + { + Label: "No Duplicate Receive Message", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/duplicate_msg.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/attachment.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/location_attachment.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/thumbs_up.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/referral_optin_user_ref.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:ref:optin_user_ref", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"referrer_id": "optin_ref"}}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Receive OptIn", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/referral_optin.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"referrer_id": "optin_ref"}}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Notification Messages OptIn", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/notification_messages_optin.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeOptIn, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"title": "Bird Facts", "payload": "3456"}}, + }, + ExpectedURNAuthTokens: map[urns.URN]map[string]string{"facebook:5678": {"optin:3456": "12345678901234567890"}}, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Notification Messages OptOut", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/notification_messages_optout.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeOptOut, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"title": "Bird Facts", "payload": "3456"}}, + }, + ExpectedURNAuthTokens: map[urns.URN]map[string]string{"facebook:5678": {}}, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Get Started", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/postback_get_started.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeNewConversation, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"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", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"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/postback_referral.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"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"`, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"referrer_id": "referral id", "source": "referral source", "type": "referral type", "ad_id": "ad id"}}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Referral timestamp seconds", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/referral_seconds.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"referrer_id":"referral id"`, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeReferral, URN: "facebook:5678", Time: time.Date(2023, 12, 3, 10, 25, 11, 0, time.UTC), Extra: map[string]string{"referrer_id": "referral id", "source": "referral source", "type": "referral type", "ad_id": "ad id"}}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Fallback Attachment", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/fallback.json")), + ExpectedRespStatus: 200, + ExpectedEvents: []ExpectedEvent{}, + PrepRequest: addValidSignature, + }, + { + Label: "Receive DLR", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/dlr.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedStatuses: []ExpectedStatus{{ExternalID: "mid.1458668856218:ed81099e15d3f4f233", Status: courier.MsgStatusDelivered}}, + PrepRequest: addValidSignature, + }, + { + Label: "Different Page", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/different_page.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"data":[]`, + PrepRequest: addValidSignature, + }, + { + Label: "Echo", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/echo.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `ignoring echo`, + PrepRequest: addValidSignature, + }, + { + Label: "Not Page", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/not_page.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/no_entries.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/no_messaging_entries.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + PrepRequest: addValidSignature, + }, + { + Label: "Unknown Messaging Entry", + URL: "/c/fba/receive", + Data: string(test.ReadFile("./testdata/fba/unknown_messaging_entry.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/invalid_urn.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid facebook id", + PrepRequest: addValidSignature, + }, +} + +func TestFacebookIncoming(t *testing.T) { + graphURL = createMockGraphAPI().URL + + RunIncomingTestCases(t, facebookTestChannels, newHandler("FBA", "Facebook"), facebookIncomingTests) +} + +func TestFacebookDescribeURN(t *testing.T) { + fbGraph := buildMockFBGraphFBA(facebookIncomingTests) + defer fbGraph.Close() + + channel := facebookTestChannels[0] + handler := newHandler("FBA", "Facebook") + handler.Initialize(test.NewMockServer(courier.NewConfig(), test.NewMockBackend())) + 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 TestFacebookVerify(t *testing.T) { + RunIncomingTestCases(t, facebookTestChannels, newHandler("FBA", "Facebook"), []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, + }, + }) +} + +// 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.MsgOut) { + sendURL = s.URL + graphURL = s.URL +} + +var facebookOutgoingTests = []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 broadcast with opt-in auth token", + MsgText: "Simple Message", + MsgURN: "facebook:12345", + MsgURNAuth: "345678", + MsgOrigin: courier.MsgOriginBroadcast, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"notification_messages_token":"345678"},"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: "Opt-in request", + MsgURN: "facebook:12345", + MsgOptIn: &courier.OptInReference{ID: 3456, Name: "Joke Of The Day"}, + MockResponseBody: `{"message_id": "mid.133"}`, + MockResponseStatus: 200, + ExpectedRequestBody: `{"messaging_type":"UPDATE","recipient":{"id":"12345"},"message":{"attachment":{"type":"template","payload":{"template_type":"notification_messages","title":"Joke Of The Day","payload":"3456"}}}}`, + 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, + }, +} + +func TestFacebookOutgoing(t *testing.T) { + // shorter max msg length for testing + maxMsgLength = 100 + + var channel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "FBA", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}) + + checkRedacted := []string{"wac_admin_system_user_token", "missing_facebook_app_secret", "missing_facebook_webhook_secret", "a123"} + + RunOutgoingTestCases(t, channel, newHandler("FBA", "Facebook"), facebookOutgoingTests, 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 TestFacebookBuildAttachmentRequest(t *testing.T) { + mb := test.NewMockBackend() + s := courier.NewServer(courier.NewConfig(), mb) + + handler := &handler{NewBaseHandler(courier.ChannelType("FBA"), "Facebook", DisableUUIDRouting())} + handler.Initialize(s) + req, _ := handler.BuildAttachmentRequest(context.Background(), mb, facebookTestChannels[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 createMockGraphAPI() *httptest.Server { + return 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 + } + + // valid token + w.Write([]byte(`{"url": "https://foo.bar/attachmentURL"}`)) + })) +} + +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", http.StatusForbidden) + } + + // 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 +} diff --git a/handlers/facebookapp/facebookapp.go b/handlers/meta/handlers.go similarity index 57% rename from handlers/facebookapp/facebookapp.go rename to handlers/meta/handlers.go index 968378687..a9d1670ae 100644 --- a/handlers/facebookapp/facebookapp.go +++ b/handlers/meta/handlers.go @@ -1,4 +1,4 @@ -package facebookapp +package meta import ( "bytes" @@ -17,24 +17,25 @@ import ( "github.com/buger/jsonparser" "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/handlers/meta/messenger" + "github.com/nyaruka/courier/handlers/meta/whatsapp" "github.com/nyaruka/courier/utils" + "github.com/nyaruka/gocommon/jsonx" "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/" + sendURL = "https://graph.facebook.com/v17.0/me/messages" + graphURL = "https://graph.facebook.com/v17.0/" signatureHeader = "X-Hub-Signature-256" maxRequestBodyBytes int64 = 1024 * 1024 // max for the body - maxMsgLengthIG = 1000 - maxMsgLengthFBA = 2000 - maxMsgLengthWAC = 4096 + maxMsgLength = 1000 // Sticker ID substitutions stickerIDToEmoji = map[int64]string{ @@ -61,25 +62,14 @@ const ( payloadKey = "payload" ) -var waStatusMapping = map[string]courier.MsgStatusValue{ - "sent": courier.MsgSent, - "delivered": courier.MsgDelivered, - "read": courier.MsgDelivered, - "failed": courier.MsgFailed, -} - -var waIgnoreStatuses = map[string]bool{ - "deleted": true, -} - -func newHandler(channelType courier.ChannelType, name string, useUUIDRoutes bool) courier.ChannelHandler { - return &handler{handlers.NewBaseHandlerWithParams(channelType, name, useUUIDRoutes, []string{courier.ConfigAuthToken})} +func newHandler(channelType courier.ChannelType, name string) courier.ChannelHandler { + return &handler{handlers.NewBaseHandler(channelType, name, handlers.DisableUUIDRouting(), handlers.WithRedactConfigKeys(courier.ConfigAuthToken))} } func init() { - courier.RegisterHandler(newHandler("IG", "Instagram", false)) - courier.RegisterHandler(newHandler("FBA", "Facebook", false)) - courier.RegisterHandler(newHandler("WAC", "WhatsApp Cloud", false)) + courier.RegisterHandler(newHandler("IG", "Instagram")) + courier.RegisterHandler(newHandler("FBA", "Facebook")) + courier.RegisterHandler(newHandler("WAC", "WhatsApp Cloud")) } @@ -95,203 +85,32 @@ func (h *handler) Initialize(s courier.Server) error { 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 moPayload struct { +// https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/components#notification-payload-object +// +// { +// "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 Notifications 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"` - 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"` - Contacts []struct { - Name struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - FormattedName string `json:"formatted_name"` - } `json:"name"` - Phones []struct { - Phone string `json:"phone"` - WaID string `json:"wa_id"` - Type string `json:"type"` - } `json:"phones"` - } `json:"contacts"` - 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"` - - MessagingFeedback *struct { - FeedbackScreens []struct { - ScreenID int `json:"screen_id"` - Questions map[string]FeedbackQuestion `json:"questions"` - } `json:"feedback_screens"` - } `json:"messaging_feedback"` - } `json:"messaging"` + ID string `json:"id"` + Time int64 `json:"time"` + Changes []whatsapp.Change `json:"changes"` // used by WhatsApp + Messaging []messenger.Messaging `json:"messaging"` // used by Facebook and Instgram } `json:"entry"` } @@ -321,7 +140,7 @@ func (h *handler) GetChannel(ctx context.Context, r *http.Request) (courier.Chan return nil, nil } - payload := &moPayload{} + payload := &Notifications{} err := handlers.DecodeAndValidateJSON(payload, r) if err != nil { return nil, err @@ -386,7 +205,7 @@ func (h *handler) receiveVerify(ctx context.Context, channel courier.Channel, w return nil, err } -func resolveMediaURL(mediaID string, token string, clog *courier.ChannelLog) (string, error) { +func (h *handler) resolveMediaURL(mediaID string, token string, clog *courier.ChannelLog) (string, error) { if token == "" { return "", fmt.Errorf("missing token for WAC channel") } @@ -400,7 +219,7 @@ func resolveMediaURL(mediaID string, token string, clog *courier.ChannelLog) (st //req.Header.Set("User-Agent", utils.HTTPUserAgent) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return "", errors.New("error resolving media URL") } @@ -410,7 +229,7 @@ func resolveMediaURL(mediaID string, token string, clog *courier.ChannelLog) (st } // 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) { +func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, payload *Notifications, clog *courier.ChannelLog) ([]courier.Event, error) { err := h.validateSignature(r) if err != nil { return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) @@ -427,12 +246,12 @@ func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w } var events []courier.Event - var data []interface{} + 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) + events, data, err = h.processWhatsAppPayload(ctx, channel, payload, w, r, clog) webhook := channel.ConfigForKey("webhook", nil) if webhook != nil { er := handlers.SendWebhooks(channel, r, webhook, clog) @@ -449,14 +268,14 @@ func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w 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, []interface{}, error) { +func (h *handler) processWhatsAppPayload(ctx context.Context, channel courier.Channel, payload *Notifications, 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) token := h.Server().Config().WhatsappAdminSystemUserToken // the list of data we will return in our response - data := make([]interface{}, 0, 2) + data := make([]any, 0, 2) seenMsgIDs := make(map[string]bool, 2) contactNames := make(map[string]string) @@ -483,7 +302,7 @@ func (h *handler) processCloudWhatsAppPayload(ctx context.Context, channel couri 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() + date := parseTimestamp(ts) urn, err := urns.NewWhatsAppURN(msg.From) if err != nil { @@ -501,21 +320,21 @@ func (h *handler) processCloudWhatsAppPayload(ctx context.Context, channel couri text = msg.Text.Body } else if msg.Type == "audio" && msg.Audio != nil { text = msg.Audio.Caption - mediaURL, err = resolveMediaURL(msg.Audio.ID, token, clog) + mediaURL, err = h.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) + mediaURL, err = h.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) + mediaURL, err = h.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) + mediaURL, err = h.resolveMediaURL(msg.Image.ID, token, clog) } else if msg.Type == "video" && msg.Video != nil { text = msg.Video.Caption - mediaURL, err = resolveMediaURL(msg.Video.ID, token, clog) + mediaURL, err = h.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" { @@ -564,9 +383,9 @@ func (h *handler) processCloudWhatsAppPayload(ctx context.Context, channel couri for _, status := range change.Value.Statuses { - msgStatus, found := waStatusMapping[status.Status] + msgStatus, found := whatsapp.StatusMapping[status.Status] if !found { - if waIgnoreStatuses[status.Status] { + if whatsapp.IgnoreStatuses[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)) @@ -578,15 +397,8 @@ func (h *handler) processCloudWhatsAppPayload(ctx context.Context, channel couri clog.Error(courier.ErrorExternal(strconv.Itoa(statusError.Code), statusError.Title)) } - event := h.Backend().NewMsgStatusForExternalID(channel, status.ID, msgStatus, clog) - err := h.Backend().WriteMsgStatus(ctx, event) - - // we don't know about this message, just tell them we ignored it - if err == courier.ErrMsgNotFound { - data = append(data, courier.NewInfoData(fmt.Sprintf("message id: %s not found, ignored", status.ID))) - continue - } - + event := h.Backend().NewStatusUpdateByExternalID(channel, status.ID, msgStatus, clog) + err := h.Backend().WriteStatusUpdate(ctx, event) if err != nil { return nil, nil, err } @@ -606,14 +418,14 @@ func (h *handler) processCloudWhatsAppPayload(ctx context.Context, channel couri 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, []interface{}, error) { +func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel courier.Channel, payload *Notifications, 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([]interface{}, 0, 2) + data := make([]any, 0, 2) seenMsgIDs := make(map[string]bool, 2) @@ -632,8 +444,7 @@ func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel c continue } - // create our date from the timestamp (they give us millis, arg is nanos) - date := time.Unix(0, msg.Timestamp*1000000).UTC() + date := parseTimestamp(msg.Timestamp) sender := msg.Sender.UserRef if sender == "" { @@ -656,26 +467,40 @@ func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel c } 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) + var event courier.ChannelEvent + + if msg.OptIn.Type == "notification_messages" { + eventType := courier.EventTypeOptIn + authToken := msg.OptIn.NotificationMessagesToken + + if msg.OptIn.NotificationMessagesStatus == "STOP_NOTIFICATIONS" { + eventType = courier.EventTypeOptOut + authToken = "" // so that we remove it } - } - event := h.Backend().NewChannelEvent(channel, courier.Referral, urn, clog).WithOccurredOn(date) + event = h.Backend().NewChannelEvent(channel, eventType, urn, clog). + WithOccurredOn(date). + WithExtra(map[string]string{titleKey: msg.OptIn.Title, payloadKey: msg.OptIn.Payload}). + WithURNAuthTokens(map[string]string{fmt.Sprintf("optin:%s", msg.OptIn.Payload): authToken}) + } else { - // build our extra - extra := map[string]interface{}{ - referrerIDKey: msg.OptIn.Ref, + // 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.EventTypeReferral, urn, clog). + WithOccurredOn(date). + WithExtra(map[string]string{referrerIDKey: msg.OptIn.Ref}) } - event = event.WithExtra(extra) err := h.Backend().WriteChannelEvent(ctx, event, clog) if err != nil { @@ -687,20 +512,17 @@ func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel c } else if msg.Postback != nil { // by default postbacks are treated as new conversations, unless we have referral information - eventType := courier.NewConversation + eventType := courier.EventTypeNewConversation if msg.Postback.Referral.Ref != "" { - eventType = courier.Referral + eventType = courier.EventTypeReferral } event := h.Backend().NewChannelEvent(channel, eventType, urn, clog).WithOccurredOn(date) // build our extra - extra := map[string]interface{}{ - titleKey: msg.Postback.Title, - payloadKey: msg.Postback.Payload, - } + extra := map[string]string{titleKey: msg.Postback.Title, payloadKey: msg.Postback.Payload} // add in referral information if we have it - if eventType == courier.Referral { + if eventType == courier.EventTypeReferral { extra[referrerIDKey] = msg.Postback.Referral.Ref extra[sourceKey] = msg.Postback.Referral.Source extra[typeKey] = msg.Postback.Referral.Type @@ -722,13 +544,10 @@ func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel c } else if msg.Referral != nil { // this is an incoming referral - event := h.Backend().NewChannelEvent(channel, courier.Referral, urn, clog).WithOccurredOn(date) + event := h.Backend().NewChannelEvent(channel, courier.EventTypeReferral, urn, clog).WithOccurredOn(date) // build our extra - extra := map[string]interface{}{ - sourceKey: msg.Referral.Source, - typeKey: msg.Referral.Type, - } + extra := map[string]string{sourceKey: msg.Referral.Source, typeKey: msg.Referral.Type} // add referrer id if present if msg.Referral.Ref != "" { @@ -762,22 +581,22 @@ func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel c } if msg.Message.IsDeleted { - h.Backend().DeleteMsgWithExternalID(ctx, channel, msg.Message.MID) + 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 we have a sticker ID, use that as our text if att.Type == "image" && att.Payload != nil && att.Payload.StickerID != 0 { text = stickerIDToEmoji[att.Payload.StickerID] } + if att.Type == "like_heart" { + text = "❤️" + } if att.Type == "location" { attachmentURLs = append(attachmentURLs, fmt.Sprintf("geo:%f,%f", att.Payload.Coordinates.Lat, att.Payload.Coordinates.Long)) @@ -785,18 +604,17 @@ func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel c 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 != "" { + if att.Payload != nil && att.Payload.URL != "" && att.Type != "fallback" { attachmentURLs = append(attachmentURLs, att.Payload.URL) } } - // if we have a story mention, skip and do not save any message - if has_story_mentions { + // if we have no text or accepted attachments, don't create a message + if text == "" && len(attachmentURLs) == 0 { continue } @@ -820,15 +638,8 @@ func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel c } else if msg.Delivery != nil { // this is a delivery report for _, mid := range msg.Delivery.MIDs { - event := h.Backend().NewMsgStatusForExternalID(channel, mid, courier.MsgDelivered, clog) - err := h.Backend().WriteMsgStatus(ctx, event) - - // we don't know about this message, just tell them we ignored it - if err == courier.ErrMsgNotFound { - data = append(data, courier.NewInfoData("message not found, ignored")) - continue - } - + event := h.Backend().NewStatusUpdateByExternalID(channel, mid, courier.MsgStatusDelivered, clog) + err := h.Backend().WriteStatusUpdate(ctx, event) if err != nil { return nil, nil, err } @@ -867,70 +678,17 @@ func (h *handler) processFacebookInstagramPayload(ctx context.Context, channel c 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.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, 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 h.sendWhatsAppMsg(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.MsgStatus, error) { +func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { // can't do anything without an access token accessToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") if accessToken == "" { @@ -938,7 +696,16 @@ func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, } isHuman := msg.Origin() == courier.MsgOriginChat || msg.Origin() == courier.MsgOriginTicket - payload := mtPayload{} + payload := &messenger.SendRequest{} + + // build our recipient + if msg.URN().IsFacebookRef() { + payload.Recipient.UserRef = msg.URN().FacebookRef() + } else if msg.URNAuth() != "" { + payload.Recipient.NotificationMessagesToken = msg.URNAuth() + } else { + payload.Recipient.ID = msg.URN().Path() + } if msg.Topic() != "" || isHuman { payload.MessagingType = "MESSAGE_TAG" @@ -957,19 +724,12 @@ func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, } } - // 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().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) isCustomerFeedbackTemplateMsg := strings.Contains(msg.Text(), "{customer_feedback_template}") @@ -1074,32 +834,28 @@ func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - _, _, err = handlers.RequestHTTP(req, clog) + _, _, err = h.RequestHTTP(req, clog) if err != nil { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil } + // Send each text segment and attachment separately. We send attachments first as otherwise quick replies get + // attached to attachment segments and are hidden when images load. + for _, part := range handlers.SplitMsg(msg, handlers.SplitOptions{MaxTextLen: maxMsgLength}) { + if part.Type == handlers.MsgPartTypeOptIn { + payload.Message.Attachment = &messenger.Attachment{} + payload.Message.Attachment.Type = "template" + payload.Message.Attachment.Payload.TemplateType = "notification_messages" + payload.Message.Attachment.Payload.Title = part.OptIn.Name + payload.Message.Attachment.Payload.Payload = fmt.Sprint(part.OptIn.ID) + payload.Message.Text = "" - msgParts := make([]string, 0) - if msg.Text() != "" { - if msg.Channel().ChannelType() == "IG" { - msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLengthIG) - } else { - msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLengthFBA) - } - - } - - // 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]) + } else if part.Type == handlers.MsgPartTypeAttachment { + payload.Message.Attachment = &messenger.Attachment{} + attType, attURL := handlers.SplitAttachment(part.Attachment) attType = strings.Split(attType, "/")[0] if attType == "application" { attType = "file" @@ -1108,25 +864,22 @@ func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, 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.Text = part.Text payload.Message.Attachment = nil } // include any quick replies on the last piece we send - if i == (len(msgParts)+len(msg.Attachments()))-1 { + if part.IsLast { for _, qr := range msg.QuickReplies() { - payload.Message.QuickReplies = append(payload.Message.QuickReplies, mtQuickReply{qr, qr, "text"}) + payload.Message.QuickReplies = append(payload.Message.QuickReplies, messenger.QuickReply{Title: qr, Payload: qr, ContentType: "text"}) } } else { payload.Message.QuickReplies = nil } - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, err - } + jsonBody := jsonx.MustMarshal(payload) req, err := http.NewRequest(http.MethodPost, msgURL.String(), bytes.NewReader(jsonBody)) if err != nil { @@ -1135,8 +888,8 @@ func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - _, respBody, _ := handlers.RequestHTTP(req, clog) - respPayload := &fbaMTResponse{} + _, respBody, _ := h.RequestHTTP(req, clog) + respPayload := &messenger.SendResponse{} err = json.Unmarshal(respBody, respPayload) if err != nil { clog.Error(courier.ErrorResponseUnparseable("JSON")) @@ -1154,7 +907,7 @@ func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, } // if this is our first message, record the external id - if i == 0 { + if part.IsFirst { status.SetExternalID(respPayload.ExternalID) if msg.URN().IsFacebookRef() { recipientID := respPayload.RecipientID @@ -1170,11 +923,11 @@ func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, clog.RawError(errors.Errorf("unable to make facebook urn from %s", recipientID)) } - contact, err := h.Backend().GetContact(ctx, msg.Channel(), msg.URN(), "", "", clog) + contact, err := h.Backend().GetContact(ctx, msg.Channel(), msg.URN(), nil, "", 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) + realURN, err := h.Backend().AddURNtoContact(ctx, msg.Channel(), contact, realIDURN, nil) if err != nil { clog.RawError(errors.Errorf("unable to add real facebook URN %s to contact with uuid %s", realURN.String(), contact.UUID())) } @@ -1182,7 +935,7 @@ func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, 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) + extURN, err := h.Backend().AddURNtoContact(ctx, msg.Channel(), contact, referralIDExtURN, nil) if err != nil { clog.RawError(errors.Errorf("unable to add URN %s to contact with uuid %s", extURN.String(), contact.UUID())) } @@ -1197,124 +950,13 @@ func (h *handler) sendFacebookInstagramMsg(ctx context.Context, msg courier.Msg, } // this was wired successfully - status.SetStatus(courier.MsgWired) + 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,omitempty"` - Image *wacMTMedia `json:"image,omitempty"` - Document *wacMTMedia `json:"document,omitempty"` - Video *wacMTMedia `json:"video,omitempty"` -} - -type wacComponent struct { - Type string `json:"type"` - SubType string `json:"sub_type,omitempty"` - Index string `json:"index,omitempty"` - 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"` - - Interactive *wacInteractive `json:"interactive,omitempty"` - - Template *wacTemplate `json:"template,omitempty"` -} - -type wacMTResponse struct { - Messages []*struct { - ID string `json:"id"` - } `json:"messages"` - Contacts []*struct { - Input string `json:"input,omitempty"` - WaID string `json:"wa_id,omitempty"` - } `json:"contacts,omitempty"` - 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.MsgStatus, error) { +func (h *handler) sendWhatsAppMsg(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { // can't do anything without an access token accessToken := h.Server().Config().WhatsappAdminSystemUserToken @@ -1326,23 +968,23 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, path, _ := url.Parse(fmt.Sprintf("/%s/messages", msg.Channel().Address())) wacPhoneURL := base.ResolveReference(path) - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + 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(), maxMsgLengthWAC) + msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) } qrs := msg.QuickReplies() - lang := getSupportedLanguage(msg.Locale()) + menuButton := handlers.GetText("Menu", msg.Locale()) - var payloadAudio wacMTPayload + var payloadAudio whatsapp.SendRequest for i := 0; i < len(msgParts)+len(msg.Attachments()); i++ { - payload := wacMTPayload{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path()} + payload := whatsapp.SendRequest{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path()} if len(msg.Attachments()) == 0 { // do we have a template? - templating, err := h.getTemplating(msg) + templating, err := whatsapp.GetTemplating(msg) if err != nil { return nil, errors.Wrapf(err, "unable to decode template: %s for channel: %s", string(msg.Metadata()), msg.Channel().UUID()) } @@ -1350,20 +992,20 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, hasTemplate = true payload.Type = "template" - template := wacTemplate{Name: templating.Template.Name, Language: &wacLanguage{Policy: "deterministic", Code: lang.code}} + template := whatsapp.Template{Name: templating.Template.Name, Language: &whatsapp.Language{Policy: "deterministic", Code: templating.Language}} payload.Template = &template - if len(templating.Variables) > 0 { - 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) + component := &whatsapp.Component{Type: "body"} + + for _, v := range templating.Variables { + component.Params = append(component.Params, &whatsapp.Param{Type: "text", Text: v}) } + template.Components = append(payload.Template.Components, component) + if len(msg.Attachments()) > 0 { - header := &wacComponent{Type: "header"} + header := &whatsapp.Component{Type: "header"} attType, attURL := handlers.SplitAttachment(msg.Attachments()[0]) attType = strings.Split(attType, "/")[0] @@ -1374,27 +1016,27 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, if attType == "application" { attType = "document" } - media := wacMTMedia{Link: parsedURL.String()} + media := whatsapp.Media{Link: parsedURL.String()} if attType == "image" { - header.Params = append(header.Params, &wacParam{Type: "image", Image: &media}) + header.Params = append(header.Params, &whatsapp.Param{Type: "image", Image: &media}) } else if attType == "video" { - header.Params = append(header.Params, &wacParam{Type: "video", Video: &media}) + header.Params = append(header.Params, &whatsapp.Param{Type: "video", Video: &media}) } else if attType == "document" { media.Filename, err = utils.BasePathForURL(attURL) if err != nil { return nil, err } - header.Params = append(header.Params, &wacParam{Type: "document", Document: &media}) + header.Params = append(header.Params, &whatsapp.Param{Type: "document", Document: &media}) } else { return nil, fmt.Errorf("unknown attachment mime type: %s", attType) } - payload.Template.Components = append(payload.Template.Components, header) + template.Components = append(payload.Template.Components, header) } } else { if i < (len(msgParts) + len(msg.Attachments()) - 1) { // this is still a msg part - text := &wacText{PreviewURL: false} + text := &whatsapp.Text{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 @@ -1406,13 +1048,13 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, payload.Type = "interactive" // We can use buttons if len(qrs) <= 3 { - interactive := wacInteractive{Type: "button", Body: struct { + interactive := whatsapp.Interactive{Type: "button", Body: struct { Text string "json:\"text\"" }{Text: msgParts[i-len(msg.Attachments())]}} - btns := make([]wacMTButton, len(qrs)) + btns := make([]whatsapp.Button, len(qrs)) for i, qr := range qrs { - btns[i] = wacMTButton{ + btns[i] = whatsapp.Button{ Type: "reply", } btns[i].Reply.ID = fmt.Sprint(i) @@ -1427,18 +1069,18 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, btns[i].Reply.Title = text } interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" + Button string "json:\"button,omitempty\"" + Sections []whatsapp.Section "json:\"sections,omitempty\"" + Buttons []whatsapp.Button "json:\"buttons,omitempty\"" }{Buttons: btns} payload.Interactive = &interactive } else if len(qrs) <= 10 { - interactive := wacInteractive{Type: "list", Body: struct { + interactive := whatsapp.Interactive{Type: "list", Body: struct { Text string "json:\"text\"" }{Text: msgParts[i-len(msg.Attachments())]}} - section := wacMTSection{ - Rows: make([]wacMTSectionRow, len(qrs)), + section := whatsapp.Section{ + Rows: make([]whatsapp.SectionRow, len(qrs)), } for i, qr := range qrs { var text string @@ -1449,17 +1091,17 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, } else { text = qr } - section.Rows[i] = wacMTSectionRow{ + section.Rows[i] = whatsapp.SectionRow{ ID: fmt.Sprint(i), Title: text, } } interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" - }{Button: lang.menu, Sections: []wacMTSection{ + Button string "json:\"button,omitempty\"" + Sections []whatsapp.Section "json:\"sections,omitempty\"" + Buttons []whatsapp.Button "json:\"buttons,omitempty\"" + }{Button: menuButton, Sections: []whatsapp.Section{ section, }} @@ -1469,7 +1111,7 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, } } else { // this is still a msg part - text := &wacText{PreviewURL: false} + text := &whatsapp.Text{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 @@ -1483,19 +1125,11 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, } else if i < len(msg.Attachments()) && (len(qrs) == 0 || len(qrs) > 3) { attType, attURL := handlers.SplitAttachment(msg.Attachments()[i]) attType = strings.Split(attType, "/")[0] - parsedURL, err := url.Parse(attURL) - if err != nil { - return status, err - } if attType == "application" { attType = "document" } payload.Type = attType - media := wacMTMedia{Link: parsedURL.String()} - if len(msgParts) == 1 && attType != "audio" && len(msg.Attachments()) == 1 && len(msg.QuickReplies()) == 0 { - media.Caption = msgParts[i] - hasCaption = true - } + media := whatsapp.Media{Link: attURL} if len(msgParts) == 1 && attType != "audio" && len(msg.Attachments()) == 1 && len(msg.QuickReplies()) == 0 { media.Caption = msgParts[i] @@ -1523,7 +1157,7 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, payload.Type = "interactive" // We can use buttons if len(qrs) <= 3 { - interactive := wacInteractive{Type: "button", Body: struct { + interactive := whatsapp.Interactive{Type: "button", Body: struct { Text string "json:\"text\"" }{Text: msgParts[i]}} @@ -1535,50 +1169,50 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, attType = "document" } if attType == "image" { - image := wacMTMedia{ + image := whatsapp.Media{ 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 string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *whatsapp.Media "json:\"video,omitempty\"" + Image *whatsapp.Media "json:\"image,omitempty\"" + Document *whatsapp.Media "json:\"document,omitempty\"" }{Type: "image", Image: &image} } else if attType == "video" { - video := wacMTMedia{ + video := whatsapp.Media{ 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 string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *whatsapp.Media "json:\"video,omitempty\"" + Image *whatsapp.Media "json:\"image,omitempty\"" + Document *whatsapp.Media "json:\"document,omitempty\"" }{Type: "video", Video: &video} } else if attType == "document" { filename, err := utils.BasePathForURL(attURL) if err != nil { return nil, err } - document := wacMTMedia{ + document := whatsapp.Media{ 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 string "json:\"type\"" + Text string "json:\"text,omitempty\"" + Video *whatsapp.Media "json:\"video,omitempty\"" + Image *whatsapp.Media "json:\"image,omitempty\"" + Document *whatsapp.Media "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) + payloadAudio = whatsapp.SendRequest{MessagingProduct: "whatsapp", RecipientType: "individual", To: msg.URN().Path(), Type: "audio", Audio: &whatsapp.Media{Link: attURL}} + _, err := h.requestWAC(payloadAudio, accessToken, status, wacPhoneURL, zeroIndex, clog) if err != nil { return status, nil } @@ -1588,9 +1222,9 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, } } - btns := make([]wacMTButton, len(qrs)) + btns := make([]whatsapp.Button, len(qrs)) for i, qr := range qrs { - btns[i] = wacMTButton{ + btns[i] = whatsapp.Button{ Type: "reply", } btns[i].Reply.ID = fmt.Sprint(i) @@ -1605,19 +1239,19 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, btns[i].Reply.Title = text } interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" + Button string "json:\"button,omitempty\"" + Sections []whatsapp.Section "json:\"sections,omitempty\"" + Buttons []whatsapp.Button "json:\"buttons,omitempty\"" }{Buttons: btns} payload.Interactive = &interactive } else if len(qrs) <= 10 { - interactive := wacInteractive{Type: "list", Body: struct { + interactive := whatsapp.Interactive{Type: "list", Body: struct { Text string "json:\"text\"" }{Text: msgParts[i-len(msg.Attachments())]}} - section := wacMTSection{ - Rows: make([]wacMTSectionRow, len(qrs)), + section := whatsapp.Section{ + Rows: make([]whatsapp.SectionRow, len(qrs)), } for i, qr := range qrs { var text string @@ -1628,17 +1262,17 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, } else { text = qr } - section.Rows[i] = wacMTSectionRow{ + section.Rows[i] = whatsapp.SectionRow{ ID: fmt.Sprint(i), Title: text, } } interactive.Action = &struct { - Button string "json:\"button,omitempty\"" - Sections []wacMTSection "json:\"sections,omitempty\"" - Buttons []wacMTButton "json:\"buttons,omitempty\"" - }{Button: lang.menu, Sections: []wacMTSection{ + Button string "json:\"button,omitempty\"" + Sections []whatsapp.Section "json:\"sections,omitempty\"" + Buttons []whatsapp.Button "json:\"buttons,omitempty\"" + }{Button: menuButton, Sections: []whatsapp.Section{ section, }} @@ -1648,7 +1282,7 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, } } else { // this is still a msg part - text := &wacText{PreviewURL: false} + text := &whatsapp.Text{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 @@ -1663,7 +1297,7 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, zeroIndex = true } - status, respPayload, err := requestWAC(payload, accessToken, status, wacPhoneURL, zeroIndex, clog) + respPayload, err := h.requestWAC(payload, accessToken, status, wacPhoneURL, zeroIndex, clog) if err != nil { return status, err } @@ -1675,13 +1309,14 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, if err != nil { return status, nil } - err = status.SetUpdatedURN(msg.URN(), toUpdateURN) + err = status.SetURNUpdate(msg.URN(), toUpdateURN) if err != nil { clog.Error(courier.ErrorResponseUnexpected("unable to update contact URN for a new based on wa_id")) } hasNewURN = true } } + if hasTemplate && len(msg.Attachments()) > 0 || hasCaption { break } @@ -1689,41 +1324,39 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg, return status, nil } -func requestWAC(payload wacMTPayload, accessToken string, status courier.MsgStatus, wacPhoneURL *url.URL, zeroIndex bool, clog *courier.ChannelLog) (courier.MsgStatus, *wacMTResponse, error) { - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, nil, err - } +func (h *handler) requestWAC(payload whatsapp.SendRequest, accessToken string, status courier.StatusUpdate, wacPhoneURL *url.URL, zeroIndex bool, clog *courier.ChannelLog) (*whatsapp.SendResponse, error) { + jsonBody := jsonx.MustMarshal(payload) req, err := http.NewRequest(http.MethodPost, wacPhoneURL.String(), bytes.NewReader(jsonBody)) if err != nil { - return nil, nil, err + 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{} + _, respBody, _ := h.RequestHTTP(req, clog) + respPayload := &whatsapp.SendResponse{} err = json.Unmarshal(respBody, respPayload) if err != nil { clog.Error(courier.ErrorResponseUnparseable("JSON")) - return status, nil, nil + return respPayload, nil } if respPayload.Error.Code != 0 { clog.Error(courier.ErrorExternal(strconv.Itoa(respPayload.Error.Code), respPayload.Error.Message)) - return status, nil, nil + return respPayload, nil } externalID := respPayload.Messages[0].ID if zeroIndex && externalID != "" { status.SetExternalID(externalID) } + // this was wired successfully - status.SetStatus(courier.MsgWired) - return status, respPayload, nil + status.SetStatus(courier.MsgStatusWired) + return respPayload, nil } // DescribeURN looks up URN metadata for new contacts @@ -1757,7 +1390,7 @@ func (h *handler) DescribeURN(ctx context.Context, channel courier.Channel, urn u.RawQuery = query.Encode() req, _ := http.NewRequest(http.MethodGet, u.String(), nil) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return nil, errors.New("unable to look up contact data") } @@ -1824,29 +1457,6 @@ func fbCalculateSignature(appSecret string, body []byte) (string, error) { 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 @@ -1865,110 +1475,10 @@ func (h *handler) BuildAttachmentRequest(ctx context.Context, b courier.Backend, 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 - } +func parseTimestamp(ts int64) time.Time { + // sometimes Facebook sends timestamps in seconds rather than milliseconds + if ts >= 1_000_000_000_000 { + return time.Unix(0, ts*1000000).UTC() } - 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 + return time.Unix(ts, 0).UTC() } diff --git a/handlers/meta/instagram_test.go b/handlers/meta/instagram_test.go new file mode 100644 index 000000000..9fadd3853 --- /dev/null +++ b/handlers/meta/instagram_test.go @@ -0,0 +1,450 @@ +package meta + +import ( + "context" + "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/urns" + "github.com/stretchr/testify/assert" +) + +var instgramTestChannels = []courier.Channel{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "IG", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}), +} + +var instagramIncomingTests = []IncomingTestCase{ + { + Label: "Receive Message", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/hello_msg.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/hello_msg.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid request signature", + PrepRequest: addInvalidSignature, + }, + { + Label: "No Duplicate Receive Message", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/duplicate_msg.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/attachment.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/icebreaker_get_started.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeNewConversation, URN: "instagram:5678", Time: time.Date(2016, 4, 7, 1, 11, 27, 970000000, time.UTC), Extra: map[string]string{"title": "icebreaker question", "payload": "get_started"}}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Different Page", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/different_page.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"data":[]`, + PrepRequest: addValidSignature, + }, + { + Label: "Echo", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/echo.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `ignoring echo`, + PrepRequest: addValidSignature, + }, + { + Label: "No Entries", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/no_entries.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "no entries found", + NoLogsExpected: true, + PrepRequest: addValidSignature, + }, + { + Label: "Not Instagram", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/not_instagram.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/no_messaging_entries.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + PrepRequest: addValidSignature, + }, + { + Label: "Unknown Messaging Entry", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/unknown_messaging_entry.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/invalid_urn.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid instagram id", + PrepRequest: addValidSignature, + }, + { + Label: "Story Mention", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/story_mention.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `ignoring story_mention`, + PrepRequest: addValidSignature, + }, + { + Label: "Message unsent", + URL: "/c/ig/receive", + Data: string(test.ReadFile("./testdata/ig/unsent_msg.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `msg deleted`, + PrepRequest: addValidSignature, + }, +} + +func TestInstagramIncoming(t *testing.T) { + graphURL = createMockGraphAPI().URL + + RunIncomingTestCases(t, instgramTestChannels, newHandler("IG", "Instagram"), instagramIncomingTests) +} + +var instagramOutgoingTests = []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, + }, +} + +func TestInstagramOutgoing(t *testing.T) { + // shorter max msg length for testing + maxMsgLength = 100 + + var channel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "IG", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}) + + checkRedacted := []string{"wac_admin_system_user_token", "missing_facebook_app_secret", "missing_facebook_webhook_secret", "a123"} + + RunOutgoingTestCases(t, channel, newHandler("IG", "Instagram"), instagramOutgoingTests, checkRedacted, nil) +} + +func TestInstgramVerify(t *testing.T) { + RunIncomingTestCases(t, instgramTestChannels, newHandler("IG", "Instagram"), []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, + }, + }) +} + +func TestInstagramDescribeURN(t *testing.T) { + fbGraph := buildMockFBGraphIG(instagramIncomingTests) + defer fbGraph.Close() + + channel := instgramTestChannels[0] + handler := newHandler("IG", "Instagram") + handler.Initialize(test.NewMockServer(courier.NewConfig(), test.NewMockBackend())) + 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 TestInstagramBuildAttachmentRequest(t *testing.T) { + mb := test.NewMockBackend() + s := courier.NewServer(courier.NewConfig(), mb) + + handler := &handler{NewBaseHandler(courier.ChannelType("IG"), "Instagram", DisableUUIDRouting())} + handler.Initialize(s) + req, _ := handler.BuildAttachmentRequest(context.Background(), mb, facebookTestChannels[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) +} + +// 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", http.StatusForbidden) + } + + // 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 +} diff --git a/handlers/meta/messenger/api.go b/handlers/meta/messenger/api.go new file mode 100644 index 000000000..603e74524 --- /dev/null +++ b/handlers/meta/messenger/api.go @@ -0,0 +1,144 @@ +package messenger + +// { +// "messaging_type": "" +// "recipient": { +// "id":"" +// }, +// "message": { +// "text":"hello, world!" +// "attachment":{ +// "type":"image", +// "payload":{ +// "url":"http://www.messenger-rocks.com/image.jpg", +// "is_reusable":true +// } +// } +// } +// } +type SendRequest struct { + MessagingType string `json:"messaging_type"` + Tag string `json:"tag,omitempty"` + Recipient struct { + UserRef string `json:"user_ref,omitempty"` + ID string `json:"id,omitempty"` + NotificationMessagesToken string `json:"notification_messages_token,omitempty"` + } `json:"recipient"` + Message struct { + Text string `json:"text,omitempty"` + QuickReplies []QuickReply `json:"quick_replies,omitempty"` + Attachment *Attachment `json:"attachment,omitempty"` + } `json:"message"` +} + +type Attachment struct { + Type string `json:"type"` + Payload struct { + URL string `json:"url,omitempty"` + IsReusable bool `json:"is_reusable,omitempty"` + + TemplateType string `json:"template_type,omitempty"` + Title string `json:"title,omitempty"` + Payload string `json:"payload,omitempty"` + } `json:"payload"` +} + +type QuickReply struct { + Title string `json:"title"` + Payload string `json:"payload"` + ContentType string `json:"content_type"` +} + +type SendResponse struct { + ExternalID string `json:"message_id"` + RecipientID string `json:"recipient_id"` + Error struct { + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` +} + +type FeedbackQuestion struct { + Type string `json:"type"` + Payload string `json:"payload"` + FollowUp *struct { + Type string `json:"type"` + Payload string `json:"payload"` + } `json:"follow_up"` +} + +// see https://developers.facebook.com/docs/messenger-platform/webhooks/#event-notifications +type Messaging struct { + Sender *struct { + ID string `json:"id"` + UserRef string `json:"user_ref,omitempty"` + } `json:"sender"` + Recipient *struct { + ID string `json:"id"` + } `json:"recipient"` + Timestamp int64 `json:"timestamp"` + + OptIn *struct { + Type string `json:"type"` + Payload string `json:"payload"` + NotificationMessagesToken string `json:"notification_messages_token"` + NotificationMessagesTimezone string `json:"notification_messages_timezone"` + NotificationMessagesFrequency string `json:"notification_messages_frequency"` + NotificationMessagesStatus string `json:"notification_messages_status"` + TokenExpiryTimestamp int64 `json:"token_expiry_timestamp"` + UserTokenStatus string `json:"user_token_status"` + Title string `json:"title"` + + 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"` + + MessagingFeedback *struct { + FeedbackScreens []struct { + ScreenID int `json:"screen_id"` + Questions map[string]FeedbackQuestion `json:"questions"` + } `json:"feedback_screens"` + } `json:"messaging_feedback"` +} diff --git a/handlers/facebookapp/testdata/fba/attachmentFBA.json b/handlers/meta/testdata/fba/attachment.json similarity index 100% rename from handlers/facebookapp/testdata/fba/attachmentFBA.json rename to handlers/meta/testdata/fba/attachment.json diff --git a/handlers/facebookapp/testdata/fba/differentPageFBA.json b/handlers/meta/testdata/fba/different_page.json similarity index 100% rename from handlers/facebookapp/testdata/fba/differentPageFBA.json rename to handlers/meta/testdata/fba/different_page.json diff --git a/handlers/facebookapp/testdata/fba/dlr.json b/handlers/meta/testdata/fba/dlr.json similarity index 100% rename from handlers/facebookapp/testdata/fba/dlr.json rename to handlers/meta/testdata/fba/dlr.json diff --git a/handlers/facebookapp/testdata/fba/duplicateMsgFBA.json b/handlers/meta/testdata/fba/duplicate_msg.json similarity index 100% rename from handlers/facebookapp/testdata/fba/duplicateMsgFBA.json rename to handlers/meta/testdata/fba/duplicate_msg.json diff --git a/handlers/facebookapp/testdata/fba/echoFBA.json b/handlers/meta/testdata/fba/echo.json similarity index 100% rename from handlers/facebookapp/testdata/fba/echoFBA.json rename to handlers/meta/testdata/fba/echo.json diff --git a/handlers/meta/testdata/fba/fallback.json b/handlers/meta/testdata/fba/fallback.json new file mode 100644 index 000000000..61001574c --- /dev/null +++ b/handlers/meta/testdata/fba/fallback.json @@ -0,0 +1,32 @@ +{ + "object": "page", + "entry": [ + { + "id": "12345", + "messaging": [ + { + "message": { + "mid": "external_id", + "attachments": [ + { + "type": "fallback", + "payload": { + "url": "My Documents/foo.doc", + "title": "Foo" + } + } + ] + }, + "recipient": { + "id": "12345" + }, + "sender": { + "id": "5678" + }, + "timestamp": 1459991487970 + } + ], + "time": 1459991487970 + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/fba/helloMsgFBA.json b/handlers/meta/testdata/fba/hello_msg.json similarity index 100% rename from handlers/facebookapp/testdata/fba/helloMsgFBA.json rename to handlers/meta/testdata/fba/hello_msg.json diff --git a/handlers/facebookapp/testdata/fba/invalidURNFBA.json b/handlers/meta/testdata/fba/invalid_urn.json similarity index 100% rename from handlers/facebookapp/testdata/fba/invalidURNFBA.json rename to handlers/meta/testdata/fba/invalid_urn.json diff --git a/handlers/facebookapp/testdata/fba/locationAttachment.json b/handlers/meta/testdata/fba/location_attachment.json similarity index 100% rename from handlers/facebookapp/testdata/fba/locationAttachment.json rename to handlers/meta/testdata/fba/location_attachment.json diff --git a/handlers/facebookapp/testdata/fba/noEntriesFBA.json b/handlers/meta/testdata/fba/no_entries.json similarity index 100% rename from handlers/facebookapp/testdata/fba/noEntriesFBA.json rename to handlers/meta/testdata/fba/no_entries.json diff --git a/handlers/facebookapp/testdata/fba/noMessagingEntriesFBA.json b/handlers/meta/testdata/fba/no_messaging_entries.json similarity index 100% rename from handlers/facebookapp/testdata/fba/noMessagingEntriesFBA.json rename to handlers/meta/testdata/fba/no_messaging_entries.json diff --git a/handlers/facebookapp/testdata/fba/notPage.json b/handlers/meta/testdata/fba/not_page.json similarity index 100% rename from handlers/facebookapp/testdata/fba/notPage.json rename to handlers/meta/testdata/fba/not_page.json diff --git a/handlers/meta/testdata/fba/notification_messages_optin.json b/handlers/meta/testdata/fba/notification_messages_optin.json new file mode 100644 index 000000000..2f1fcd920 --- /dev/null +++ b/handlers/meta/testdata/fba/notification_messages_optin.json @@ -0,0 +1,30 @@ +{ + "object": "page", + "entry": [ + { + "id": "12345", + "time": 1459991487970, + "messaging": [ + { + "sender": { + "id": "5678" + }, + "recipient": { + "id": "12345" + }, + "timestamp": 1459991487970, + "optin": { + "type": "notification_messages", + "payload": "3456", + "notification_messages_token": "12345678901234567890", + "notification_messages_frequency": "DAILY", + "token_expiry_timestamp": 2145916800000, + "user_token_status": "NOT_REFRESHED", + "notification_messages_timezone": "UTC", + "title": "Bird Facts" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/meta/testdata/fba/notification_messages_optout.json b/handlers/meta/testdata/fba/notification_messages_optout.json new file mode 100644 index 000000000..45a66a51e --- /dev/null +++ b/handlers/meta/testdata/fba/notification_messages_optout.json @@ -0,0 +1,30 @@ +{ + "object": "page", + "entry": [ + { + "id": "12345", + "time": 1459991487970, + "messaging": [ + { + "recipient": { + "id": "12345" + }, + "timestamp": 1459991487970, + "sender": { + "id": "5678" + }, + "optin": { + "type": "notification_messages", + "payload": "3456", + "notification_messages_token": "12345678901234567890", + "notification_messages_frequency": "DAILY", + "token_expiry_timestamp": 2145916800000, + "user_token_status": "NOT_REFRESHED", + "notification_messages_status": "STOP_NOTIFICATIONS", + "title": "Bird Facts" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/fba/postback.json b/handlers/meta/testdata/fba/postback.json similarity index 100% rename from handlers/facebookapp/testdata/fba/postback.json rename to handlers/meta/testdata/fba/postback.json diff --git a/handlers/facebookapp/testdata/fba/postbackGetStarted.json b/handlers/meta/testdata/fba/postback_get_started.json similarity index 100% rename from handlers/facebookapp/testdata/fba/postbackGetStarted.json rename to handlers/meta/testdata/fba/postback_get_started.json diff --git a/handlers/facebookapp/testdata/fba/postbackReferral.json b/handlers/meta/testdata/fba/postback_referral.json similarity index 100% rename from handlers/facebookapp/testdata/fba/postbackReferral.json rename to handlers/meta/testdata/fba/postback_referral.json diff --git a/handlers/facebookapp/testdata/fba/referral.json b/handlers/meta/testdata/fba/referral.json similarity index 100% rename from handlers/facebookapp/testdata/fba/referral.json rename to handlers/meta/testdata/fba/referral.json diff --git a/handlers/facebookapp/testdata/fba/optIn.json b/handlers/meta/testdata/fba/referral_optin.json similarity index 100% rename from handlers/facebookapp/testdata/fba/optIn.json rename to handlers/meta/testdata/fba/referral_optin.json diff --git a/handlers/facebookapp/testdata/fba/optInUserRef.json b/handlers/meta/testdata/fba/referral_optin_user_ref.json similarity index 100% rename from handlers/facebookapp/testdata/fba/optInUserRef.json rename to handlers/meta/testdata/fba/referral_optin_user_ref.json diff --git a/handlers/meta/testdata/fba/referral_seconds.json b/handlers/meta/testdata/fba/referral_seconds.json new file mode 100644 index 000000000..3b3d76460 --- /dev/null +++ b/handlers/meta/testdata/fba/referral_seconds.json @@ -0,0 +1,27 @@ +{ + "object": "page", + "entry": [ + { + "id": "12345", + "messaging": [ + { + "referral": { + "ref": "referral id", + "ad_id": "ad id", + "source": "referral source", + "type": "referral type" + }, + "recipient": { + "id": "12345" + }, + "sender": { + "id": "5678", + "user_ref": "5678" + }, + "timestamp": 1701599111 + } + ], + "time": 1701599116216 + } + ] +} \ No newline at end of file diff --git a/handlers/facebookapp/testdata/fba/thumbsUp.json b/handlers/meta/testdata/fba/thumbs_up.json similarity index 100% rename from handlers/facebookapp/testdata/fba/thumbsUp.json rename to handlers/meta/testdata/fba/thumbs_up.json diff --git a/handlers/facebookapp/testdata/fba/unknownMessagingEntryFBA.json b/handlers/meta/testdata/fba/unknown_messaging_entry.json similarity index 100% rename from handlers/facebookapp/testdata/fba/unknownMessagingEntryFBA.json rename to handlers/meta/testdata/fba/unknown_messaging_entry.json diff --git a/handlers/facebookapp/testdata/ig/attachmentIG.json b/handlers/meta/testdata/ig/attachment.json similarity index 100% rename from handlers/facebookapp/testdata/ig/attachmentIG.json rename to handlers/meta/testdata/ig/attachment.json diff --git a/handlers/facebookapp/testdata/ig/differentPageIG.json b/handlers/meta/testdata/ig/different_page.json similarity index 100% rename from handlers/facebookapp/testdata/ig/differentPageIG.json rename to handlers/meta/testdata/ig/different_page.json diff --git a/handlers/facebookapp/testdata/ig/duplicateMsgIG.json b/handlers/meta/testdata/ig/duplicate_msg.json similarity index 100% rename from handlers/facebookapp/testdata/ig/duplicateMsgIG.json rename to handlers/meta/testdata/ig/duplicate_msg.json diff --git a/handlers/facebookapp/testdata/ig/echoIG.json b/handlers/meta/testdata/ig/echo.json similarity index 100% rename from handlers/facebookapp/testdata/ig/echoIG.json rename to handlers/meta/testdata/ig/echo.json diff --git a/handlers/facebookapp/testdata/ig/helloMsgIG.json b/handlers/meta/testdata/ig/hello_msg.json similarity index 100% rename from handlers/facebookapp/testdata/ig/helloMsgIG.json rename to handlers/meta/testdata/ig/hello_msg.json diff --git a/handlers/facebookapp/testdata/ig/icebreakerGetStarted.json b/handlers/meta/testdata/ig/icebreaker_get_started.json similarity index 100% rename from handlers/facebookapp/testdata/ig/icebreakerGetStarted.json rename to handlers/meta/testdata/ig/icebreaker_get_started.json diff --git a/handlers/facebookapp/testdata/ig/invalidURNIG.json b/handlers/meta/testdata/ig/invalid_urn.json similarity index 100% rename from handlers/facebookapp/testdata/ig/invalidURNIG.json rename to handlers/meta/testdata/ig/invalid_urn.json diff --git a/handlers/facebookapp/testdata/ig/like_heart.json b/handlers/meta/testdata/ig/like_heart.json similarity index 100% rename from handlers/facebookapp/testdata/ig/like_heart.json rename to handlers/meta/testdata/ig/like_heart.json diff --git a/handlers/facebookapp/testdata/ig/noEntriesIG.json b/handlers/meta/testdata/ig/no_entries.json similarity index 100% rename from handlers/facebookapp/testdata/ig/noEntriesIG.json rename to handlers/meta/testdata/ig/no_entries.json diff --git a/handlers/facebookapp/testdata/ig/noMessagingEntriesIG.json b/handlers/meta/testdata/ig/no_messaging_entries.json similarity index 100% rename from handlers/facebookapp/testdata/ig/noMessagingEntriesIG.json rename to handlers/meta/testdata/ig/no_messaging_entries.json diff --git a/handlers/facebookapp/testdata/ig/notInstagram.json b/handlers/meta/testdata/ig/not_instagram.json similarity index 100% rename from handlers/facebookapp/testdata/ig/notInstagram.json rename to handlers/meta/testdata/ig/not_instagram.json diff --git a/handlers/facebookapp/testdata/ig/storyMentionIG.json b/handlers/meta/testdata/ig/story_mention.json similarity index 100% rename from handlers/facebookapp/testdata/ig/storyMentionIG.json rename to handlers/meta/testdata/ig/story_mention.json diff --git a/handlers/facebookapp/testdata/ig/unknownMessagingEntryIG.json b/handlers/meta/testdata/ig/unknown_messaging_entry.json similarity index 100% rename from handlers/facebookapp/testdata/ig/unknownMessagingEntryIG.json rename to handlers/meta/testdata/ig/unknown_messaging_entry.json diff --git a/handlers/facebookapp/testdata/ig/unsentMsgIG.json b/handlers/meta/testdata/ig/unsent_msg.json similarity index 100% rename from handlers/facebookapp/testdata/ig/unsentMsgIG.json rename to handlers/meta/testdata/ig/unsent_msg.json diff --git a/handlers/dialog360/testdata/wac/audioWAC.json b/handlers/meta/testdata/wac/audio.json similarity index 100% rename from handlers/dialog360/testdata/wac/audioWAC.json rename to handlers/meta/testdata/wac/audio.json diff --git a/handlers/dialog360/testdata/wac/buttonWAC.json b/handlers/meta/testdata/wac/button.json similarity index 100% rename from handlers/dialog360/testdata/wac/buttonWAC.json rename to handlers/meta/testdata/wac/button.json diff --git a/handlers/dialog360/testdata/wac/buttonReplyWAC.json b/handlers/meta/testdata/wac/button_reply.json similarity index 100% rename from handlers/dialog360/testdata/wac/buttonReplyWAC.json rename to handlers/meta/testdata/wac/button_reply.json diff --git a/handlers/facebookapp/testdata/wac/contactWAC.json b/handlers/meta/testdata/wac/contactWAC.json similarity index 100% rename from handlers/facebookapp/testdata/wac/contactWAC.json rename to handlers/meta/testdata/wac/contactWAC.json diff --git a/handlers/dialog360/testdata/wac/documentWAC.json b/handlers/meta/testdata/wac/document.json similarity index 100% rename from handlers/dialog360/testdata/wac/documentWAC.json rename to handlers/meta/testdata/wac/document.json diff --git a/handlers/dialog360/testdata/wac/duplicateWAC.json b/handlers/meta/testdata/wac/duplicate.json similarity index 100% rename from handlers/dialog360/testdata/wac/duplicateWAC.json rename to handlers/meta/testdata/wac/duplicate.json diff --git a/handlers/dialog360/testdata/wac/errorErrors.json b/handlers/meta/testdata/wac/error_errors.json similarity index 100% rename from handlers/dialog360/testdata/wac/errorErrors.json rename to handlers/meta/testdata/wac/error_errors.json diff --git a/handlers/dialog360/testdata/wac/errorMsg.json b/handlers/meta/testdata/wac/error_msg.json similarity index 100% rename from handlers/dialog360/testdata/wac/errorMsg.json rename to handlers/meta/testdata/wac/error_msg.json diff --git a/handlers/dialog360/testdata/wac/errorStatus.json b/handlers/meta/testdata/wac/error_status.json similarity index 100% rename from handlers/dialog360/testdata/wac/errorStatus.json rename to handlers/meta/testdata/wac/error_status.json diff --git a/handlers/dialog360/testdata/wac/helloWAC.json b/handlers/meta/testdata/wac/hello.json similarity index 100% rename from handlers/dialog360/testdata/wac/helloWAC.json rename to handlers/meta/testdata/wac/hello.json diff --git a/handlers/dialog360/testdata/wac/ignoreStatusWAC.json b/handlers/meta/testdata/wac/ignore_status.json similarity index 100% rename from handlers/dialog360/testdata/wac/ignoreStatusWAC.json rename to handlers/meta/testdata/wac/ignore_status.json diff --git a/handlers/dialog360/testdata/wac/imageWAC.json b/handlers/meta/testdata/wac/image.json similarity index 100% rename from handlers/dialog360/testdata/wac/imageWAC.json rename to handlers/meta/testdata/wac/image.json diff --git a/handlers/dialog360/testdata/wac/invalidFrom.json b/handlers/meta/testdata/wac/invalid_from.json similarity index 100% rename from handlers/dialog360/testdata/wac/invalidFrom.json rename to handlers/meta/testdata/wac/invalid_from.json diff --git a/handlers/dialog360/testdata/wac/invalidStatusWAC.json b/handlers/meta/testdata/wac/invalid_status.json similarity index 100% rename from handlers/dialog360/testdata/wac/invalidStatusWAC.json rename to handlers/meta/testdata/wac/invalid_status.json diff --git a/handlers/dialog360/testdata/wac/invalidTimestamp.json b/handlers/meta/testdata/wac/invalid_timestamp.json similarity index 100% rename from handlers/dialog360/testdata/wac/invalidTimestamp.json rename to handlers/meta/testdata/wac/invalid_timestamp.json diff --git a/handlers/dialog360/testdata/wac/listReplyWAC.json b/handlers/meta/testdata/wac/list_reply.json similarity index 100% rename from handlers/dialog360/testdata/wac/listReplyWAC.json rename to handlers/meta/testdata/wac/list_reply.json diff --git a/handlers/dialog360/testdata/wac/locationWAC.json b/handlers/meta/testdata/wac/location.json similarity index 100% rename from handlers/dialog360/testdata/wac/locationWAC.json rename to handlers/meta/testdata/wac/location.json diff --git a/handlers/dialog360/testdata/wac/validStatusWAC.json b/handlers/meta/testdata/wac/valid_status.json similarity index 100% rename from handlers/dialog360/testdata/wac/validStatusWAC.json rename to handlers/meta/testdata/wac/valid_status.json diff --git a/handlers/dialog360/testdata/wac/videoWAC.json b/handlers/meta/testdata/wac/video.json similarity index 100% rename from handlers/dialog360/testdata/wac/videoWAC.json rename to handlers/meta/testdata/wac/video.json diff --git a/handlers/dialog360/testdata/wac/voiceWAC.json b/handlers/meta/testdata/wac/voice.json similarity index 100% rename from handlers/dialog360/testdata/wac/voiceWAC.json rename to handlers/meta/testdata/wac/voice.json diff --git a/handlers/meta/whataspp_test.go b/handlers/meta/whataspp_test.go new file mode 100644 index 000000000..e660131e6 --- /dev/null +++ b/handlers/meta/whataspp_test.go @@ -0,0 +1,588 @@ +package meta + +import ( + "context" + "encoding/json" + "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 whatsappTestChannels = []courier.Channel{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "WAC", "12345", "", map[string]any{courier.ConfigAuthToken: "a123"}), +} + +var whatappReceiveURL = "/c/wac/receive" + +var whatsappIncomingTests = []IncomingTestCase{ + { + Label: "Receive Message WAC", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/hello.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: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/duplicate.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: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/voice.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: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/button.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: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/document.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: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/image.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 Video Message", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/video.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: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/audio.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: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/location.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: whatappReceiveURL, + Data: "not json", + ExpectedRespStatus: 200, + ExpectedBodyContains: "unable to parse", + NoLogsExpected: true, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Invalid From", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/invalid_from.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid whatsapp id", + PrepRequest: addValidSignature, + }, + { + Label: "Receive Invalid Timestamp", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/invalid_timestamp.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid timestamp", + PrepRequest: addValidSignature, + }, + { + Label: "Receive Message WAC invalid signature", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/hello.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "invalid request signature", + NoQueueErrorCheck: true, + NoInvalidChannelCheck: true, + PrepRequest: addInvalidSignature, + }, + { + Label: "Receive Message WAC with error message", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/error_msg.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: "Handled", + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("131051", "Unsupported message type")}, + NoInvalidChannelCheck: true, + PrepRequest: addValidSignature, + }, + { + Label: "Receive error message", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/error_errors.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: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/valid_status.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"type":"status"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "external_id", Status: courier.MsgStatusSent}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Status with error message", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/error_status.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"type":"status"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "external_id", Status: courier.MsgStatusFailed}, + }, + 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: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/invalid_status.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"unknown status: in_orbit"`, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Ignore Status", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/ignore_status.json")), + ExpectedRespStatus: 200, + ExpectedBodyContains: `"ignoring status: deleted"`, + PrepRequest: addValidSignature, + }, + { + Label: "Receive Valid Interactive Button Reply Message", + URL: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/button_reply.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: whatappReceiveURL, + Data: string(test.ReadFile("./testdata/wac/list_reply.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 TestWhatsAppIncoming(t *testing.T) { + graphURL = createMockGraphAPI().URL + + RunIncomingTestCases(t, whatsappTestChannels, newHandler("WAC", "Cloud API WhatsApp"), whatsappIncomingTests) +} + +var whatsappOutgoingTests = []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: "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"], "language": "en_US"}}`), + 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_US"},"components":[{"type":"body","sub_type":"","index":"","parameters":[{"type":"text","text":"Chef"},{"type":"text","text":"tomorrow"}]}]}}`, + 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 TestWhatsAppOutgoing(t *testing.T) { + // shorter max msg length for testing + maxMsgLength = 100 + + var channel = 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, channel, newHandler("WAC", "Cloud API WhatsApp"), whatsappOutgoingTests, checkRedacted, nil) +} + +func TestWhatsAppDescribeURN(t *testing.T) { + channel := whatsappTestChannels[0] + handler := newHandler("WAC", "Cloud API WhatsApp") + handler.Initialize(newServerWithWAC(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(), whatsappTestChannels[0], tc.urn, clog) + assert.Equal(t, metadata, tc.expectedMetadata) + } + + AssertChannelLogRedaction(t, clog, []string{"a123", "wac_admin_system_user_token"}) +} + +func TestWhatsAppBuildAttachmentRequest(t *testing.T) { + mb := test.NewMockBackend() + s := newServerWithWAC(mb) + handler := &handler{NewBaseHandler(courier.ChannelType("WAC"), "WhatsApp Cloud", DisableUUIDRouting())} + handler.Initialize(s) + req, _ := handler.BuildAttachmentRequest(context.Background(), mb, whatsappTestChannels[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")) +} + +func newServerWithWAC(backend courier.Backend) courier.Server { + config := courier.NewConfig() + config.WhatsappAdminSystemUserToken = "wac_admin_system_user_token" + return courier.NewServer(config, backend) +} diff --git a/handlers/meta/whatsapp/api.go b/handlers/meta/whatsapp/api.go new file mode 100644 index 000000000..57468d3cf --- /dev/null +++ b/handlers/meta/whatsapp/api.go @@ -0,0 +1,244 @@ +package whatsapp + +import "github.com/nyaruka/courier" + +// see https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/payload-examples#message-status-updates +var StatusMapping = map[string]courier.MsgStatus{ + "sent": courier.MsgStatusSent, + "delivered": courier.MsgStatusDelivered, + "read": courier.MsgStatusDelivered, + "failed": courier.MsgStatusFailed, +} + +var IgnoreStatuses = map[string]bool{ + "deleted": true, +} + +// see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#example-2 +type MOMedia struct { + Caption string `json:"caption"` + Filename string `json:"filename"` + ID string `json:"id"` + Mimetype string `json:"mime_type"` + SHA256 string `json:"sha256"` +} + +type Change 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 *MOMedia `json:"image"` + Audio *MOMedia `json:"audio"` + Video *MOMedia `json:"video"` + Document *MOMedia `json:"document"` + Voice *MOMedia `json:"voice"` + Location *struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Name string `json:"name"` + Address string `json:"address"` + } `json:"location"` + Button *struct { + Text string `json:"text"` + Payload string `json:"payload"` + } `json:"button"` + 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"` + Contacts []struct { + Name struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + FormattedName string `json:"formatted_name"` + } `json:"name"` + Phones []struct { + Phone string `json:"phone"` + WaID string `json:"wa_id"` + Type string `json:"type"` + } `json:"phones"` + } `json:"contacts"` + 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"` +} + +// see https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages#media-messages +type Media struct { + ID string `json:"id,omitempty"` + Link string `json:"link,omitempty"` + Caption string `json:"caption,omitempty"` + Filename string `json:"filename,omitempty"` +} + +type Section struct { + Title string `json:"title,omitempty"` + Rows []SectionRow `json:"rows" validate:"required"` +} + +type SectionRow struct { + ID string `json:"id" validate:"required"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` +} + +type Button 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 Param struct { + Type string `json:"type"` + Text string `json:"text"` + Image *Media `json:"image,omitempty"` + Document *Media `json:"document,omitempty"` + Video *Media `json:"video,omitempty"` +} + +type Component struct { + Type string `json:"type"` + SubType string `json:"sub_type"` + Index string `json:"index"` + Params []*Param `json:"parameters"` +} + +type Text struct { + Body string `json:"body"` + PreviewURL bool `json:"preview_url"` +} + +type Language struct { + Policy string `json:"policy"` + Code string `json:"code"` +} + +// see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#template-object +// e.g. https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#template-messages +type Template struct { + Name string `json:"name"` + Language *Language `json:"language"` + Components []*Component `json:"components"` +} + +// see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#interactive-object +// e.g. https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#interactive-messages +type Interactive struct { + Type string `json:"type"` + Header *struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Video *Media `json:"video,omitempty"` + Image *Media `json:"image,omitempty"` + Document *Media `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 []Section `json:"sections,omitempty"` + Buttons []Button `json:"buttons,omitempty"` + } `json:"action,omitempty"` +} + +// see https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages#request-syntax +// e.g. https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#message-object +type SendRequest struct { + MessagingProduct string `json:"messaging_product"` + RecipientType string `json:"recipient_type"` + To string `json:"to"` + Type string `json:"type"` + + Text *Text `json:"text,omitempty"` + + Document *Media `json:"document,omitempty"` + Image *Media `json:"image,omitempty"` + Audio *Media `json:"audio,omitempty"` + Video *Media `json:"video,omitempty"` + + Interactive *Interactive `json:"interactive,omitempty"` + + Template *Template `json:"template,omitempty"` +} + +// see https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages#response-syntax +// e.g. https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#successful-response +type SendResponse struct { + Messages []*struct { + ID string `json:"id"` + } `json:"messages"` + Error struct { + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` + Contacts []*struct { + Input string `json:"input,omitempty"` + WaID string `json:"wa_id,omitempty"` + } `json:"contacts,omitempty"` +} diff --git a/handlers/meta/whatsapp/templates.go b/handlers/meta/whatsapp/templates.go new file mode 100644 index 000000000..fa0b30414 --- /dev/null +++ b/handlers/meta/whatsapp/templates.go @@ -0,0 +1,42 @@ +package whatsapp + +import ( + "encoding/json" + + "github.com/nyaruka/courier" + "github.com/nyaruka/courier/utils" + "github.com/pkg/errors" +) + +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"` + Language string `json:"language"` +} + +func GetTemplating(msg courier.MsgOut) (*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 +} diff --git a/handlers/meta/whatsapp/templates_test.go b/handlers/meta/whatsapp/templates_test.go new file mode 100644 index 000000000..19c3d81ac --- /dev/null +++ b/handlers/meta/whatsapp/templates_test.go @@ -0,0 +1,41 @@ +package whatsapp_test + +import ( + "encoding/json" + "testing" + + "github.com/nyaruka/courier/handlers/meta/whatsapp" + "github.com/nyaruka/courier/test" + "github.com/stretchr/testify/assert" +) + +func TestGetTemplating(t *testing.T) { + msg := test.NewMockMsg(1, "87995844-2017-4ba0-bc73-f3da75b32f9b", nil, "tel:+1234567890", "hi", nil) + + // no metadata, no templating + tpl, err := whatsapp.GetTemplating(msg) + assert.NoError(t, err) + assert.Nil(t, tpl) + + msg.WithMetadata(json.RawMessage(`{}`)) + + // no templating in metadata, no templating + tpl, err = whatsapp.GetTemplating(msg) + assert.NoError(t, err) + assert.Nil(t, tpl) + + msg.WithMetadata(json.RawMessage(`{"templating": {"foo": "bar"}}`)) + + // invalid templating in metadata, error + tpl, err = whatsapp.GetTemplating(msg) + assert.Error(t, err, "x") + assert.Nil(t, tpl) + + msg.WithMetadata(json.RawMessage(`{"templating": {"template": {"uuid": "4ed5000f-5c94-4143-9697-b7cbd230a381", "name": "Update"}}}`)) + + // invalid templating in metadata, error + tpl, err = whatsapp.GetTemplating(msg) + assert.NoError(t, err) + assert.Equal(t, "4ed5000f-5c94-4143-9697-b7cbd230a381", tpl.Template.UUID) + assert.Equal(t, "Update", tpl.Template.Name) +} diff --git a/handlers/mtarget/mtarget.go b/handlers/mtarget/handler.go similarity index 86% rename from handlers/mtarget/mtarget.go rename to handlers/mtarget/handler.go index 3e2a42dc3..9f79ea345 100644 --- a/handlers/mtarget/mtarget.go +++ b/handlers/mtarget/handler.go @@ -32,13 +32,13 @@ func newHandler() courier.ChannelHandler { return &handler{handlers.NewBaseHandler(courier.ChannelType("MT"), "Mtarget")} } -var statusMapping = map[string]courier.MsgStatusValue{ - "0": courier.MsgWired, - "1": courier.MsgWired, - "2": courier.MsgSent, - "3": courier.MsgDelivered, - "4": courier.MsgFailed, - "6": courier.MsgFailed, +var statusMapping = map[string]courier.MsgStatus{ + "0": courier.MsgStatusWired, + "1": courier.MsgStatusWired, + "2": courier.MsgStatusSent, + "3": courier.MsgStatusDelivered, + "4": courier.MsgStatusFailed, + "6": courier.MsgStatusFailed, } // Initialize is called by the engine once everything is loaded @@ -61,6 +61,7 @@ func (h *handler) receiveMsg(ctx context.Context, c courier.Channel, w http.Resp text := r.Form.Get("Content") from := r.Form.Get("Msisdn") keyword := r.Form.Get("Keyword") + msgID := r.Form.Get("MsgId") if from == "" { return nil, handlers.WriteAndLogRequestError(ctx, h, c, w, r, fmt.Errorf("missing required field 'Msisdn'")) @@ -107,7 +108,7 @@ func (h *handler) receiveMsg(ctx context.Context, c courier.Channel, w http.Resp // we have all our parts, grab them and put them together // build up the list of keys we are looking up - keys := make([]interface{}, longCount+1) + keys := make([]any, longCount+1) keys[0] = mapKey for i := 1; i < longCount+1; i++ { keys[i] = fmt.Sprintf("%d", i) @@ -133,7 +134,7 @@ func (h *handler) receiveMsg(ctx context.Context, c courier.Channel, w http.Resp // if this a stop command, shortcut stopping that contact if keyword == "Stop" { - stop := h.Backend().NewChannelEvent(c, courier.StopContact, urn, clog) + stop := h.Backend().NewChannelEvent(c, courier.EventTypeStopContact, urn, clog) err := h.Backend().WriteChannelEvent(ctx, stop, clog) if err != nil { return nil, err @@ -142,12 +143,12 @@ func (h *handler) receiveMsg(ctx context.Context, c courier.Channel, w http.Resp } // otherwise, create and write the message - msg := h.Backend().NewIncomingMsg(c, urn, text, "", clog).WithReceivedOn(time.Now().UTC()) - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + msg := h.Backend().NewIncomingMsg(c, urn, text, msgID, clog).WithReceivedOn(time.Now().UTC()) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for MT channel") @@ -159,7 +160,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } // send our message - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) for _, part := range handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) { // build our request params := url.Values{ @@ -178,7 +179,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, err } - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -197,10 +198,10 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann externalID, _ := jsonparser.GetString(respBody, "results", "[0]", "ticket") if code == "0" && externalID != "" { // all went well, set ourselves to wired - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) status.SetExternalID(externalID) } else { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) clog.RawError(fmt.Errorf("Error status code, failing permanently")) break } diff --git a/handlers/mtarget/mtarget_test.go b/handlers/mtarget/handler_test.go similarity index 76% rename from handlers/mtarget/mtarget_test.go rename to handlers/mtarget/handler_test.go index 045531dd8..a803a2f00 100644 --- a/handlers/mtarget/mtarget_test.go +++ b/handlers/mtarget/handler_test.go @@ -12,7 +12,7 @@ import ( var ( receiveURL = "/c/mt/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive" - receiveValidMessage = "Msisdn=+923161909799&Content=hello+world&Keyword=Default" + receiveValidMessage = "Msisdn=+923161909799&Content=hello+world&Keyword=Default&MsgId=foo" receiveInvalidURN = "Msisdn=MTN&Content=hello+world&Keyword=Default" receiveStop = "Msisdn=+923161909799&Content=Stop&Keyword=Stop" receiveMissingFrom = "Content=hello&Keyword=Default" @@ -31,27 +31,51 @@ var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MT", "2020", "FR", nil), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ {Label: "Receive Valid Message", URL: receiveURL, Data: receiveValidMessage, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", - ExpectedMsgText: Sp("hello world"), ExpectedURN: "tel:+923161909799"}, + ExpectedMsgText: Sp("hello world"), ExpectedURN: "tel:+923161909799", ExpectedExternalID: "foo"}, {Label: "Invalid URN", URL: receiveURL, Data: receiveInvalidURN, ExpectedRespStatus: 400, ExpectedBodyContains: "phone number supplied is not a number"}, - {Label: "Receive Stop", URL: receiveURL, Data: receiveStop, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", - ExpectedURN: "tel:+923161909799", ExpectedEvent: courier.StopContact}, + { + Label: "Receive Stop", + URL: receiveURL, + Data: receiveStop, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Accepted", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeStopContact, URN: "tel:+923161909799"}, + }, + }, {Label: "Receive Missing From", URL: receiveURL, Data: receiveMissingFrom, ExpectedRespStatus: 400, ExpectedBodyContains: "missing required field 'Msisdn'"}, {Label: "Receive Part 2", URL: receiveURL, Data: receivePart2, ExpectedRespStatus: 200, ExpectedBodyContains: "received"}, {Label: "Receive Part 1", URL: receiveURL, Data: receivePart1, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedMsgText: Sp("hello world"), ExpectedURN: "tel:+923161909799"}, - {Label: "Status Delivered", URL: statusURL, Data: statusDelivered, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", - ExpectedExternalID: "12a7ee90-50ce-11e7-80ae-00000a0a643c", ExpectedMsgStatus: "D"}, - {Label: "Status Failed", URL: statusURL, Data: statusFailed, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", - ExpectedExternalID: "12a7ee90-50ce-11e7-80ae-00000a0a643c", ExpectedMsgStatus: "F"}, + { + Label: "Status Delivered", + URL: statusURL, + Data: statusDelivered, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Accepted", + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "12a7ee90-50ce-11e7-80ae-00000a0a643c", Status: courier.MsgStatusDelivered}, + }, + }, + { + Label: "Status Failed", + URL: statusURL, + Data: statusFailed, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Accepted", + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "12a7ee90-50ce-11e7-80ae-00000a0a643c", Status: courier.MsgStatusFailed}, + }, + }, {Label: "Status Missing ID", URL: statusURL, Data: statusMissingID, ExpectedRespStatus: 400, ExpectedBodyContains: "missing required field 'MsgId'"}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { @@ -59,11 +83,11 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -123,13 +147,13 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MT", "2020", "FR", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username", }, ) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/mtn/mtn.go b/handlers/mtn/handler.go similarity index 86% rename from handlers/mtn/mtn.go rename to handlers/mtn/handler.go index b7fde7f60..560712425 100644 --- a/handlers/mtn/mtn.go +++ b/handlers/mtn/handler.go @@ -49,17 +49,17 @@ func (h *handler) Initialize(s courier.Server) error { return nil } -var statusMapping = map[string]courier.MsgStatusValue{ - "DELIVRD": courier.MsgDelivered, - "DeliveredToTerminal": courier.MsgDelivered, - "DeliveryUncertain": courier.MsgSent, - "EXPIRED": courier.MsgFailed, - "DeliveryImpossible": courier.MsgErrored, - "DeliveredToNetwork": courier.MsgSent, +var statusMapping = map[string]courier.MsgStatus{ + "DELIVRD": courier.MsgStatusDelivered, + "DeliveredToTerminal": courier.MsgStatusDelivered, + "DeliveryUncertain": courier.MsgStatusSent, + "EXPIRED": courier.MsgStatusFailed, + "DeliveryImpossible": courier.MsgStatusErrored, + "DeliveredToNetwork": courier.MsgStatusSent, // no changes - "MessageWaiting": courier.MsgWired, - "DeliveryNotificationNotSupported": courier.MsgWired, + "MessageWaiting": courier.MsgStatusWired, + "DeliveryNotificationNotSupported": courier.MsgStatusWired, } type moPayload struct { @@ -87,7 +87,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h // create and write the message msg := h.Backend().NewIncomingMsg(channel, urn, payload.Message, "", clog).WithReceivedOn(date) - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } else { clog.SetType(courier.ChannelLogTypeMsgStatus) @@ -102,12 +102,12 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h fmt.Errorf("unknown status '%s'", payload.DeliveryStatus)) } - if msgStatus == courier.MsgWired { + if msgStatus == courier.MsgStatusWired { return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "no status changed, ignored") } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, payload.TransactionID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, payload.TransactionID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } } @@ -121,7 +121,7 @@ type mtPayload struct { } // Send implements courier.ChannelHandler -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { accessToken, err := h.getAccessToken(ctx, msg.Channel(), clog) if err != nil { return nil, err @@ -131,7 +131,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann cpAddress := msg.Channel().StringConfigForKey(configCPAddress, "") partSendURL, _ := url.Parse(fmt.Sprintf("%s/%s", baseURL, "v2/messages/sms/outbound")) - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) mtMsg := &mtPayload{} mtMsg.From = strings.TrimPrefix(msg.Channel().Address(), "+") @@ -154,7 +154,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -168,7 +168,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // if this is our first message, record the external id status.SetExternalID(externalID) - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } @@ -225,7 +225,7 @@ func (h *handler) fetchAccessToken(ctx context.Context, channel courier.Channel, req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return "", 0, err } diff --git a/handlers/mtn/mtn_test.go b/handlers/mtn/handler_test.go similarity index 83% rename from handlers/mtn/mtn_test.go rename to handlers/mtn/handler_test.go index d2d1cad9f..4e9adc406 100644 --- a/handlers/mtn/mtn_test.go +++ b/handlers/mtn/handler_test.go @@ -11,7 +11,7 @@ import ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MTN", "2020", "US", map[string]interface{}{courier.ConfigAuthToken: "customer-secret123", courier.ConfigAPIKey: "customer-key"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MTN", "2020", "US", map[string]any{courier.ConfigAuthToken: "customer-secret123", courier.ConfigAPIKey: "customer-key"}), } var ( @@ -79,7 +79,7 @@ var missingTransactionID = `{ "deliveryStatus": "EXPIRED" }` -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL, @@ -103,8 +103,9 @@ var testCases = []ChannelHandleTestCase{ Data: validStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, - ExpectedExternalID: "rrt-58503", + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "rrt-58503", Status: courier.MsgStatusDelivered}, + }, }, { Label: "Receive Valid delivered Status", @@ -112,8 +113,9 @@ var testCases = []ChannelHandleTestCase{ Data: validDeliveredStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, - ExpectedExternalID: "rrt-58503", + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "rrt-58503", Status: courier.MsgStatusDelivered}, + }, }, { Label: "Receive ignored Status", @@ -121,8 +123,6 @@ var testCases = []ChannelHandleTestCase{ Data: ignoredStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `Ignored`, - ExpectedMsgStatus: "", - ExpectedExternalID: "rrt-58503", }, { Label: "Receive ignored Status, missing transaction ID", @@ -130,7 +130,6 @@ var testCases = []ChannelHandleTestCase{ Data: missingTransactionID, ExpectedRespStatus: 200, ExpectedBodyContains: `Ignored`, - ExpectedMsgStatus: "", }, { Label: "Receive expired Status", @@ -138,8 +137,9 @@ var testCases = []ChannelHandleTestCase{ Data: expiredStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, - ExpectedExternalID: "rrt-58503", + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "rrt-58503", Status: courier.MsgStatusFailed}, + }, }, { Label: "Receive uknown Status", @@ -150,8 +150,8 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -159,11 +159,11 @@ func BenchmarkHandler(b *testing.B) { } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { apiHostURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ {Label: "Plain Send", MsgText: "Simple Message ☺", MsgURN: "tel:+250788383383", @@ -224,7 +224,7 @@ func setupBackend(mb *test.MockBackend) { rc.Do("SET", "channel-token:8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ACCESS_TOKEN") } -var cpAddressSendTestCases = []ChannelSendTestCase{ +var cpAddressSendTestCases = []OutgoingTestCase{ {Label: "Plain Send", MsgText: "Simple Message ☺", MsgURN: "tel:+250788383383", @@ -241,9 +241,9 @@ var cpAddressSendTestCases = []ChannelSendTestCase{ SendPrep: setSendURL}, } -func TestSending(t *testing.T) { - var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MTN", "2020", "US", map[string]interface{}{courier.ConfigAuthToken: "customer-secret123", courier.ConfigAPIKey: "customer-key"}) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"customer-key", "customer-secret123"}, setupBackend) - var cpAddressChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MTN", "2020", "US", map[string]interface{}{courier.ConfigAuthToken: "customer-secret123", courier.ConfigAPIKey: "customer-key", configCPAddress: "FOO"}) - RunChannelSendTestCases(t, cpAddressChannel, newHandler(), cpAddressSendTestCases, []string{"customer-key", "customer-secret123"}, setupBackend) +func TestOutgoing(t *testing.T) { + var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MTN", "2020", "US", map[string]any{courier.ConfigAuthToken: "customer-secret123", courier.ConfigAPIKey: "customer-key"}) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"customer-key", "customer-secret123"}, setupBackend) + var cpAddressChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "MTN", "2020", "US", map[string]any{courier.ConfigAuthToken: "customer-secret123", courier.ConfigAPIKey: "customer-key", configCPAddress: "FOO"}) + RunOutgoingTestCases(t, cpAddressChannel, newHandler(), cpAddressSendTestCases, []string{"customer-key", "customer-secret123"}, setupBackend) } diff --git a/handlers/nexmo/nexmo.go b/handlers/nexmo/handler.go similarity index 87% rename from handlers/nexmo/nexmo.go rename to handlers/nexmo/handler.go index ad77ff4b2..585f9a3ec 100644 --- a/handlers/nexmo/nexmo.go +++ b/handlers/nexmo/handler.go @@ -88,7 +88,7 @@ type handler struct { } func newHandler() courier.ChannelHandler { - return &handler{handlers.NewBaseHandlerWithParams(courier.ChannelType("NX"), "Nexmo", true, []string{configNexmoAPISecret, configNexmoAppPrivateKey})} + return &handler{handlers.NewBaseHandler(courier.ChannelType("NX"), "Nexmo", handlers.WithRedactConfigKeys(configNexmoAPISecret, configNexmoAppPrivateKey))} } // Initialize is called by the engine once everything is loaded @@ -109,14 +109,14 @@ type statusForm struct { ErrCode int `name:"err-code"` } -var statusMappings = map[string]courier.MsgStatusValue{ - "failed": courier.MsgFailed, - "expired": courier.MsgFailed, - "rejected": courier.MsgFailed, - "buffered": courier.MsgSent, - "accepted": courier.MsgSent, - "unknown": courier.MsgWired, - "delivered": courier.MsgDelivered, +var statusMappings = map[string]courier.MsgStatus{ + "failed": courier.MsgStatusFailed, + "expired": courier.MsgStatusFailed, + "rejected": courier.MsgStatusFailed, + "buffered": courier.MsgStatusSent, + "accepted": courier.MsgStatusSent, + "unknown": courier.MsgStatusWired, + "delivered": courier.MsgStatusDelivered, } // receiveStatus is our HTTP handler function for status updates @@ -137,7 +137,7 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w clog.Error(courier.ErrorExternal("dlr:"+strconv.Itoa(form.ErrCode), dlrErrorCodes[form.ErrCode])) } - status := h.Backend().NewMsgStatusForExternalID(channel, form.MessageID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, form.MessageID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } @@ -165,12 +165,12 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w } // create and write the message - msg := h.Backend().NewIncomingMsg(channel, urn, form.Text, "", clog) - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + msg := h.Backend().NewIncomingMsg(channel, urn, form.Text, form.MessageID, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { nexmoAPIKey := msg.Channel().StringConfigForKey(configNexmoAPIKey, "") if nexmoAPIKey == "" { return nil, fmt.Errorf("no nexmo API key set for NX channel") @@ -191,7 +191,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann textType = "unicode" } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), text, maxMsgLength) for _, part := range parts { form := url.Values{ @@ -216,7 +216,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, respBody, requestErr = handlers.RequestHTTP(req, clog) + resp, respBody, requestErr = h.RequestHTTP(req, clog) matched := throttledRE.FindAllStringSubmatch(string(respBody), -1) if len(matched) > 0 && len(matched[0]) > 0 { sleepTime, _ := strconv.Atoi(matched[0][1]) @@ -244,6 +244,6 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/nexmo/nexmo_test.go b/handlers/nexmo/handler_test.go similarity index 89% rename from handlers/nexmo/nexmo_test.go rename to handlers/nexmo/handler_test.go index 6bffde3a4..f3e55f292 100644 --- a/handlers/nexmo/nexmo_test.go +++ b/handlers/nexmo/handler_test.go @@ -18,7 +18,7 @@ const ( receiveURL = "/c/nx/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive" ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Valid Receive", URL: "/c/nx/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive?to=2020&msisdn=2349067554729&text=Join&messageId=external1", @@ -26,6 +26,7 @@ var testCases = []ChannelHandleTestCase{ ExpectedBodyContains: "Accepted", ExpectedMsgText: Sp("Join"), ExpectedURN: "tel:+2349067554729", + ExpectedExternalID: "external1", }, { Label: "Invalid URN", @@ -41,6 +42,7 @@ var testCases = []ChannelHandleTestCase{ Data: "to=2020&msisdn=2349067554729&text=Join&messageId=external1", ExpectedMsgText: Sp("Join"), ExpectedURN: "tel:+2349067554729", + ExpectedExternalID: "external1", }, { Label: "Receive URL check", @@ -59,41 +61,46 @@ var testCases = []ChannelHandleTestCase{ URL: "/c/nx/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?to=2020&messageId=external1&status=delivered&err-code=0", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, - ExpectedExternalID: "external1", + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "external1", Status: courier.MsgStatusDelivered}, + }, }, { Label: "Status expired", URL: "/c/nx/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?to=2020&messageId=external1&status=expired&err-code=0", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, - ExpectedExternalID: "external1", + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "external1", Status: courier.MsgStatusFailed}, + }, }, { Label: "Status failed", URL: "/c/nx/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?to=2020&messageId=external1&status=failed&err-code=6", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, - ExpectedExternalID: "external1", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("dlr:6", "Anti-Spam Rejection")}, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "external1", Status: courier.MsgStatusFailed}, + }, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("dlr:6", "Anti-Spam Rejection")}, }, { Label: "Status accepted", URL: "/c/nx/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?to=2020&messageId=external1&status=accepted", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, - ExpectedMsgStatus: courier.MsgSent, - ExpectedExternalID: "external1", + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "external1", Status: courier.MsgStatusSent}, + }, }, { Label: "Status buffered", URL: "/c/nx/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?to=2020&messageId=external1&status=buffered", ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, - ExpectedMsgStatus: courier.MsgSent, - ExpectedExternalID: "external1", + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "external1", Status: courier.MsgStatusSent}, + }, }, { Label: "Status unexpected", @@ -103,8 +110,8 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -112,11 +119,11 @@ func BenchmarkHandler(b *testing.B) { } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -206,15 +213,15 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "NX", "2020", "US", - map[string]interface{}{ + map[string]any{ configNexmoAPIKey: "nexmo-api-key", configNexmoAPISecret: "nexmo-api-secret", configNexmoAppID: "nexmo-app-id", configNexmoAppPrivateKey: "nexmo-app-private-key", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"nexmo-api-secret", "nexmo-app-private-key"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"nexmo-api-secret", "nexmo-app-private-key"}, nil) } diff --git a/handlers/novo/novo.go b/handlers/novo/handler.go similarity index 88% rename from handlers/novo/novo.go rename to handlers/novo/handler.go index 0257800a7..6d10b5bc7 100644 --- a/handlers/novo/novo.go +++ b/handlers/novo/handler.go @@ -74,11 +74,11 @@ func (h *handler) receiveMessage(ctx context.Context, c courier.Channel, w http. // create and write the message msg := h.Backend().NewIncomingMsg(c, urn, body, "", clog).WithReceivedOn(time.Now().UTC()) - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { merchantID := msg.Channel().StringConfigForKey(configMerchantId, "") if merchantID == "" { return nil, fmt.Errorf("no merchant_id set for NV channel") @@ -89,7 +89,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no merchant_secret set for NV channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for _, part := range parts { from := strings.TrimPrefix(msg.Channel().Address(), "+") @@ -110,7 +110,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, err } - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -119,9 +119,9 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // we always get 204 on success if responseMsgStatus == "FINISHED" { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } else { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) clog.RawError(fmt.Errorf("received invalid response")) break } diff --git a/handlers/novo/novo_test.go b/handlers/novo/handler_test.go similarity index 90% rename from handlers/novo/novo_test.go rename to handlers/novo/handler_test.go index ab516548f..bfaa9ad0e 100644 --- a/handlers/novo/novo_test.go +++ b/handlers/novo/handler_test.go @@ -10,7 +10,7 @@ import ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "NV", "2020", "TT", map[string]interface{}{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "NV", "2020", "TT", map[string]any{ "merchant_id": "my-merchant-id", "merchant_secret": "my-merchant-secret", "secret": "sesame", @@ -21,7 +21,7 @@ const ( receiveURL = "/c/nv/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -49,19 +49,19 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL + "?%s" } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -115,13 +115,13 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "NV", "2020", "TT", - map[string]interface{}{ + map[string]any{ "merchant_id": "my-merchant-id", "merchant_secret": "my-merchant-secret", "secret": "sesame", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"my-merchant-secret", "sesame"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"my-merchant-secret", "sesame"}, nil) } diff --git a/handlers/playmobile/playmobile.go b/handlers/playmobile/handler.go similarity index 92% rename from handlers/playmobile/playmobile.go rename to handlers/playmobile/handler.go index 6d63d6aa6..91bf0d10d 100644 --- a/handlers/playmobile/playmobile.go +++ b/handlers/playmobile/handler.go @@ -3,7 +3,6 @@ package playmobile import ( "bytes" "context" - "encoding/json" "encoding/xml" "errors" "fmt" @@ -13,6 +12,7 @@ import ( "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/gocommon/jsonx" ) const ( @@ -105,7 +105,7 @@ func (h *handler) receiveMessage(ctx context.Context, c courier.Channel, w http. return nil, handlers.WriteAndLogRequestIgnored(ctx, h, c, w, r, "no messages, ignored") } - msgs := make([]courier.Msg, 0, 1) + msgs := make([]courier.MsgIn, 0, 1) // parse each inbound message for _, pmMsg := range payload.Message { @@ -146,7 +146,7 @@ func (h *handler) receiveMessage(ctx context.Context, c courier.Channel, w http. } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(configUsername, "") if username == "" { return nil, fmt.Errorf("no username set for PM channel") @@ -167,7 +167,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no base url set for PM channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) for i, part := range handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) { payload := mtPayload{} @@ -183,11 +183,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann message.SMS.Content.Text = part payload.Messages = append(payload.Messages, message) - jsonBody, err := json.Marshal(payload) - - if err != nil { - return nil, err - } + jsonBody := jsonx.MustMarshal(payload) req, err := http.NewRequest(http.MethodPost, fmt.Sprintf(sendURL, baseURL), bytes.NewReader(jsonBody)) if err != nil { @@ -197,12 +193,12 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil diff --git a/handlers/playmobile/playmobile_test.go b/handlers/playmobile/handler_test.go similarity index 91% rename from handlers/playmobile/playmobile_test.go rename to handlers/playmobile/handler_test.go index d4953ccec..b9013585c 100644 --- a/handlers/playmobile/playmobile_test.go +++ b/handlers/playmobile/handler_test.go @@ -11,7 +11,7 @@ import ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "PM", "1122", "UZ", map[string]interface{}{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "PM", "1122", "UZ", map[string]any{ "incoming_prefixes": []string{"abc", "DE"}, }), } @@ -27,8 +27,6 @@ var ( SMS Response Accepted ` - invalidXML = `` - noMessages = `` receiveWithPrefix = ` @@ -73,7 +71,7 @@ var ( }` ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -100,9 +98,9 @@ var testCases = []ChannelHandleTestCase{ { Label: "Invalid XML", URL: receiveURL, - Data: invalidXML, + Data: `<>`, ExpectedBodyContains: "", - ExpectedRespStatus: 405, + ExpectedRespStatus: 400, }, { Label: "Receive With Prefix", @@ -122,19 +120,19 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL + "?%s" } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ {Label: "Plain Send", MsgText: "Simple Message", MsgURN: "tel:99999999999", @@ -178,14 +176,14 @@ var defaultSendTestCases = []ChannelSendTestCase{ SendPrep: setSendURL}, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "PM", "1122", "UZ", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username", "shortcode": "1122", "base_url": "http://91.204.239.42", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("Username", "Password")}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("Username", "Password")}, nil) } diff --git a/handlers/plivo/plivo.go b/handlers/plivo/handler.go similarity index 89% rename from handlers/plivo/plivo.go rename to handlers/plivo/handler.go index 5499f4a15..48f0dce8f 100644 --- a/handlers/plivo/plivo.go +++ b/handlers/plivo/handler.go @@ -62,12 +62,12 @@ type statusForm struct { ParentMessageUUID string `name:"ParentMessageUUID"` } -var statusMapping = map[string]courier.MsgStatusValue{ - "queued": courier.MsgWired, - "delivered": courier.MsgDelivered, - "undelivered": courier.MsgSent, - "sent": courier.MsgSent, - "rejected": courier.MsgFailed, +var statusMapping = map[string]courier.MsgStatus{ + "queued": courier.MsgStatusWired, + "delivered": courier.MsgStatusDelivered, + "undelivered": courier.MsgStatusSent, + "sent": courier.MsgStatusSent, + "rejected": courier.MsgStatusFailed, } // receiveStatus is our HTTP handler function for status updates @@ -93,7 +93,7 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, externalID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, externalID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } @@ -124,7 +124,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w // create and write the message msg := h.Backend().NewIncomingMsg(channel, urn, form.Text, form.MessageUUID, clog) - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } type mtPayload struct { @@ -136,7 +136,7 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { authID := msg.Channel().StringConfigForKey(configPlivoAuthID, "") authToken := msg.Channel().StringConfigForKey(configPlivoAuthToken, "") plivoAppID := msg.Channel().StringConfigForKey(configPlivoAPPID, "") @@ -147,7 +147,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann callbackDomain := msg.Channel().CallbackDomain(h.Server().Config().Domain) statusURL := fmt.Sprintf("https://%s/c/pl/%s/status", callbackDomain, msg.Channel().UUID()) - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for i, part := range parts { payload := &mtPayload{ @@ -170,7 +170,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.SetBasicAuth(authID, authToken) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -186,7 +186,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/plivo/plivo_test.go b/handlers/plivo/handler_test.go similarity index 86% rename from handlers/plivo/plivo_test.go rename to handlers/plivo/handler_test.go index 5d00c3515..ee0da1e68 100644 --- a/handlers/plivo/plivo_test.go +++ b/handlers/plivo/handler_test.go @@ -29,21 +29,39 @@ var ( unknownStatus = "MessageUUID=12345&status=UNKNOWN&To=%2B60124361111&From=2020" ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ {Label: "Receive Valid", URL: receiveURL, Data: validReceive, ExpectedRespStatus: 200, ExpectedBodyContains: "Message Accepted", ExpectedMsgText: Sp("Hello"), ExpectedURN: "tel:+60124361111", ExpectedExternalID: "abc1234"}, {Label: "Invalid URN", URL: receiveURL, Data: invalidURN, ExpectedRespStatus: 400, ExpectedBodyContains: "phone number supplied is not a number"}, {Label: "Invalid Address Params", URL: receiveURL, Data: invalidAddress, ExpectedRespStatus: 400, ExpectedBodyContains: "invalid to number [1515], expecting [2020]"}, {Label: "Missing Params", URL: receiveURL, Data: missingParams, ExpectedRespStatus: 400, ExpectedBodyContains: "Field validation for 'To' failed"}, - {Label: "Valid Status", URL: statusURL, Data: validStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered}, - {Label: "Sent Status", URL: statusURL, Data: validSentStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, ExpectedMsgStatus: courier.MsgSent}, + { + Label: "Valid Status", + URL: statusURL, + Data: validStatus, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "12345", Status: courier.MsgStatusDelivered}, + }, + }, + { + Label: "Sent Status", + URL: statusURL, + Data: validSentStatus, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"S"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "12345", Status: courier.MsgStatusSent}, + }, + }, {Label: "Invalid Status Address", URL: statusURL, Data: invalidStatusAddress, ExpectedRespStatus: 400, ExpectedBodyContains: "invalid to number [1515], expecting [2020]"}, {Label: "Unkown Status", URL: statusURL, Data: unknownStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `ignoring unknown status 'UNKNOWN'`}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -51,11 +69,11 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL + "/%s/" } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ {Label: "Plain Send", MsgText: "Simple Message ☺", MsgURN: "tel:+250788383383", @@ -123,15 +141,15 @@ var defaultSendTestCases = []ChannelSendTestCase{ SendPrep: setSendURL}, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "PL", "2020", "US", - map[string]interface{}{ + map[string]any{ configPlivoAuthID: "AuthID", configPlivoAuthToken: "AuthToken", configPlivoAPPID: "AppID", }, ) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("AuthID", "AuthToken")}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("AuthID", "AuthToken")}, nil) } diff --git a/handlers/redrabbit/redrabbit.go b/handlers/redrabbit/handler.go similarity index 86% rename from handlers/redrabbit/redrabbit.go rename to handlers/redrabbit/handler.go index 59bfc586b..6543ae342 100644 --- a/handlers/redrabbit/redrabbit.go +++ b/handlers/redrabbit/handler.go @@ -36,7 +36,7 @@ func (h *handler) Initialize(s courier.Server) error { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "") if username == "" || password == "" { @@ -44,7 +44,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } text := handlers.GetTextAndAttachments(msg) - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) form := url.Values{ "LoginName": []string{username}, "Password": []string{password}, @@ -72,13 +72,13 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, err } - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } // all went well, set ourselves to wired - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/redrabbit/redrabbit_test.go b/handlers/redrabbit/handler_test.go similarity index 93% rename from handlers/redrabbit/redrabbit_test.go rename to handlers/redrabbit/handler_test.go index 2afa17eca..93593bca0 100644 --- a/handlers/redrabbit/redrabbit_test.go +++ b/handlers/redrabbit/handler_test.go @@ -10,11 +10,11 @@ import ( ) // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ {Label: "Plain Send", MsgText: "Simple Message", MsgURN: "tel:+250788383383", ExpectedMsgStatus: "W", @@ -97,13 +97,13 @@ var defaultSendTestCases = []ChannelSendTestCase{ SendPrep: setSendURL}, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "RR", "2020", "US", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username", }, ) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/responses.go b/handlers/responses.go index 2803f9e51..35f80b503 100644 --- a/handlers/responses.go +++ b/handlers/responses.go @@ -8,7 +8,7 @@ import ( ) // WriteMsgsAndResponse writes the passed in message to our backend -func WriteMsgsAndResponse(ctx context.Context, h courier.ChannelHandler, msgs []courier.Msg, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, error) { +func WriteMsgsAndResponse(ctx context.Context, h courier.ChannelHandler, msgs []courier.MsgIn, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, error) { events := make([]courier.Event, len(msgs)) for i, m := range msgs { err := h.Server().Backend().WriteMsg(ctx, m, clog) @@ -22,17 +22,13 @@ func WriteMsgsAndResponse(ctx context.Context, h courier.ChannelHandler, msgs [] } // WriteMsgStatusAndResponse write the passed in status to our backend -func WriteMsgStatusAndResponse(ctx context.Context, h courier.ChannelHandler, channel courier.Channel, status courier.MsgStatus, w http.ResponseWriter, r *http.Request) ([]courier.Event, error) { - err := h.Server().Backend().WriteMsgStatus(ctx, status) - if err == courier.ErrMsgNotFound { - return nil, WriteAndLogRequestIgnored(ctx, h, channel, w, r, "msg not found, ignored") - } - +func WriteMsgStatusAndResponse(ctx context.Context, h courier.ChannelHandler, channel courier.Channel, status courier.StatusUpdate, w http.ResponseWriter, r *http.Request) ([]courier.Event, error) { + err := h.Server().Backend().WriteStatusUpdate(ctx, status) if err != nil { return nil, err } - return []courier.Event{status}, h.WriteStatusSuccessResponse(ctx, w, []courier.MsgStatus{status}) + return []courier.Event{status}, h.WriteStatusSuccessResponse(ctx, w, []courier.StatusUpdate{status}) } // WriteAndLogRequestError logs the passed in error and writes the response to the response writer diff --git a/handlers/rocketchat/rocketchat.go b/handlers/rocketchat/handler.go similarity index 90% rename from handlers/rocketchat/rocketchat.go rename to handlers/rocketchat/handler.go index 1b07fed1a..a36eecd3c 100644 --- a/handlers/rocketchat/rocketchat.go +++ b/handlers/rocketchat/handler.go @@ -3,7 +3,6 @@ package rocketchat import ( "bytes" "context" - "encoding/json" "errors" "fmt" "net/http" @@ -11,6 +10,7 @@ import ( "github.com/buger/jsonparser" "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" ) @@ -79,7 +79,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w msg.WithAttachment(attachment.URL) } - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // BuildAttachmentRequest download media for message attachment with RC auth_token/user_id set @@ -105,13 +105,13 @@ type mtPayload struct { Attachments []Attachment `json:"attachments,omitempty"` } -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { baseURL := msg.Channel().StringConfigForKey(configBaseURL, "") secret := msg.Channel().StringConfigForKey(configSecret, "") botUsername := msg.Channel().StringConfigForKey(configBotUsername, "") // the status that will be written for this message - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) payload := &mtPayload{ UserURN: msg.URN().Path(), @@ -123,10 +123,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann payload.Attachments = append(payload.Attachments, Attachment{mimeType, url}) } - body, err := json.Marshal(payload) - if err != nil { - return status, err - } + body := jsonx.MustMarshal(payload) req, err := http.NewRequest(http.MethodPost, baseURL+"/message", bytes.NewReader(body)) if err != nil { @@ -135,7 +132,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Token %s", secret)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -145,6 +142,6 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann status.SetExternalID(msgID) } - status.SetStatus(courier.MsgSent) + status.SetStatus(courier.MsgStatusSent) return status, nil } diff --git a/handlers/rocketchat/rocketchat_test.go b/handlers/rocketchat/handler_test.go similarity index 90% rename from handlers/rocketchat/rocketchat_test.go rename to handlers/rocketchat/handler_test.go index fb37685c9..8c7d25c77 100644 --- a/handlers/rocketchat/rocketchat_test.go +++ b/handlers/rocketchat/handler_test.go @@ -16,7 +16,7 @@ const ( var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "RC", "1234", "", - map[string]interface{}{ + map[string]any{ configBaseURL: "https://my.rocket.chat/api/apps/public/684202ed-1461-4983-9ea7-fde74b15026c", configSecret: "123456789", configBotUsername: "rocket.cat", @@ -49,7 +49,7 @@ const attachmentMsg = `{ "attachments": [{"type": "image/jpg", "url": "https://link.to/image.jpg"}] }` -var testCases = []handlers.ChannelHandleTestCase{ +var testCases = []handlers.IncomingTestCase{ { Label: "Receive Hello Msg", URL: receiveURL, @@ -96,19 +96,19 @@ var testCases = []handlers.ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - handlers.RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + handlers.RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { handlers.RunChannelBenchmarks(b, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { c.(*test.MockChannel).SetConfig(configBaseURL, s.URL) } -var sendTestCases = []handlers.ChannelSendTestCase{ +var sendTestCases = []handlers.OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -145,6 +145,6 @@ var sendTestCases = []handlers.ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { - handlers.RunChannelSendTestCases(t, testChannels[0], newHandler(), sendTestCases, []string{"123456789"}, nil) +func TestOutgoing(t *testing.T) { + handlers.RunOutgoingTestCases(t, testChannels[0], newHandler(), sendTestCases, []string{"123456789"}, nil) } diff --git a/handlers/shaqodoon/shaqodoon.go b/handlers/shaqodoon/handler.go similarity index 89% rename from handlers/shaqodoon/shaqodoon.go rename to handlers/shaqodoon/handler.go index af8611192..6b77f3ef8 100644 --- a/handlers/shaqodoon/shaqodoon.go +++ b/handlers/shaqodoon/handler.go @@ -79,11 +79,11 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w // create and write the message msg := h.Backend().NewIncomingMsg(channel, urn, form.Text, "", clog).WithReceivedOn(date) - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { sendURL := msg.Channel().StringConfigForKey(courier.ConfigSendURL, "") if sendURL == "" { return nil, fmt.Errorf("missing send_url for SQ channel") @@ -113,13 +113,13 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) - resp, _, err := handlers.RequestHTTPInsecure(req, clog) + resp, _, err := h.RequestHTTPInsecure(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/shaqodoon/shaqodoon_test.go b/handlers/shaqodoon/handler_test.go similarity index 92% rename from handlers/shaqodoon/shaqodoon_test.go rename to handlers/shaqodoon/handler_test.go index 5697982fd..f2ed3f0bb 100644 --- a/handlers/shaqodoon/shaqodoon_test.go +++ b/handlers/shaqodoon/handler_test.go @@ -26,7 +26,7 @@ var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SQ", "2020", "US", nil), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ {Label: "Receive Valid Message", URL: receiveValidMessage, Data: "empty", ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedMsgText: Sp("Join"), ExpectedURN: "tel:+2349067554729"}, {Label: "Receive Badly Escaped", URL: receiveBadlyEscaped, Data: "empty", ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", @@ -43,19 +43,19 @@ var handleTestCases = []ChannelHandleTestCase{ {Label: "Receive Invalid Date", URL: receiveInvalidDate, Data: "empty", ExpectedRespStatus: 400, ExpectedBodyContains: "invalid date format, must be RFC 3339"}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler(), handleTestCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { c.(*test.MockChannel).SetConfig(courier.ConfigSendURL, s.URL) } -var getSendTestCases = []ChannelSendTestCase{ +var getSendTestCases = []OutgoingTestCase{ {Label: "Plain Send", MsgText: "Simple Message", MsgURN: "tel:+250788383383", ExpectedMsgStatus: "W", @@ -82,12 +82,12 @@ var getSendTestCases = []ChannelSendTestCase{ SendPrep: setSendURL}, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var getChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SQ", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigSendURL: "SendURL", courier.ConfigPassword: "Password", courier.ConfigUsername: "Username"}) - RunChannelSendTestCases(t, getChannel, newHandler(), getSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, getChannel, newHandler(), getSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/slack/slack.go b/handlers/slack/handler.go similarity index 90% rename from handlers/slack/slack.go rename to handlers/slack/handler.go index cd2092538..db4dcda1b 100644 --- a/handlers/slack/slack.go +++ b/handlers/slack/handler.go @@ -41,7 +41,7 @@ type handler struct { } func newHandler() courier.ChannelHandler { - return &handler{handlers.NewBaseHandlerWithParams(courier.ChannelType("SL"), "Slack", true, []string{configBotToken, configUserToken, configValidationToken})} + return &handler{handlers.NewBaseHandler(courier.ChannelType("SL"), "Slack", handlers.WithRedactConfigKeys(configBotToken, configUserToken, configValidationToken))} } func (h *handler) Initialize(s courier.Server) error { @@ -97,7 +97,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h msg.WithAttachment(attURL) } - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "Ignoring request, no message") } @@ -116,7 +116,7 @@ func (h *handler) resolveFile(ctx context.Context, channel courier.Channel, file req.Header.Add("Content-Type", "application/json; charset=utf-8") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", userToken)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return "", errors.New("unable to resolve file") } @@ -145,23 +145,23 @@ func (h *handler) resolveFile(ctx context.Context, channel courier.Channel, file return filePath, nil } -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { botToken := msg.Channel().StringConfigForKey(configBotToken, "") if botToken == "" { return nil, fmt.Errorf("missing bot token for SL/slack channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) for _, attachment := range msg.Attachments() { - fileAttachment, err := parseAttachmentToFileParams(msg, attachment, clog) + fileAttachment, err := h.parseAttachmentToFileParams(msg, attachment, clog) if err != nil { clog.RawError(err) return status, nil } if fileAttachment != nil { - err = sendFilePart(msg, botToken, fileAttachment, clog) + err = h.sendFilePart(msg, botToken, fileAttachment, clog) if err != nil { clog.RawError(err) return status, nil @@ -170,26 +170,26 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } if len(msg.QuickReplies()) != 0 { - _, err := sendQuickReplies(msg, botToken, clog) + _, err := h.sendQuickReplies(msg, botToken, clog) if err != nil { clog.RawError(err) return status, nil } } - if msg.Text() != "" && len(msg.QuickReplies()) == 0 { - err := sendTextMsgPart(msg, botToken, clog) + if msg.Text() != "" { + err := h.sendTextMsgPart(msg, botToken, clog) if err != nil { clog.RawError(err) return status, nil } } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } -func sendTextMsgPart(msg courier.Msg, token string, clog *courier.ChannelLog) error { +func (h *handler) sendTextMsgPart(msg courier.MsgOut, token string, clog *courier.ChannelLog) error { sendURL := apiURL + "/chat.postMessage" msgPayload := &mtPayload{ @@ -209,7 +209,7 @@ func sendTextMsgPart(msg courier.Msg, token string, clog *courier.ChannelLog) er req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return errors.New("error sending message") } @@ -229,7 +229,7 @@ func sendTextMsgPart(msg courier.Msg, token string, clog *courier.ChannelLog) er return nil } -func parseAttachmentToFileParams(msg courier.Msg, attachment string, clog *courier.ChannelLog) (*FileParams, error) { +func (h *handler) parseAttachmentToFileParams(msg courier.MsgOut, attachment string, clog *courier.ChannelLog) (*FileParams, error) { _, attURL := handlers.SplitAttachment(attachment) req, err := http.NewRequest(http.MethodGet, attURL, nil) @@ -237,7 +237,7 @@ func parseAttachmentToFileParams(msg courier.Msg, attachment string, clog *couri return nil, errors.Wrapf(err, "error building file request") } - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return nil, errors.New("error fetching attachment") } @@ -249,7 +249,7 @@ func parseAttachmentToFileParams(msg courier.Msg, attachment string, clog *couri return &FileParams{File: respBody, FileName: filename, Channels: msg.URN().Path()}, nil } -func sendFilePart(msg courier.Msg, token string, fileParams *FileParams, clog *courier.ChannelLog) error { +func (h *handler) sendFilePart(msg courier.MsgOut, token string, fileParams *FileParams, clog *courier.ChannelLog) error { uploadURL := apiURL + "/files.upload" body := &bytes.Buffer{} @@ -281,7 +281,7 @@ func sendFilePart(msg courier.Msg, token string, fileParams *FileParams, clog *c req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) req.Header.Add("Content-Type", writer.FormDataContentType()) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return errors.New("error uploading file to slack") } @@ -297,7 +297,7 @@ func sendFilePart(msg courier.Msg, token string, fileParams *FileParams, clog *c return nil } -func sendQuickReplies(msg courier.Msg, botToken string, clog *courier.ChannelLog) (*courier.ChannelLog, error) { +func (h *handler) sendQuickReplies(msg courier.MsgOut, botToken string, clog *courier.ChannelLog) (*courier.ChannelLog, error) { sendURL := apiURL + "/chat.postMessage" payload := &mtPayload{ @@ -345,7 +345,7 @@ func sendQuickReplies(msg courier.Msg, botToken string, clog *courier.ChannelLog req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", botToken)) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return clog, errors.New("error uploading file to slack") } @@ -374,7 +374,7 @@ func (h *handler) DescribeURN(ctx context.Context, channel courier.Channel, urn q.Add("user", urn.Path()) req.URL.RawQuery = q.Encode() - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return nil, errors.New("unable to look up user info") } diff --git a/handlers/slack/slack_test.go b/handlers/slack/handler_test.go similarity index 89% rename from handlers/slack/slack_test.go rename to handlers/slack/handler_test.go index 1027d6e25..1f24fde97 100644 --- a/handlers/slack/slack_test.go +++ b/handlers/slack/handler_test.go @@ -25,7 +25,7 @@ const ( ) var testChannels = []courier.Channel{ - test.NewMockChannel(channelUUID, "SL", "2022", "US", map[string]interface{}{"bot_token": "xoxb-abc123", "verification_token": "one-long-verification-token"}), + test.NewMockChannel(channelUUID, "SL", "2022", "US", map[string]any{"bot_token": "xoxb-abc123", "verification_token": "one-long-verification-token"}), } const helloMsg = `{ @@ -124,11 +124,11 @@ const videoFileMsg = `{ "event_time": 1653427243 }` -func setSendUrl(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { apiURL = s.URL } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Hello Msg", URL: receiveURL, @@ -177,7 +177,7 @@ var handleTestCases = []ChannelHandleTestCase{ }, } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -186,7 +186,7 @@ var defaultSendTestCases = []ChannelSendTestCase{ MockResponseStatus: 200, ExpectedRequestBody: `{"channel":"U0123ABCDEF","text":"Simple Message"}`, ExpectedMsgStatus: "W", - SendPrep: setSendUrl, + SendPrep: setSendURL, }, { Label: "Unicode Send", @@ -196,7 +196,7 @@ var defaultSendTestCases = []ChannelSendTestCase{ MockResponseStatus: 200, ExpectedRequestBody: `{"channel":"U0123ABCDEF","text":"☺"}`, ExpectedMsgStatus: "W", - SendPrep: setSendUrl, + SendPrep: setSendURL, }, { Label: "Send Text Auth Error", @@ -207,11 +207,11 @@ var defaultSendTestCases = []ChannelSendTestCase{ ExpectedRequestBody: `{"channel":"U0123ABCDEF","text":"Hello"}`, ExpectedMsgStatus: "E", ExpectedErrors: []*courier.ChannelError{courier.NewChannelError("", "", "invalid_auth")}, - SendPrep: setSendUrl, + SendPrep: setSendURL, }, } -var fileSendTestCases = []ChannelSendTestCase{ +var fileSendTestCases = []OutgoingTestCase{ { Label: "Send Image", MsgText: "", @@ -225,7 +225,7 @@ var fileSendTestCases = []ChannelSendTestCase{ }: httpx.NewMockResponse(200, nil, []byte(`{"ok":true,"file":{"id":"F1L3SL4CK1D"}}`)), }, ExpectedMsgStatus: "W", - SendPrep: setSendUrl, + SendPrep: setSendURL, }, { Label: "Send Image", @@ -240,19 +240,19 @@ var fileSendTestCases = []ChannelSendTestCase{ }: httpx.NewMockResponse(200, nil, []byte(`{"ok":true,"file":{"id":"F1L3SL4CK1D"}}`)), }, ExpectedMsgStatus: "W", - SendPrep: setSendUrl, + SendPrep: setSendURL, }, } -func TestHandler(t *testing.T) { +func TestIncoming(t *testing.T) { slackServiceMock := buildMockSlackService(handleTestCases) defer slackServiceMock.Close() - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } -func TestSending(t *testing.T) { - RunChannelSendTestCases(t, testChannels[0], newHandler(), defaultSendTestCases, []string{"xoxb-abc123", "one-long-verification-token"}, nil) +func TestOutgoing(t *testing.T) { + RunOutgoingTestCases(t, testChannels[0], newHandler(), defaultSendTestCases, []string{"xoxb-abc123", "one-long-verification-token"}, nil) } func TestSendFiles(t *testing.T) { @@ -260,11 +260,11 @@ func TestSendFiles(t *testing.T) { defer fileServer.Close() fileSendTestCases := mockAttachmentURLs(fileServer, fileSendTestCases) - RunChannelSendTestCases(t, testChannels[0], newHandler(), fileSendTestCases, []string{"xoxb-abc123", "one-long-verification-token"}, nil) + RunOutgoingTestCases(t, testChannels[0], newHandler(), fileSendTestCases, []string{"xoxb-abc123", "one-long-verification-token"}, nil) } func TestVerification(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), []ChannelHandleTestCase{ + RunIncomingTestCases(t, testChannels, newHandler(), []IncomingTestCase{ {Label: "Valid token", URL: receiveURL, ExpectedRespStatus: 200, Data: `{"token":"one-long-verification-token","challenge":"challenge123","type":"url_verification"}`, Headers: map[string]string{"content-type": "text/plain"}, @@ -285,7 +285,7 @@ func buildMockAttachmentFileServer() *httptest.Server { })) } -func buildMockSlackService(testCases []ChannelHandleTestCase) *httptest.Server { +func buildMockSlackService(testCases []IncomingTestCase) *httptest.Server { files := make(map[string]File) @@ -342,8 +342,8 @@ func buildMockSlackService(testCases []ChannelHandleTestCase) *httptest.Server { return server } -func mockAttachmentURLs(fileServer *httptest.Server, testCases []ChannelSendTestCase) []ChannelSendTestCase { - casesWithMockedUrls := make([]ChannelSendTestCase, len(testCases)) +func mockAttachmentURLs(fileServer *httptest.Server, testCases []OutgoingTestCase) []OutgoingTestCase { + casesWithMockedUrls := make([]OutgoingTestCase, len(testCases)) for i, testCase := range testCases { mockedCase := testCase @@ -356,10 +356,11 @@ func mockAttachmentURLs(fileServer *httptest.Server, testCases []ChannelSendTest } func TestDescribeURN(t *testing.T) { - server := buildMockSlackService([]ChannelHandleTestCase{}) + server := buildMockSlackService([]IncomingTestCase{}) defer server.Close() handler := newHandler() + handler.Initialize(test.NewMockServer(courier.NewConfig(), test.NewMockBackend())) clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, testChannels[0], handler.RedactValues(testChannels[0])) urn, _ := urns.NewURNFromParts(urns.SlackScheme, "U012345", "", "") diff --git a/handlers/smscentral/smscentral.go b/handlers/smscentral/handler.go similarity index 86% rename from handlers/smscentral/smscentral.go rename to handlers/smscentral/handler.go index 1545401ec..51750406c 100644 --- a/handlers/smscentral/smscentral.go +++ b/handlers/smscentral/handler.go @@ -59,11 +59,11 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w // build our msg msg := h.Backend().NewIncomingMsg(channel, urn, form.Message, "", clog) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for SC channel") @@ -74,7 +74,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no password set for SC channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) // build our request form := url.Values{ @@ -90,12 +90,12 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/smscentral/smscentral_test.go b/handlers/smscentral/handler_test.go similarity index 87% rename from handlers/smscentral/smscentral_test.go rename to handlers/smscentral/handler_test.go index 58e803f4b..7a91cabb5 100644 --- a/handlers/smscentral/smscentral_test.go +++ b/handlers/smscentral/handler_test.go @@ -14,10 +14,10 @@ const ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SC", "2020", "US", map[string]interface{}{"username": "Username", "password": "Password"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SC", "2020", "US", map[string]any{"username": "Username", "password": "Password"}), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: receiveURL, @@ -59,8 +59,8 @@ var handleTestCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { @@ -68,11 +68,11 @@ func BenchmarkHandler(b *testing.B) { } // setSend takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ {Label: "Plain Send", MsgText: "Simple Message", MsgURN: "tel:+250788383383", ExpectedMsgStatus: "W", @@ -99,12 +99,12 @@ var defaultSendTestCases = []ChannelSendTestCase{ SendPrep: setSendURL}, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SC", "2020", "US", - map[string]interface{}{ + map[string]any{ courier.ConfigPassword: "Password", courier.ConfigUsername: "Username", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password"}, nil) } diff --git a/handlers/split.go b/handlers/split.go new file mode 100644 index 000000000..2195ba908 --- /dev/null +++ b/handlers/split.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "bytes" + "strings" + + "github.com/nyaruka/courier" + "golang.org/x/exp/slices" +) + +type MsgPartType int + +const ( + MsgPartTypeText MsgPartType = iota + MsgPartTypeAttachment + MsgPartTypeCaptionedAttachment + MsgPartTypeOptIn +) + +// MsgPart represents a message part - either Text or Attachment will be set +type MsgPart struct { + Type MsgPartType + Text string + Attachment string + OptIn *courier.OptInReference + IsFirst bool + IsLast bool +} + +type SplitOptions struct { + MaxTextLen int + MaxCaptionLen int + Captionable []MediaType +} + +// SplitMsg splits an outgoing message into separate text and attachment parts, with attachment parts first. +func SplitMsg(m courier.MsgOut, opts SplitOptions) []MsgPart { + text := m.Text() + attachments := m.Attachments() + + if m.OptIn() != nil { + return []MsgPart{{Type: MsgPartTypeOptIn, Text: text, OptIn: m.OptIn(), IsFirst: true, IsLast: true}} + } + + // if we have a single attachment and text we may be able to combine them into a captioned attachment + if len(attachments) == 1 && len(text) > 0 && (len(text) <= opts.MaxCaptionLen || opts.MaxCaptionLen == 0) { + att := attachments[0] + mediaType, _ := SplitAttachment(att) + mediaType = strings.Split(mediaType, "/")[0] + if slices.Contains(opts.Captionable, MediaType(mediaType)) { + return []MsgPart{{Type: MsgPartTypeCaptionedAttachment, Text: text, Attachment: attachments[0], IsFirst: true, IsLast: true}} + } + } + + parts := make([]MsgPart, 0, 5) + + for _, a := range attachments { + parts = append(parts, MsgPart{Type: MsgPartTypeAttachment, Attachment: a}) + } + for _, t := range SplitMsgByChannel(m.Channel(), text, opts.MaxTextLen) { + if len(t) > 0 { + parts = append(parts, MsgPart{Type: MsgPartTypeText, Text: t}) + } + } + + if len(parts) > 0 { + parts[0].IsFirst = true + parts[len(parts)-1].IsLast = true + } + + return parts +} + +// deprecated use SplitMsg instead +func SplitMsgByChannel(channel courier.Channel, text string, maxLength int) []string { + max := channel.IntConfigForKey(courier.ConfigMaxLength, maxLength) + + return SplitText(text, max) +} + +// SplitText splits the passed in string into segments that are at most max length +func SplitText(text string, max int) []string { + // smaller than our max, just return it + if len(text) <= max { + return []string{text} + } + + parts := make([]string, 0, 2) + part := bytes.Buffer{} + for _, r := range text { + part.WriteRune(r) + if part.Len() == max || (part.Len() > max-6 && r == ' ') { + parts = append(parts, strings.TrimSpace(part.String())) + part.Reset() + } + } + if part.Len() > 0 { + parts = append(parts, strings.TrimSpace(part.String())) + } + + return parts +} diff --git a/handlers/split_test.go b/handlers/split_test.go new file mode 100644 index 000000000..b62d3b349 --- /dev/null +++ b/handlers/split_test.go @@ -0,0 +1,85 @@ +package handlers_test + +import ( + "testing" + + "github.com/nyaruka/courier" + "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/courier/test" + "github.com/stretchr/testify/assert" +) + +func TestSplitMsg(t *testing.T) { + var channel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "AC", "2020", "US", nil) + + tcs := []struct { + msg courier.MsgOut + opts handlers.SplitOptions + expectedParts []handlers.MsgPart + }{ + { + msg: test.NewMockMsg(1001, "b6454f25-e5b9-4795-a180-b9e35ca3a523", channel, "tel+1234567890", "This is a message longer than 10", nil), + opts: handlers.SplitOptions{MaxTextLen: 20}, + expectedParts: []handlers.MsgPart{ + {Type: handlers.MsgPartTypeText, Text: "This is a message", IsFirst: true}, + {Type: handlers.MsgPartTypeText, Text: "longer than 10", IsLast: true}, + }, + }, + { + msg: test.NewMockMsg(1001, "b6454f25-e5b9-4795-a180-b9e35ca3a523", channel, "tel+1234567890", "Lovely image", []string{"image/jpeg:http://test.jpg"}), + opts: handlers.SplitOptions{MaxTextLen: 20}, + expectedParts: []handlers.MsgPart{ + {Type: handlers.MsgPartTypeAttachment, Attachment: "image/jpeg:http://test.jpg", IsFirst: true}, + {Type: handlers.MsgPartTypeText, Text: "Lovely image", IsLast: true}, + }, + }, + { + msg: test.NewMockMsg(1001, "b6454f25-e5b9-4795-a180-b9e35ca3a523", channel, "tel+1234567890", "Lovely image", []string{"image/jpeg:http://test.jpg"}), + opts: handlers.SplitOptions{MaxTextLen: 20, Captionable: []handlers.MediaType{handlers.MediaTypeImage}}, + expectedParts: []handlers.MsgPart{ + {Type: handlers.MsgPartTypeCaptionedAttachment, Text: "Lovely image", Attachment: "image/jpeg:http://test.jpg", IsFirst: true, IsLast: true}, + }, + }, + } + + for _, tc := range tcs { + actualParts := handlers.SplitMsg(tc.msg, tc.opts) + assert.Equal(t, tc.expectedParts, actualParts) + } + +} + +func TestSplitMsgByChannel(t *testing.T) { + var channelWithMaxLength = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "AC", "2020", "US", + map[string]any{ + courier.ConfigUsername: "user1", + courier.ConfigPassword: "pass1", + courier.ConfigMaxLength: 25, + }) + var channelWithoutMaxLength = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "AC", "2020", "US", + map[string]any{ + courier.ConfigUsername: "user1", + courier.ConfigPassword: "pass1", + }) + + assert.Equal(t, []string{""}, handlers.SplitMsgByChannel(channelWithoutMaxLength, "", 160)) + assert.Equal(t, []string{"Simple message"}, handlers.SplitMsgByChannel(channelWithoutMaxLength, "Simple message", 160)) + assert.Equal(t, []string{"This is a message", "longer than 10"}, handlers.SplitMsgByChannel(channelWithoutMaxLength, "This is a message longer than 10", 20)) + assert.Equal(t, []string{" "}, handlers.SplitMsgByChannel(channelWithoutMaxLength, " ", 20)) + assert.Equal(t, []string{"This is a message", "longer than 10"}, handlers.SplitMsgByChannel(channelWithoutMaxLength, "This is a message longer than 10", 20)) + + // Max length should be the one configured on the channel + assert.Equal(t, []string{""}, handlers.SplitMsgByChannel(channelWithMaxLength, "", 160)) + assert.Equal(t, []string{"Simple message"}, handlers.SplitMsgByChannel(channelWithMaxLength, "Simple message", 160)) + assert.Equal(t, []string{"This is a message longer", "than 10"}, handlers.SplitMsgByChannel(channelWithMaxLength, "This is a message longer than 10", 20)) + assert.Equal(t, []string{" "}, handlers.SplitMsgByChannel(channelWithMaxLength, " ", 20)) + assert.Equal(t, []string{"This is a message", "longer than 10"}, handlers.SplitMsgByChannel(channelWithMaxLength, "This is a message longer than 10", 20)) +} + +func TestSplitText(t *testing.T) { + assert.Equal(t, []string{""}, handlers.SplitText("", 160)) + assert.Equal(t, []string{"Simple message"}, handlers.SplitText("Simple message", 160)) + assert.Equal(t, []string{"This is a message", "longer than 10"}, handlers.SplitText("This is a message longer than 10", 20)) + assert.Equal(t, []string{" "}, handlers.SplitText(" ", 20)) + assert.Equal(t, []string{"This is a message", "longer than 10"}, handlers.SplitText("This is a message longer than 10", 20)) +} diff --git a/handlers/start/start.go b/handlers/start/handler.go similarity index 90% rename from handlers/start/start.go rename to handlers/start/handler.go index 6a0a13e4c..67336b83f 100644 --- a/handlers/start/start.go +++ b/handlers/start/handler.go @@ -82,14 +82,14 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w date := time.Unix(ts, 0).UTC() // build our msg - msg := h.Backend().NewIncomingMsg(channel, urn, payload.Body.Text, "", clog).WithReceivedOn(date) + msg := h.Backend().NewIncomingMsg(channel, urn, payload.Body.Text, payload.Service.RequestID, clog).WithReceivedOn(date) // and write it - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // Start Mobile expects a XML response from a message receive request -func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { w.Header().Set("Content-Type", "text/xml") w.WriteHeader(200) _, err := fmt.Fprint(w, `Accepted`) @@ -121,7 +121,7 @@ type mtResponse struct { State string `xml:"state"` } -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for ST channel: %s", msg.Channel().UUID()) @@ -132,7 +132,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no password set for ST channel: %s", msg.Channel().UUID()) } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for i, part := range parts { @@ -164,7 +164,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/xml; charset=utf8") req.SetBasicAuth(username, password) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -172,7 +172,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann response := &mtResponse{} err = xml.Unmarshal(respBody, response) if err == nil { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) if i == 0 { status.SetExternalID(response.ID) } diff --git a/handlers/start/start_test.go b/handlers/start/handler_test.go similarity index 92% rename from handlers/start/start_test.go rename to handlers/start/handler_test.go index 746e7f052..6f415ca7c 100644 --- a/handlers/start/start_test.go +++ b/handlers/start/handler_test.go @@ -12,7 +12,7 @@ import ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ST", "2020", "UA", map[string]interface{}{"username": "st-username", "password": "st-password"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ST", "2020", "UA", map[string]any{"username": "st-username", "password": "st-password"}), } const ( @@ -72,7 +72,7 @@ const ( ` ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -82,6 +82,7 @@ var testCases = []ChannelHandleTestCase{ ExpectedMsgText: Sp("Hello World"), ExpectedURN: "tel:+250788123123", ExpectedDate: time.Date(2015, 12, 18, 15, 02, 54, 0, time.UTC), + ExpectedExternalID: "msg1", }, { Label: "Receive Valid Encoded", @@ -92,6 +93,7 @@ var testCases = []ChannelHandleTestCase{ ExpectedMsgText: Sp("Кохання"), ExpectedURN: "tel:+380501529999", ExpectedDate: time.Date(2015, 12, 18, 15, 02, 54, 0, time.UTC), + ExpectedExternalID: "43473486", }, { Label: "Receive Valid with empty Text", @@ -101,6 +103,7 @@ var testCases = []ChannelHandleTestCase{ ExpectedBodyContains: "Accepted", ExpectedMsgText: Sp(""), ExpectedURN: "tel:+250788123123", + ExpectedExternalID: "msg1", }, { Label: "Receive Valid missing body", @@ -148,8 +151,8 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -157,11 +160,11 @@ func BenchmarkHandler(b *testing.B) { } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -238,8 +241,8 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 - var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ST", "2020", "UA", map[string]interface{}{"username": "Username", "password": "Password"}) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("Username", "Password")}, nil) + var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ST", "2020", "UA", map[string]any{"username": "Username", "password": "Password"}) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("Username", "Password")}, nil) } diff --git a/handlers/teams/teams.go b/handlers/teams/teams.go deleted file mode 100644 index ae18d2d62..000000000 --- a/handlers/teams/teams.go +++ /dev/null @@ -1,414 +0,0 @@ -package teams - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "time" - - "github.com/buger/jsonparser" - "github.com/golang-jwt/jwt/v4" - "github.com/lestrrat-go/jwx/jwk" - "github.com/nyaruka/courier" - "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/utils" - "github.com/nyaruka/gocommon/urns" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" -) - -var ( - jv = JwtTokenValidator{} - AllowedSigningAlgorithms = []string{"RS256", "RS384", "RS512"} - ToBotFromChannelTokenIssuer = "https://api.botframework.com" - jwks_uri = "https://login.botframework.com/v1/.well-known/keys" -) - -const fetchTimeout = 20 - -func init() { - courier.RegisterHandler(newHandler()) -} - -type handler struct { - handlers.BaseHandler -} - -func newHandler() courier.ChannelHandler { - return &handler{handlers.NewBaseHandler(courier.ChannelType("TM"), "Teams")} -} - -func (h *handler) Initialize(s courier.Server) error { - h.SetServer(s) - s.AddHandlerRoute(h, http.MethodPost, "receive", courier.ChannelLogTypeMsgReceive, h.receiveEvent) - return nil -} - -type metadata struct { - JwksURI string `json:"jwks_uri"` -} - -type Keys struct { - Keys struct { - Kty string `json:"kty"` - Kid string `json:"kid"` - Endorsements []string `json:"endorsements"` - } -} - -// AuthCache is a general purpose cache -type AuthCache struct { - Keys interface{} - Expiry time.Time -} - -// JwtTokenValidator is the default implementation of TokenValidator. -type JwtTokenValidator struct { - AuthCache -} - -// IsExpired checks if the Keys have expired. -// Compares Expiry time with current time. -func (cache *AuthCache) IsExpired() bool { - - if diff := time.Now().Sub(cache.Expiry).Hours(); diff > 0 { - return true - } - return false -} - -func validateToken(channel courier.Channel, w http.ResponseWriter, r *http.Request) error { - tokenH := r.Header.Get("Authorization") - tokenHeader := strings.Replace(tokenH, "Bearer ", "", 1) - getKey := func(token *jwt.Token) (interface{}, error) { - // Get new JWKs if the cache is expired - if jv.AuthCache.IsExpired() { - - ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout*time.Second) - defer cancel() - set, err := jwk.Fetch(ctx, jwks_uri) - if err != nil { - return nil, err - } - // Update the cache - // The expiry time is set to be of 5 days - jv.AuthCache = AuthCache{ - Keys: set, - Expiry: time.Now().Add(time.Hour * 24 * 5), - } - } - - keyID, ok := token.Header["kid"].(string) - if !ok { - return nil, fmt.Errorf("Expecting JWT header to have string kid") - } - - // Return cached JWKs - key, ok := jv.AuthCache.Keys.(jwk.Set).LookupKeyID(keyID) - if ok { - var rawKey interface{} - err := key.Raw(&rawKey) - if err != nil { - return nil, err - } - return rawKey, nil - } - return nil, fmt.Errorf("Could not find public key") - } - - token, _ := jwt.Parse(tokenHeader, getKey) - - // Check allowed signing algorithms - alg := token.Header["alg"] - isAllowed := func() bool { - for _, allowed := range AllowedSigningAlgorithms { - if allowed == alg { - return true - } - } - return false - }() - if !isAllowed { - return fmt.Errorf("Unauthorized. Invalid signing algorithm") - } - - issuer := token.Claims.(jwt.MapClaims)["iss"].(string) - - if issuer != ToBotFromChannelTokenIssuer { - return fmt.Errorf("Unauthorized, invalid token issuer") - } - - audience := token.Claims.(jwt.MapClaims)["aud"].(string) - appID := channel.StringConfigForKey("appID", "") - - if audience != appID { - return fmt.Errorf("Unauthorized: invalid AppId passed on token") - } - - return nil -} - -func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, clog *courier.ChannelLog) ([]courier.Event, error) { - payload := &Activity{} - err := handlers.DecodeAndValidateJSON(payload, r) - if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) - } - - err = validateToken(channel, w, r) - if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) - } - - path := strings.Split(payload.ServiceURL, "//") - serviceURL := path[1] - - var urn urns.URN - - // the list of events we deal with - events := make([]courier.Event, 0, 2) - - // the list of data we will return in our response - data := make([]interface{}, 0, 2) - - date, err := time.Parse(time.RFC3339, payload.Timestamp) - if err != nil { - return nil, err - } - - if payload.Type == "message" { - sender := strings.Split(payload.Conversation.ID, "a:") - - urn, err = urns.NewTeamsURN(sender[1] + ":" + path[1]) - if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) - } - - text := payload.Text - attachmentURLs := make([]string, 0, 2) - - for _, att := range payload.Attachments { - if att.ContentType != "" && att.ContentURL != "" { - attachmentURLs = append(attachmentURLs, att.ContentURL) - } - } - - event := h.Backend().NewIncomingMsg(channel, urn, text, payload.ID, 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, err - } - - events = append(events, event) - data = append(data, courier.NewMsgReceiveData(event)) - } - - if payload.Type == "conversationUpdate" { - userID := payload.MembersAdded[0].ID - - if userID == "" { - return nil, nil - } - - bot := ChannelAccount{} - - bot.ID = channel.StringConfigForKey("botID", "") - bot.Role = "bot" - - members := []ChannelAccount{} - - members = append(members, ChannelAccount{ID: userID, Role: payload.MembersAdded[0].Role}) - - ConversationJson := &mtPayload{ - Bot: bot, - Members: members, - IsGroup: false, - } - jsonBody, err := json.Marshal(ConversationJson) - if err != nil { - return nil, err - } - token := channel.StringConfigForKey(courier.ConfigAuthToken, "") - req, err := http.NewRequest(http.MethodPost, payload.ServiceURL+"/v3/conversations", bytes.NewReader(jsonBody)) - - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - - resp, respBody, err := handlers.RequestHTTP(req, clog) - if err != nil || resp.StatusCode/100 != 2 { - return nil, errors.New("unable to look up contact data") - } - - var body ConversationAccount - - err = json.Unmarshal(respBody, &body) - if err != nil { - return nil, err - } - conversationID := strings.Split(body.ID, "a:") - urn, err = urns.NewTeamsURN(conversationID[1] + ":" + serviceURL) - if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) - } - - event := h.Backend().NewChannelEvent(channel, courier.NewConversation, urn, clog).WithOccurredOn(date) - events = append(events, event) - data = append(data, courier.NewEventReceiveData(event)) - } - // Ignore activity of type messageReaction - if payload.Type == "messageReaction" { - data = append(data, courier.NewInfoData("ignoring messageReaction")) - } - - return events, courier.WriteDataResponse(w, http.StatusOK, "Events Handled", data) -} - -type mtPayload struct { - Activity Activity `json:"activity"` - TopicName string `json:"topicname,omitempty"` - Bot ChannelAccount `json:"bot,omitempty"` - Members []ChannelAccount `json:"members,omitempty"` - IsGroup bool `json:"isGroup,omitempty"` - TenantId string `json:"tenantId,omitempty"` - ChannelData ChannelData `json:"channelData,omitempty"` -} - -type ChannelData struct { - AadObjectId string `json:"aadObjectId"` - Tenant struct { - ID string `json:"id"` - } `json:"tenant"` -} - -type ChannelAccount struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - Role string `json:"role"` - AadObjectId string `json:"aadObjectId,omitempty"` -} - -type ConversationAccount struct { - ID string `json:"id"` - ConversationType string `json:"conversationType"` - TenantID string `json:"tenantId"` - Role string `json:"role"` - Name string `json:"name"` - IsGroup bool `json:"isGroup"` - AadObjectId string `json:"aadObjectId"` -} - -type mtAttachment struct { - ContentType string `json:"contentType"` - ContentURL string `json:"contentUrl"` - Name string `json:"name,omitempty"` -} - -type Activity struct { - Action string `json:"action,omitempty"` - Attachments []mtAttachment `json:"attachments,omitempty"` - ChannelID string `json:"channelId,omitempty"` - Conversation ConversationAccount `json:"conversation,omitempty"` - ID string `json:"id,omitempty"` - MembersAdded []ChannelAccount `json:"membersAdded,omitempty"` - Name string `json:"name,omitempty"` - Recipient ChannelAccount `json:"recipient,omitempty"` - ServiceURL string `json:"serviceUrl,omitempty"` - Text string `json:"text"` - Type string `json:"type"` - Timestamp string `json:"timestamp,omitempty"` -} - -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { - - token := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") - if token == "" { - return nil, fmt.Errorf("missing token for TM channel") - } - - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) - - payload := Activity{} - - path := strings.Split(msg.URN().Path(), ":") - conversationID := path[1] - - msgURL := msg.URN().TeamsServiceURL() + "v3/conversations/a:" + conversationID + "/activities" - - for _, attachment := range msg.Attachments() { - attType, attURL := handlers.SplitAttachment(attachment) - filename, err := utils.BasePathForURL(attURL) - if err != nil { - logrus.WithField("channel_uuid", msg.Channel().UUID()).WithError(err).Error("Error while parsing the media URL") - } - payload.Attachments = append(payload.Attachments, mtAttachment{attType, attURL, filename}) - } - - if msg.Text() != "" { - payload.Type = "message" - payload.Text = msg.Text() - } - - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, err - } - - req, err := http.NewRequest(http.MethodPost, msgURL, bytes.NewReader(jsonBody)) - - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - - _, respBody, err := handlers.RequestHTTP(req, clog) - if err != nil { - return status, err - } - status.SetStatus(courier.MsgWired) - externalID, err := jsonparser.GetString(respBody, "id") - if err != nil { - logrus.WithError(errors.Errorf("unable to get message_id from body")) - return status, nil - } - status.SetExternalID(externalID) - return status, nil -} - -func (h *handler) DescribeURN(ctx context.Context, channel courier.Channel, urn urns.URN, clog *courier.ChannelLog) (map[string]string, error) { - - accessToken := channel.StringConfigForKey(courier.ConfigAuthToken, "") - if accessToken == "" { - return nil, fmt.Errorf("missing access token") - } - - // build a request to lookup the stats for this contact - pathSplit := strings.Split(urn.Path(), ":") - conversationID := pathSplit[1] - url := urn.TeamsServiceURL() + "v3/conversations/a:" + conversationID + "/members" - - req, _ := http.NewRequest(http.MethodGet, url, nil) - req.Header.Set("Authorization", "Bearer "+accessToken) - resp, respBody, err := handlers.RequestHTTP(req, clog) - if err != nil { - return nil, fmt.Errorf("unable to look up contact data:%v\n%v", err, resp) - } - - // read our first and last name - givenName, _ := jsonparser.GetString(respBody, "[0]", "givenName") - surname, _ := jsonparser.GetString(respBody, "[0]", "surname") - - return map[string]string{"name": utils.JoinNonEmpty(" ", givenName, surname)}, nil -} diff --git a/handlers/teams/teams_test.go b/handlers/teams/teams_test.go deleted file mode 100644 index 20b819892..000000000 --- a/handlers/teams/teams_test.go +++ /dev/null @@ -1,348 +0,0 @@ -//go:build ignore - -package teams - -import ( - "context" - "io" - "log" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/buger/jsonparser" - "github.com/nyaruka/courier" - . "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/test" - "github.com/nyaruka/gocommon/urns" - "gopkg.in/go-playground/assert.v1" -) - -var access_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYzEyMyJ9.eyJpc3MiOiJodHRwczovL2FwaS5ib3RmcmFtZXdvcmsuY29tIiwic2VydmljZXVybCI6Imh0dHBzOi8vc21iYS50cmFmZmljbWFuYWdlci5uZXQvYnIvIiwiYXVkIjoiMTU5NiJ9.hqKdNdlB0NX6jtwkN96jI-kIiWTWPDIA1K7oo56tVsRBmMycyNNHrsGbKrEw7dccLjATmimpk4x0J_umaJZ5mcK5S5F7b4hkGHFIRWc4vaMjxCl6VSJ6E6DTRnQwfrfTF0AerHSO1iABI2YAlbdMV3ahxGzzNkaqnIX496G2IKwiYziOumo4M0gfOt-MqNkOJKvnSRfB7pikSATaSQiaFmrA5A8bH0AbaM9znPIRxHyrKqlFlrpWkPSiUPOS3aHQeD8kVGk7RNEWtOk26sXfUIjHp8ZYExIClBEmc6QPAf2-FAuwsw-S8YDLwsiycJ0gEO8MYPZWn8gXR_sVIwLMMg" - -var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "TM", "2022", "US", map[string]interface{}{"auth_token": access_token, "tenantID": "cba321", "botID": "0123", "appID": "1596"}), -} - -var helloMsg = `{ - "channelId": "msteams", - "conversation": { - "converstaionType": "personal", - "id": "a:2811", - "tenantId": "cba321" - }, - "id": "56834", - "timestamp": "2022-06-06T16:51:00.0000000Z", - "serviceUrl": "https://smba.trafficmanager.net/br/", - "text":"Hello World", - "type":"message" -}` - -var attachment = `{ - "channelId": "msteams", - "conversation": { - "converstaionType": "personal", - "id": "a:2811", - "tenantId": "cba321" - }, - "id": "56834", - "timestamp": "2022-06-06T16:51:00.0000000Z", - "serviceUrl": "https://smba.trafficmanager.net/br/", - "text":"Hello World", - "type":"message", - "attachments":[ - { - "contentType": "image", - "contentUrl": "https://image-url/foo.png", - "name": "foo.png" - } - ] -}` - -var attachmentVideo = `{ - "channelId": "msteams", - "conversation": { - "converstaionType": "personal", - "id": "a:2811", - "tenantId": "cba321" - }, - "id": "56834", - "timestamp": "2022-06-06T16:51:00.0000000Z", - "serviceUrl": "https://smba.trafficmanager.net/br/", - "text":"Hello World", - "type":"message", - "attachments":[ - { - "contentType": "video/mp4", - "contentUrl": "https://video-url/foo.mp4", - "name": "foo.png" - } - ] -}` - -var attachmentDocument = `{ - "channelId": "msteams", - "conversation": { - "converstaionType": "personal", - "id": "a:2811", - "tenantId": "cba321" - }, - "id": "56834", - "timestamp": "2022-06-06T16:51:00.0000000Z", - "serviceUrl": "https://smba.trafficmanager.net/br/", - "text":"Hello World", - "type":"message", - "attachments":[ - { - "contentType": "application/pdf", - "contentUrl": "https://document-url/foo.pdf", - "name": "foo.png" - } - ] -}` - -var conversationUpdate = `{ - "channelId": "msteams", - "id": "56834", - "timestamp": "2022-06-06T16:51:00.0000000Z", - "serviceUrl": "https://smba.trafficmanager.net/br/", - "type":"conversationUpdate", - "membersAdded": [{ - "id":"4569", - "name": "Joe", - "role": "user" - }] -}` - -var messageReaction = `{ - "channelId": "msteams", - "id": "56834", - "timestamp": "2022-06-06T16:51:00.0000000Z", - "serviceUrl": "https://smba.trafficmanager.net/br/", - "type":"messageReaction" -}` - -var testCases = []ChannelHandleTestCase{ - { - Label: "Receive Message", - URL: "/c/tm/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive", - Data: helloMsg, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp("Hello World"), - ExpectedURN: "teams:2811:smba.trafficmanager.net/br/", - ExpectedExternalID: "56834", - ExpectedDate: time.Date(2022, 6, 6, 16, 51, 00, 0000000, time.UTC), - Headers: map[string]string{"Authorization": "Bearer " + access_token}, - NoQueueErrorCheck: true, - }, - { - Label: "Receive Attachment Image", - URL: "/c/tm/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive", - Data: attachment, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp("Hello World"), - ExpectedAttachments: []string{"https://image-url/foo.png"}, - ExpectedURN: "teams:2811:smba.trafficmanager.net/br/", - ExpectedExternalID: "56834", - ExpectedDate: time.Date(2022, 6, 6, 16, 51, 00, 0000000, time.UTC), - Headers: map[string]string{"Authorization": "Bearer " + access_token}, - NoQueueErrorCheck: true, - }, - { - Label: "Receive Attachment Video", - URL: "/c/tm/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive", - Data: attachmentVideo, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp("Hello World"), - ExpectedAttachments: []string{"https://video-url/foo.mp4"}, - ExpectedURN: "teams:2811:smba.trafficmanager.net/br/", - ExpectedExternalID: "56834", - ExpectedDate: time.Date(2022, 6, 6, 16, 51, 00, 0000000, time.UTC), - Headers: map[string]string{"Authorization": "Bearer " + access_token}, - NoQueueErrorCheck: true, - }, - { - Label: "Receive Attachment Document", - URL: "/c/tm/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive", - Data: attachmentDocument, - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - ExpectedMsgText: Sp("Hello World"), - ExpectedAttachments: []string{"https://document-url/foo.pdf"}, - ExpectedURN: "teams:2811:smba.trafficmanager.net/br/", - ExpectedExternalID: "56834", - ExpectedDate: time.Date(2022, 6, 6, 16, 51, 00, 0000000, time.UTC), - Headers: map[string]string{"Authorization": "Bearer " + access_token}, - NoQueueErrorCheck: true, - }, - { - Label: "Receive Message Reaction", - URL: "/c/tm/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive", - Data: messageReaction, - ExpectedRespStatus: 200, - ExpectedURN: "", - ExpectedBodyContains: "ignoring messageReaction", - Headers: map[string]string{"Authorization": "Bearer " + access_token}, - NoQueueErrorCheck: true, - }, - { - Label: "Receive Conversation Update", - URL: "/c/tm/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive", - Data: "", - ExpectedRespStatus: 200, - ExpectedBodyContains: "Handled", - Headers: map[string]string{"Authorization": "Bearer " + access_token}, - NoQueueErrorCheck: true, - }, -} - -func TestHandler(t *testing.T) { - tmService := buildMockTeams() - newTestCases := newConversationUpdateTC(tmService.URL, testCases) - jwks_url := buildMockJwksURL() - RunChannelTestCases(t, testChannels, newHandler(), newTestCases) - jwks_url.Close() - tmService.Close() - -} - -func buildMockJwksURL() *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Content-Type", "application/json") - w.Write([]byte(`{"keys":[{"kty":"RSA","use":"sig","kid":"abc123","x5t":"abc123","n":"abcd","e":"AQAB","endorsements":["msteams"]}]}`)) - })) - - jwks_uri = server.URL - - return server -} - -func buildMockTeams() *httptest.Server { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - accessToken := r.Header.Get("Authorization") - tokenH := strings.Replace(accessToken, "Bearer ", "", 1) - // payload := r.GetBody - defer r.Body.Close() - - // invalid auth token - if tokenH != access_token { - http.Error(w, "invalid auth token", 400) - } - - if r.URL.Path == "/v3/conversations" { - w.Header().Add("Content-Type", "application/json") - w.Write([]byte(`{"id":"a:2811"}`)) - } - - if r.URL.Path == "/v3/conversations/a:2022/activities" { - byteBody, err := io.ReadAll(r.Body) - if err != nil { - log.Fatal(err) - } - text, err := jsonparser.GetString(byteBody, "text") - if err != nil { - log.Fatal(err) - } - if text == "Error" { - w.Header().Add("Content-Type", "application/json") - w.Write([]byte(`{"is_error": true}`)) - } - w.Header().Add("Content-Type", "application/json") - w.Write([]byte(`{"id":"1234567890"}`)) - } - - if r.URL.Path == "/v3/conversations/a:2022/members" { - w.Write([]byte(`[{"givenName": "John","surname": "Doe"}]`)) - } - })) - - return server -} - -func newConversationUpdateTC(newUrl string, testCase []ChannelHandleTestCase) []ChannelHandleTestCase { - casesWithMockedUrls := make([]ChannelHandleTestCase, len(testCases)) - for i, tc := range testCases { - mockedCase := tc - if mockedCase.Label == "Receive Conversation Update" { - mockedCase.Data = strings.Replace(conversationUpdate, "https://smba.trafficmanager.net/br/", newUrl, 1) - } - casesWithMockedUrls[i] = mockedCase - } - return casesWithMockedUrls -} - -var defaultSendTestCases = []ChannelSendTestCase{ - { - Label: "Plain Send", - MsgText: "Simple Message", - MsgURN: "teams:2022:https://smba.trafficmanager.net/br/", - ExpectedMsgStatus: "W", ExpectedExternalID: "1234567890", - MockResponseBody: `{id:"1234567890"}`, MockResponseStatus: 200, - }, - {Label: "Send Photo", - MsgURN: "teams:2022:https://smba.trafficmanager.net/br/", MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, - ExpectedMsgStatus: "W", ExpectedExternalID: "1234567890", - MockResponseBody: `{"id": "1234567890"}`, MockResponseStatus: 200, - }, - {Label: "Send Video", - MsgURN: "teams:2022:https://smba.trafficmanager.net/br/", MsgAttachments: []string{"video/mp4:https://foo.bar/video.mp4"}, - ExpectedMsgStatus: "W", ExpectedExternalID: "1234567890", - MockResponseBody: `{"id": "1234567890"}`, MockResponseStatus: 200, - }, - {Label: "Send Document", - MsgURN: "teams:2022:https://smba.trafficmanager.net/br/", MsgAttachments: []string{"application/pdf:https://foo.bar/document.pdf"}, - ExpectedMsgStatus: "W", ExpectedExternalID: "1234567890", - MockResponseBody: `{"id": "1234567890"}`, MockResponseStatus: 200, - }, - {Label: "ID Error", - MsgText: "Error", MsgURN: "teams:2022:smba.trafficmanager.net/br/", - ExpectedMsgStatus: "E", - MockResponseBody: `{"is_error": true}`, MockResponseStatus: 200, - }, -} - -func newSendTestCases(testSendCases []ChannelSendTestCase, url string) []ChannelSendTestCase { - var newtestSendCases []ChannelSendTestCase - for _, tc := range testSendCases { - spTC := strings.Split(tc.MsgURN, ":") - newURN := spTC[0] + ":" + spTC[1] + ":" + url + "/" - tc.MsgURN = newURN - newtestSendCases = append(newtestSendCases, tc) - } - return newtestSendCases -} - -func TestSending(t *testing.T) { - var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TM", "2022", "US", - map[string]interface{}{courier.ConfigAuthToken: access_token, "tenantID": "cba321", "botID": "0123", "appID": "1596"}) - - serviceTM := buildMockTeams() - url := strings.Split(serviceTM.URL, "http://") - newSendTestCases := newSendTestCases(defaultSendTestCases, url[1]) - RunChannelSendTestCases(t, defaultChannel, newHandler(), newSendTestCases, nil, nil) - serviceTM.Close() -} - -func TestDescribe(t *testing.T) { - server := buildMockTeams() - url := strings.Split(server.URL, "http://") - - channel := testChannels[0] - handler := newHandler().(courier.URNDescriber) - logger := courier.NewChannelLog(courier.ChannelLogTypeUnknown, channel, nil) - tcs := []struct { - urn urns.URN - expectedMetadata map[string]string - }{{urns.URN("teams:2022:" + string(url[1]) + "/"), map[string]string{"name": "John Doe"}}} - - for _, tc := range tcs { - metadata, _ := handler.DescribeURN(context.Background(), testChannels[0], tc.urn, logger) - assert.Equal(t, metadata, tc.expectedMetadata) - } - server.Close() -} diff --git a/handlers/telegram/telegram.go b/handlers/telegram/handler.go similarity index 91% rename from handlers/telegram/telegram.go rename to handlers/telegram/handler.go index 82cf19cf5..a824a29cf 100644 --- a/handlers/telegram/telegram.go +++ b/handlers/telegram/handler.go @@ -71,7 +71,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w // this is a start command, trigger a new conversation if text == "/start" { - event := h.Backend().NewChannelEvent(channel, courier.NewConversation, urn, clog).WithContactName(name).WithOccurredOn(date) + event := h.Backend().NewChannelEvent(channel, courier.EventTypeNewConversation, urn, clog).WithContactName(name).WithOccurredOn(date) err = h.Backend().WriteChannelEvent(ctx, event, clog) if err != nil { return nil, err @@ -130,7 +130,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w msg.WithAttachment(mediaURL) } // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } type mtResponse struct { @@ -142,7 +142,7 @@ type mtResponse struct { } `json:"result"` } -func (h *handler) sendMsgPart(msg courier.Msg, token string, path string, form url.Values, keyboard *ReplyKeyboardMarkup, clog *courier.ChannelLog) (string, bool, error) { +func (h *handler) sendMsgPart(msg courier.MsgOut, token string, path string, form url.Values, keyboard *ReplyKeyboardMarkup, clog *courier.ChannelLog) (string, bool, error) { // either include or remove our keyboard if keyboard == nil { form.Add("reply_markup", `{"remove_keyboard":true}`) @@ -157,7 +157,7 @@ func (h *handler) sendMsgPart(msg courier.Msg, token string, path string, form u } req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - resp, respBody, _ := handlers.RequestHTTP(req, clog) + resp, respBody, _ := h.RequestHTTP(req, clog) response := &mtResponse{} err = json.Unmarshal(respBody, response) @@ -178,7 +178,7 @@ func (h *handler) sendMsgPart(msg courier.Msg, token string, path string, form u } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { confAuth := msg.Channel().ConfigForKey(courier.ConfigAuthToken, "") authToken, isStr := confAuth.(string) if !isStr || authToken == "" { @@ -197,7 +197,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } // the status that will be written for this message - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) // whether we encountered any errors sending any parts hasError := true @@ -223,8 +223,8 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann externalID, botBlocked, err := h.sendMsgPart(msg, authToken, "sendMessage", form, msgKeyBoard, clog) if botBlocked { - status.SetStatus(courier.MsgFailed) - channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.StopContact, msg.URN(), clog) + status.SetStatus(courier.MsgStatusFailed) + channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.EventTypeStopContact, msg.URN(), clog) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) return status, err } @@ -249,8 +249,8 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } externalID, botBlocked, err := h.sendMsgPart(msg, authToken, "sendPhoto", form, attachmentKeyBoard, clog) if botBlocked { - status.SetStatus(courier.MsgFailed) - channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.StopContact, msg.URN(), clog) + status.SetStatus(courier.MsgStatusFailed) + channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.EventTypeStopContact, msg.URN(), clog) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) return status, err } @@ -265,8 +265,8 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } externalID, botBlocked, err := h.sendMsgPart(msg, authToken, "sendVideo", form, attachmentKeyBoard, clog) if botBlocked { - status.SetStatus(courier.MsgFailed) - channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.StopContact, msg.URN(), clog) + status.SetStatus(courier.MsgStatusFailed) + channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.EventTypeStopContact, msg.URN(), clog) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) return status, err } @@ -281,8 +281,8 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } externalID, botBlocked, err := h.sendMsgPart(msg, authToken, "sendAudio", form, attachmentKeyBoard, clog) if botBlocked { - status.SetStatus(courier.MsgFailed) - channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.StopContact, msg.URN(), clog) + status.SetStatus(courier.MsgStatusFailed) + channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.EventTypeStopContact, msg.URN(), clog) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) return status, err } @@ -297,8 +297,8 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } externalID, botBlocked, err := h.sendMsgPart(msg, authToken, "sendDocument", form, attachmentKeyBoard, clog) if botBlocked { - status.SetStatus(courier.MsgFailed) - channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.StopContact, msg.URN(), clog) + status.SetStatus(courier.MsgStatusFailed) + channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.EventTypeStopContact, msg.URN(), clog) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) return status, err } @@ -312,7 +312,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } if !hasError { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil @@ -346,7 +346,7 @@ func (h *handler) resolveFileID(ctx context.Context, channel courier.Channel, fi courier.LogRequestError(req, channel, err) } - resp, respBody, _ := handlers.RequestHTTP(req, clog) + resp, respBody, _ := h.RequestHTTP(req, clog) respPayload := &fileResponse{} err = json.Unmarshal(respBody, respPayload) diff --git a/handlers/telegram/telegram_test.go b/handlers/telegram/handler_test.go similarity index 96% rename from handlers/telegram/telegram_test.go rename to handlers/telegram/handler_test.go index bc2b4df66..2f70e5df4 100644 --- a/handlers/telegram/telegram_test.go +++ b/handlers/telegram/handler_test.go @@ -14,7 +14,7 @@ import ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "TG", "2020", "US", map[string]interface{}{"auth_token": "a123"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "TG", "2020", "US", map[string]any{"auth_token": "a123"}), } var helloMsg = `{ @@ -515,7 +515,7 @@ var contactMsg = ` } }` -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid Message", @@ -537,9 +537,9 @@ var testCases = []ChannelHandleTestCase{ ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedContactName: Sp("Nic Pottier"), - ExpectedEvent: courier.NewConversation, - ExpectedURN: "telegram:3527065#nicpottier", - ExpectedDate: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC), + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeNewConversation, URN: "telegram:3527065#nicpottier", Time: time.Date(2016, 1, 30, 1, 57, 9, 0, time.UTC)}, + }, }, { Label: "Receive No Params", @@ -671,6 +671,7 @@ var testCases = []ChannelHandleTestCase{ Data: invalidFileID, ExpectedRespStatus: 200, ExpectedBodyContains: "unable to resolve file", + ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseUnparseable("JSON")}, }, { Label: "Receive NoOk FileID", @@ -693,7 +694,7 @@ var testCases = []ChannelHandleTestCase{ Data: errorFile, ExpectedRespStatus: 200, ExpectedBodyContains: "unable to resolve file", - ExpectedErrors: []*courier.ChannelError{courier.ErrorResponseUnparseable("JSON")}, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("500", "error loading file")}, }, { Label: "Receive NotOk FileID", @@ -711,7 +712,7 @@ var testCases = []ChannelHandleTestCase{ }, } -func buildMockTelegramService(testCases []ChannelHandleTestCase) *httptest.Server { +func buildMockTelegramService(testCases []IncomingTestCase) *httptest.Server { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fileID := r.FormValue("file_id") defer r.Body.Close() @@ -767,11 +768,11 @@ func buildMockTelegramService(testCases []ChannelHandleTestCase) *httptest.Serve return server } -func TestHandler(t *testing.T) { +func TestIncoming(t *testing.T) { telegramService := buildMockTelegramService(testCases) defer telegramService.Close() - RunChannelTestCases(t, testChannels, newHandler(), testCases) + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -782,11 +783,11 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { apiURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -939,9 +940,9 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TG", "2020", "US", - map[string]interface{}{courier.ConfigAuthToken: "auth_token"}) + map[string]any{courier.ConfigAuthToken: "auth_token"}) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"auth_token"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"auth_token"}, nil) } diff --git a/handlers/telesom/telesom.go b/handlers/telesom/handler.go similarity index 90% rename from handlers/telesom/telesom.go rename to handlers/telesom/handler.go index 7964e4a2c..7f9e86e1e 100644 --- a/handlers/telesom/telesom.go +++ b/handlers/telesom/handler.go @@ -60,12 +60,12 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w dbMsg := h.Backend().NewIncomingMsg(channel, urn, form.Message, "", clog) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{dbMsg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{dbMsg}, w, r, clog) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for TS channel") @@ -83,7 +83,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann tsSendURL := msg.Channel().StringConfigForKey(courier.ConfigSendURL, sendURL) - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) for _, part := range handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) { from := strings.TrimPrefix(msg.Channel().Address(), "+") @@ -114,13 +114,13 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } if strings.Contains(string(respBody), "Success") { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } else { clog.RawError(fmt.Errorf("Received invalid response content: %s", string(respBody))) } diff --git a/handlers/telesom/telesom_test.go b/handlers/telesom/handler_test.go similarity index 92% rename from handlers/telesom/telesom_test.go rename to handlers/telesom/handler_test.go index 0a5a67404..fc48959d0 100644 --- a/handlers/telesom/telesom_test.go +++ b/handlers/telesom/handler_test.go @@ -15,7 +15,7 @@ var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TS", "2020", "SO", nil), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: "/c/ts/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/?mobile=%2B2349067554729&msg=Join", @@ -74,8 +74,8 @@ var handleTestCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { @@ -83,13 +83,13 @@ func BenchmarkHandler(b *testing.B) { } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { c.(*test.MockChannel).SetConfig(courier.ConfigSendURL, s.URL) sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -137,9 +137,9 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TS", "2020", "US", - map[string]interface{}{ + map[string]any{ "password": "Password", "username": "Username", "secret": "secret", @@ -150,5 +150,5 @@ func TestSending(t *testing.T) { // mock time so we can have predictable MD5 hashes dates.SetNowSource(dates.NewFixedNowSource(time.Date(2018, 4, 11, 18, 24, 30, 123456000, time.UTC))) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password", "secret"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Password", "secret"}, nil) } diff --git a/handlers/test.go b/handlers/test.go index dc56da98c..e53cc3e78 100644 --- a/handlers/test.go +++ b/handlers/test.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "io" + "log" + "log/slog" "mime/multipart" "net/http" "net/http/httptest" @@ -18,8 +20,8 @@ import ( "github.com/nyaruka/courier" "github.com/nyaruka/courier/test" "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/gocommon/i18n" "github.com/nyaruka/gocommon/urns" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -27,8 +29,23 @@ import ( // RequestPrepFunc is our type for a hook for tests to use before a request is fired in a test type RequestPrepFunc func(*http.Request) -// ChannelHandleTestCase defines the test values for a particular test case -type ChannelHandleTestCase struct { +// ExpectedStatus is an expected status update +type ExpectedStatus struct { + MsgID courier.MsgID + ExternalID string + Status courier.MsgStatus +} + +// ExpectedEvent is an expected channel event +type ExpectedEvent struct { + Type courier.ChannelEventType + URN urns.URN + Time time.Time + Extra map[string]string +} + +// IncomingTestCase defines the test values for a particular test case +type IncomingTestCase struct { Label string NoQueueErrorCheck bool NoInvalidChannelCheck bool @@ -39,20 +56,20 @@ type ChannelHandleTestCase struct { Headers map[string]string MultipartForm map[string]string - ExpectedRespStatus int - ExpectedBodyContains string - ExpectedContactName *string - ExpectedMsgText *string - ExpectedURN urns.URN - ExpectedURNAuth string - ExpectedAttachments []string - ExpectedDate time.Time - ExpectedMsgStatus courier.MsgStatusValue - ExpectedExternalID string - ExpectedMsgID int64 - ExpectedEvent courier.ChannelEventType - ExpectedEventExtra map[string]interface{} - ExpectedErrors []*courier.ChannelError + ExpectedRespStatus int + ExpectedBodyContains string + ExpectedContactName *string + ExpectedMsgText *string + ExpectedURN urns.URN + ExpectedURNAuthTokens map[urns.URN]map[string]string + ExpectedAttachments []string + ExpectedDate time.Time + ExpectedExternalID string + ExpectedMsgID int64 + ExpectedStatuses []ExpectedStatus + ExpectedEvents []ExpectedEvent + ExpectedErrors []*courier.ChannelError + NoLogsExpected bool } // MockedRequest is a fake HTTP request @@ -151,9 +168,8 @@ func testHandlerRequest(tb testing.TB, s courier.Server, path string, headers ma func newServer(backend courier.Backend) courier.Server { // for benchmarks, log to null - logger := logrus.New() - logger.Out = io.Discard - logrus.SetOutput(io.Discard) + logger := slog.Default() + log.SetOutput(io.Discard) config := courier.NewConfig() config.FacebookWebhookSecret = "fb_webhook_secret" @@ -166,8 +182,8 @@ func newServer(backend courier.Backend) courier.Server { } -// RunChannelTestCases runs all the passed in tests cases for the passed in channel configurations -func RunChannelTestCases(t *testing.T, channels []courier.Channel, handler courier.ChannelHandler, testCases []ChannelHandleTestCase) { +// RunIncomingTestCases runs all the passed in tests cases for the passed in channel configurations +func RunIncomingTestCases(t *testing.T, channels []courier.Channel, handler courier.ChannelHandler, testCases []IncomingTestCase) { mb := test.NewMockBackend() s := newServer(mb) @@ -186,7 +202,7 @@ func RunChannelTestCases(t *testing.T, channels []courier.Channel, handler couri if tc.ExpectedMsgText != nil || tc.ExpectedAttachments != nil { require.Len(mb.WrittenMsgs(), 1, "expected a msg to be written") - msg := mb.WrittenMsgs()[0] + msg := mb.WrittenMsgs()[0].(*test.MockMsg) if tc.ExpectedMsgText != nil { assert.Equal(t, *tc.ExpectedMsgText, msg.Text()) @@ -201,53 +217,53 @@ func RunChannelTestCases(t *testing.T, channels []courier.Channel, handler couri assert.Equal(t, tc.ExpectedExternalID, msg.ExternalID()) } assert.Equal(t, tc.ExpectedURN, msg.URN()) - assert.Equal(t, tc.ExpectedURNAuth, msg.URNAuth()) } else { assert.Empty(t, mb.WrittenMsgs(), "unexpected msg written") } - if tc.ExpectedMsgStatus != "" { - // TODO find better way to test statuses because some channels (e.g. infobip) can create multiple statuses in one call - require.Greater(len(mb.WrittenMsgStatuses()), 0, "expected a msg status to be written") - status := mb.WrittenMsgStatuses()[len(mb.WrittenMsgStatuses())-1] - - assert.Equal(t, tc.ExpectedMsgStatus, status.Status()) - - if tc.ExpectedExternalID != "" { - assert.Equal(t, tc.ExpectedExternalID, status.ExternalID()) - } - if tc.ExpectedMsgID != 0 { - assert.Equal(t, tc.ExpectedMsgID, int64(status.ID())) + actualStatuses := mb.WrittenMsgStatuses() + assert.Len(t, actualStatuses, len(tc.ExpectedStatuses), "unexpected number of status updates written") + for i, expectedStatus := range tc.ExpectedStatuses { + if (len(actualStatuses) - 1) < i { + break } - } else { - assert.Empty(t, mb.WrittenMsgStatuses(), "unexpected msg status written") + actualStatus := actualStatuses[i] + + assert.Equal(t, expectedStatus.MsgID, actualStatus.MsgID(), "msg id mismatch for update %d", i) + assert.Equal(t, expectedStatus.ExternalID, actualStatus.ExternalID(), "external id mismatch for update %d", i) + assert.Equal(t, expectedStatus.Status, actualStatus.Status(), "status value mismatch for update %d", i) } - if tc.ExpectedEvent != "" { - require.Len(mb.WrittenChannelEvents(), 1, "expected a channel event to be written") - event := mb.WrittenChannelEvents()[0] + actualEvents := mb.WrittenChannelEvents() + assert.Len(t, actualEvents, len(tc.ExpectedEvents), "unexpected number of events written") + for i, expectedEvent := range tc.ExpectedEvents { + if (len(actualEvents) - 1) < i { + break + } + actualEvent := actualEvents[i] - assert.Equal(t, tc.ExpectedEvent, event.EventType()) - assert.Equal(t, tc.ExpectedEventExtra, event.Extra()) - assert.Equal(t, tc.ExpectedURN, event.URN()) + assert.Equal(t, expectedEvent.Type, actualEvent.EventType(), "event type mismatch for event %d", i) + assert.Equal(t, expectedEvent.URN, actualEvent.URN(), "URN mismatch for event %d", i) + assert.Equal(t, expectedEvent.Extra, actualEvent.Extra(), "extra mismatch for event %d", i) - if !tc.ExpectedDate.IsZero() { - assert.Equal(t, tc.ExpectedDate, event.OccurredOn()) + if !expectedEvent.Time.IsZero() { + assert.Equal(t, expectedEvent.Time, actualEvent.OccurredOn()) } - } else { - assert.Empty(t, mb.WrittenChannelEvents(), "unexpected channel event written") } if tc.ExpectedContactName != nil { require.Equal(*tc.ExpectedContactName, mb.LastContactName()) } - // if we're expecting a message, status or event, check we have a log for it - if tc.ExpectedMsgText != nil || tc.ExpectedMsgStatus != "" || tc.ExpectedEvent != "" { - assert.Greater(t, len(mb.WrittenChannelLogs()), 0, "expected at least one channel log") + assert.Equal(t, tc.ExpectedURNAuthTokens, mb.URNAuthTokens()) - clog := mb.WrittenChannelLogs()[0] - assert.Equal(t, tc.ExpectedErrors, clog.Errors(), "unexpected errors logged") + // unless we know there won't be a log, check one was written + if !tc.NoLogsExpected { + if assert.Equal(t, 1, len(mb.WrittenChannelLogs()), "expected a channel log") { + + clog := mb.WrittenChannelLogs()[0] + assert.Equal(t, tc.ExpectedErrors, clog.Errors(), "unexpected errors logged") + } } }) } @@ -272,10 +288,40 @@ func RunChannelTestCases(t *testing.T, channels []courier.Channel, handler couri } // SendPrepFunc allows test cases to modify the channel, msg or server before a message is sent -type SendPrepFunc func(*httptest.Server, courier.ChannelHandler, courier.Channel, courier.Msg) +type SendPrepFunc func(*httptest.Server, courier.ChannelHandler, courier.Channel, courier.MsgOut) + +type ExpectedRequest struct { + Headers map[string]string + Path string + Params url.Values + Form url.Values + Body string +} + +func (e *ExpectedRequest) AssertMatches(t *testing.T, actual *http.Request, requestNum int) { + if e.Headers != nil { + for k, v := range e.Headers { + assert.Equal(t, v, actual.Header.Get(k), "header %s mismatch for request %d", k, requestNum) + } + } + if e.Path != "" { + assert.Equal(t, e.Path, actual.URL.Path, "patch mismatch for request %d", requestNum) + } + if e.Params != nil { + assert.Equal(t, e.Params, actual.URL.Query(), "URL params mismatch for request %d", requestNum) + } + if e.Form != nil { + actual.ParseMultipartForm(32 << 20) + assert.Equal(t, e.Form, actual.PostForm, "form mismatch for request %d", requestNum) + } + if e.Body != "" { + value, _ := io.ReadAll(actual.Body) + assert.Equal(t, e.Body, strings.Trim(string(value), "\n"), "body mismatch for request %d", requestNum) + } +} -// ChannelSendTestCase defines the test values for a particular test case -type ChannelSendTestCase struct { +// OutgoingTestCase defines the test values for a particular test case +type OutgoingTestCase struct { Label string SendPrep SendPrepFunc @@ -284,12 +330,13 @@ type ChannelSendTestCase struct { MsgURNAuth string MsgAttachments []string MsgQuickReplies []string - MsgLocale courier.Locale + MsgLocale i18n.Locale MsgTopic string MsgHighPriority bool MsgResponseToExternalID string MsgMetadata json.RawMessage MsgFlow *courier.FlowReference + MsgOptIn *courier.OptInReference MsgOrigin courier.MsgOrigin MsgContactLastSeenOn *time.Time @@ -297,22 +344,24 @@ type ChannelSendTestCase struct { MockResponseBody string MockResponses map[MockedRequest]*httpx.MockResponse - ExpectedRequestPath string - ExpectedURLParams map[string]string - ExpectedPostParams map[string]string // deprecated, use ExpectedPostForm - ExpectedPostForm url.Values - ExpectedRequestBody string - ExpectedHeaders map[string]string - ExpectedMsgStatus courier.MsgStatusValue + ExpectedRequests []ExpectedRequest + ExpectedMsgStatus courier.MsgStatus ExpectedExternalID string ExpectedErrors []*courier.ChannelError ExpectedStopEvent bool ExpectedContactURNs map[string]bool ExpectedNewURN string + + // deprecated, use ExpectedRequests + ExpectedRequestPath string + ExpectedURLParams map[string]string + ExpectedPostParams map[string]string + ExpectedRequestBody string + ExpectedHeaders map[string]string } -// RunChannelSendTestCases runs all the passed in test cases against the channel -func RunChannelSendTestCases(t *testing.T, channel courier.Channel, handler courier.ChannelHandler, testCases []ChannelSendTestCase, checkRedacted []string, setupBackend func(*test.MockBackend)) { +// RunOutgoingTestCases runs all the passed in test cases against the channel +func RunOutgoingTestCases(t *testing.T, channel courier.Channel, handler courier.ChannelHandler, testCases []OutgoingTestCase, checkRedacted []string, setupBackend func(*test.MockBackend)) { mb := test.NewMockBackend() if setupBackend != nil { setupBackend(mb) @@ -333,7 +382,7 @@ func RunChannelSendTestCases(t *testing.T, channel courier.Channel, handler cour t.Run(tc.Label, func(t *testing.T) { require := require.New(t) - msg := mb.NewOutgoingMsg(channel, 10, urns.URN(tc.MsgURN), tc.MsgText, tc.MsgHighPriority, tc.MsgQuickReplies, tc.MsgTopic, tc.MsgResponseToExternalID, msgOrigin, tc.MsgContactLastSeenOn) + msg := mb.NewOutgoingMsg(channel, 10, urns.URN(tc.MsgURN), tc.MsgText, tc.MsgHighPriority, tc.MsgQuickReplies, tc.MsgTopic, tc.MsgResponseToExternalID, msgOrigin, tc.MsgContactLastSeenOn).(*test.MockMsg) msg.WithLocale(tc.MsgLocale) for _, a := range tc.MsgAttachments { @@ -348,12 +397,17 @@ func RunChannelSendTestCases(t *testing.T, channel courier.Channel, handler cour if tc.MsgFlow != nil { msg.WithFlow(tc.MsgFlow) } + if tc.MsgOptIn != nil { + msg.WithOptIn(tc.MsgOptIn) + } - var testRequest *http.Request + actualRequests := make([]*http.Request, 0, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // copy request and add to list body, _ := io.ReadAll(r.Body) - testRequest = httptest.NewRequest(r.Method, r.URL.String(), bytes.NewBuffer(body)) - testRequest.Header = r.Header + copy := httptest.NewRequest(r.Method, r.URL.String(), bytes.NewBuffer(body)) + copy.Header = r.Header + actualRequests = append(actualRequests, copy) if (len(tc.MockResponses)) == 0 { w.WriteHeader(tc.MockResponseStatus) @@ -389,49 +443,54 @@ func RunChannelSendTestCases(t *testing.T, channel courier.Channel, handler cour assert.Equal(t, tc.ExpectedErrors, clog.Errors(), "unexpected errors logged") - if tc.ExpectedRequestPath != "" { - require.NotNil(testRequest, "path should not be nil") - require.Equal(tc.ExpectedRequestPath, testRequest.URL.Path) - } + if tc.ExpectedRequestPath != "" || tc.ExpectedURLParams != nil || tc.ExpectedPostParams != nil || tc.ExpectedRequestBody != "" || tc.ExpectedHeaders != nil { + testRequest := actualRequests[len(actualRequests)-1] - if tc.ExpectedURLParams != nil { - require.NotNil(testRequest) - for k, v := range tc.ExpectedURLParams { - value := testRequest.URL.Query().Get(k) - require.Equal(v, value, fmt.Sprintf("%s not equal", k)) + if tc.ExpectedRequestPath != "" { + require.NotNil(testRequest, "path should not be nil") + require.Equal(tc.ExpectedRequestPath, testRequest.URL.Path) } - } - - if tc.ExpectedPostParams != nil { - require.NotNil(testRequest, "post body should not be nil") - for k, v := range tc.ExpectedPostParams { - value := testRequest.PostFormValue(k) - require.Equal(v, value) + if tc.ExpectedURLParams != nil { + require.NotNil(testRequest) + for k, v := range tc.ExpectedURLParams { + value := testRequest.URL.Query().Get(k) + require.Equal(v, value, fmt.Sprintf("%s not equal", k)) + } } - } else if tc.ExpectedPostForm != nil { - require.NotNil(testRequest, "post body should not be nil") - testRequest.ParseMultipartForm(32 << 20) - assert.Equal(t, tc.ExpectedPostForm, testRequest.PostForm) - } + if tc.ExpectedPostParams != nil { + require.NotNil(testRequest, "post body should not be nil") + for k, v := range tc.ExpectedPostParams { + value := testRequest.PostFormValue(k) + require.Equal(v, value) + } + } + if tc.ExpectedRequestBody != "" { + require.NotNil(testRequest, "request body should not be nil") + value, _ := io.ReadAll(testRequest.Body) + require.Equal(tc.ExpectedRequestBody, strings.Trim(string(value), "\n")) + } + if tc.ExpectedHeaders != nil { + require.NotNil(testRequest, "headers should not be nil") + for k, v := range tc.ExpectedHeaders { + value := testRequest.Header.Get(k) + require.Equal(v, value) + } + } + } else if len(tc.ExpectedRequests) > 0 { + assert.Len(t, actualRequests, len(tc.ExpectedRequests), "unexpected number of requests made") - if tc.ExpectedRequestBody != "" { - require.NotNil(testRequest, "request body should not be nil") - value, _ := io.ReadAll(testRequest.Body) - require.Equal(tc.ExpectedRequestBody, strings.Trim(string(value), "\n")) + for i, expectedRequest := range tc.ExpectedRequests { + if (len(actualRequests) - 1) < i { + break + } + expectedRequest.AssertMatches(t, actualRequests[i], i) + } } if (len(tc.MockResponses)) != 0 { assert.Equal(t, len(tc.MockResponses), mockRRCount, "mocked request count mismatch") } - if tc.ExpectedHeaders != nil { - require.NotNil(testRequest, "headers should not be nil") - for k, v := range tc.ExpectedHeaders { - value := testRequest.Header.Get(k) - require.Equal(v, value) - } - } - if tc.ExpectedExternalID != "" { require.Equal(tc.ExpectedExternalID, status.ExternalID()) } @@ -444,13 +503,13 @@ func RunChannelSendTestCases(t *testing.T, channel courier.Channel, handler cour if tc.ExpectedStopEvent { require.Len(mb.WrittenChannelEvents(), 1) event := mb.WrittenChannelEvents()[0] - require.Equal(courier.StopContact, event.EventType()) + require.Equal(courier.EventTypeStopContact, event.EventType()) } if tc.ExpectedContactURNs != nil { var contactUUID courier.ContactUUID for urn, shouldBePresent := range tc.ExpectedContactURNs { - contact, _ := mb.GetContact(ctx, channel, urns.URN(urn), "", "", clog) + contact, _ := mb.GetContact(ctx, channel, urns.URN(urn), nil, "", clog) if contactUUID == courier.NilContactUUID && shouldBePresent { contactUUID = contact.UUID() } @@ -464,7 +523,7 @@ func RunChannelSendTestCases(t *testing.T, channel courier.Channel, handler cour } if tc.ExpectedNewURN != "" { - old, new := status.UpdatedURN() + old, new := status.URNUpdate() require.Equal(urns.URN(tc.MsgURN), old) require.Equal(urns.URN(tc.ExpectedNewURN), new) } @@ -475,7 +534,7 @@ func RunChannelSendTestCases(t *testing.T, channel courier.Channel, handler cour } // RunChannelBenchmarks runs all the passed in test cases for the passed in channels -func RunChannelBenchmarks(b *testing.B, channels []courier.Channel, handler courier.ChannelHandler, testCases []ChannelHandleTestCase) { +func RunChannelBenchmarks(b *testing.B, channels []courier.Channel, handler courier.ChannelHandler, testCases []IncomingTestCase) { mb := test.NewMockBackend() s := newServer(mb) diff --git a/handlers/thinq/thinq.go b/handlers/thinq/handler.go similarity index 88% rename from handlers/thinq/thinq.go rename to handlers/thinq/handler.go index 032e27e25..3cac20b1d 100644 --- a/handlers/thinq/thinq.go +++ b/handlers/thinq/handler.go @@ -70,7 +70,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) } - var msg courier.Msg + var msg courier.MsgIn if form.Type == "sms" { msg = h.Backend().NewIncomingMsg(channel, urn, form.Message, "", clog) @@ -83,7 +83,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w } else { return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("unknown message type: %s", form.Type)) } - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // guid: thinQ guid returned when an outbound message is sent via our API @@ -99,13 +99,13 @@ type statusForm struct { Status string `validate:"required" name:"status"` } -var statusMapping = map[string]courier.MsgStatusValue{ - "DELIVRD": courier.MsgDelivered, - "EXPIRED": courier.MsgErrored, - "DELETED": courier.MsgFailed, - "UNDELIV": courier.MsgFailed, - "UNKNOWN": courier.MsgFailed, - "REJECTD": courier.MsgFailed, +var statusMapping = map[string]courier.MsgStatus{ + "DELIVRD": courier.MsgStatusDelivered, + "EXPIRED": courier.MsgStatusErrored, + "DELETED": courier.MsgStatusFailed, + "UNDELIV": courier.MsgStatusFailed, + "UNKNOWN": courier.MsgStatusFailed, + "REJECTD": courier.MsgStatusFailed, } // receiveStatus is our HTTP handler function for status updates @@ -124,7 +124,7 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, form.GUID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, form.GUID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } @@ -135,7 +135,7 @@ type mtMessage struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { accountID := msg.Channel().StringConfigForKey(configAccountID, "") if accountID == "" { return nil, fmt.Errorf("no account id set for TQ channel") @@ -151,7 +151,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no token set for TQ channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) // we send attachments first so that text appears below for _, a := range msg.Attachments() { @@ -172,7 +172,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.SetBasicAuth(tokenUser, token) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -183,7 +183,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann clog.Error(courier.ErrorResponseValueMissing("guid")) return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) status.SetExternalID(externalID) } @@ -205,7 +205,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.SetBasicAuth(tokenUser, token) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -217,7 +217,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) status.SetExternalID(externalID) } } diff --git a/handlers/thinq/thinq_test.go b/handlers/thinq/handler_test.go similarity index 91% rename from handlers/thinq/thinq_test.go rename to handlers/thinq/handler_test.go index 2fdc9bad9..226234098 100644 --- a/handlers/thinq/thinq_test.go +++ b/handlers/thinq/handler_test.go @@ -24,7 +24,7 @@ const ( var testJpgBase64 = base64.StdEncoding.EncodeToString(test.ReadFile("../../test/testdata/test.jpg")) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -64,9 +64,10 @@ var testCases = []ChannelHandleTestCase{ URL: statusURL, Data: "guid=1234&status=DELIVRD", ExpectedRespStatus: 200, - ExpectedExternalID: "1234", ExpectedBodyContains: `"status":"D"`, - ExpectedMsgStatus: courier.MsgDelivered, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "1234", Status: courier.MsgStatusDelivered}, + }, }, { Label: "Status Invalid", @@ -86,16 +87,16 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL + "?account_id=%s" sendMMSURL = s.URL + "?account_id=%s" } -var sendTestCases = []ChannelSendTestCase{ +var sendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -154,12 +155,12 @@ var sendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var channel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TQ", "+12065551212", "US", - map[string]interface{}{ + map[string]any{ configAccountID: "1234", configAPITokenUser: "user1", configAPIToken: "sesame", }) - RunChannelSendTestCases(t, channel, newHandler(), sendTestCases, []string{httpx.BasicAuth("user1", "sesame")}, nil) + RunOutgoingTestCases(t, channel, newHandler(), sendTestCases, []string{httpx.BasicAuth("user1", "sesame")}, nil) } diff --git a/handlers/twiml/twiml.go b/handlers/twiml/handlers.go similarity index 91% rename from handlers/twiml/twiml.go rename to handlers/twiml/handlers.go index 440e649e1..7d54e40bf 100644 --- a/handlers/twiml/twiml.go +++ b/handlers/twiml/handlers.go @@ -12,6 +12,7 @@ import ( _ "embed" "encoding/base64" "fmt" + "log/slog" "net/http" "net/url" "sort" @@ -25,7 +26,6 @@ import ( "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/urns" "github.com/pkg/errors" - "github.com/sirupsen/logrus" ) const ( @@ -102,13 +102,13 @@ type statusForm struct { To string } -var statusMapping = map[string]courier.MsgStatusValue{ - "queued": courier.MsgSent, - "failed": courier.MsgFailed, - "sent": courier.MsgSent, - "delivered": courier.MsgDelivered, - "read": courier.MsgDelivered, - "undelivered": courier.MsgFailed, +var statusMapping = map[string]courier.MsgStatus{ + "queued": courier.MsgStatusSent, + "failed": courier.MsgStatusFailed, + "sent": courier.MsgStatusSent, + "delivered": courier.MsgStatusDelivered, + "read": courier.MsgStatusDelivered, + "undelivered": courier.MsgStatusFailed, } // receiveMessage is our HTTP handler function for incoming messages @@ -148,7 +148,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w mediaURL := r.PostForm.Get(fmt.Sprintf("MediaUrl%d", i)) msg.WithAttachment(mediaURL) } - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // receiveStatus is our HTTP handler function for status updates @@ -171,25 +171,25 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // if we are ignoring delivery reports and this isn't failed then move on - if channel.BoolConfigForKey(configIgnoreDLRs, false) && msgStatus != courier.MsgFailed { + if channel.BoolConfigForKey(configIgnoreDLRs, false) && msgStatus != courier.MsgStatusFailed { return nil, handlers.WriteAndLogRequestIgnored(ctx, h, channel, w, r, "ignoring non error delivery report") } // if the message id was passed explicitely, use that - var status courier.MsgStatus + var status courier.StatusUpdate idString := r.URL.Query().Get("id") if idString != "" { msgID, err := strconv.ParseInt(idString, 10, 64) if err != nil { - logrus.WithError(err).WithField("id", idString).Error("error converting twilio callback id to integer") + slog.Error("error converting twilio callback id to integer", "error", err, "id", idString) } else { - status = h.Backend().NewMsgStatusForID(channel, courier.MsgID(msgID), msgStatus, clog) + status = h.Backend().NewStatusUpdate(channel, courier.MsgID(msgID), msgStatus, clog) } } // if we have no status, then build it from the external (twilio) id if status == nil { - status = h.Backend().NewMsgStatusForExternalID(channel, form.MessageSID, msgStatus, clog) + status = h.Backend().NewStatusUpdateByExternalID(channel, form.MessageSID, msgStatus, clog) } errorCode, _ := strconv.ParseInt(form.ErrorCode, 10, 64) @@ -201,7 +201,7 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // create a stop channel event - channelEvent := h.Backend().NewChannelEvent(channel, courier.StopContact, urn, clog) + channelEvent := h.Backend().NewChannelEvent(channel, courier.EventTypeStopContact, urn, clog) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) if err != nil { return nil, err @@ -214,10 +214,10 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { // build our callback URL callbackDomain := msg.Channel().CallbackDomain(h.Server().Config().Domain) - callbackURL := fmt.Sprintf("https://%s/c/%s/%s/status?id=%d&action=callback", callbackDomain, strings.ToLower(h.ChannelType().String()), msg.Channel().UUID(), msg.ID()) + callbackURL := fmt.Sprintf("https://%s/c/%s/%s/status?id=%d&action=callback", callbackDomain, strings.ToLower(string(h.ChannelType())), msg.Channel().UUID(), msg.ID()) accountSID := msg.Channel().StringConfigForKey(configAccountSID, "") if accountSID == "" { @@ -236,7 +236,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, errors.Wrap(err, "error resolving attachments") } - status := h.Backend().NewMsgStatusForID(channel, msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(channel, msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) for i, part := range parts { // build our request @@ -286,7 +286,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil { return status, nil } @@ -296,10 +296,10 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann errorCode, _ := jsonparser.GetInt(respBody, "code") if errorCode != 0 { if errorCode == errorStopped { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) // create a stop channel event - channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.StopContact, msg.URN(), clog) + channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.EventTypeStopContact, msg.URN(), clog) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) if err != nil { return nil, err @@ -317,7 +317,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) // only save the first external id if i == 0 { @@ -454,7 +454,7 @@ func twCalculateSignature(url string, form url.Values, authToken string) ([]byte } // WriteMsgSuccessResponse writes our response in TWIML format -func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { w.Header().Set("Content-Type", "text/xml") w.WriteHeader(200) _, err := fmt.Fprint(w, ``) diff --git a/handlers/twiml/twiml_test.go b/handlers/twiml/handlers_test.go similarity index 75% rename from handlers/twiml/twiml_test.go rename to handlers/twiml/handlers_test.go index 6614bf9b2..033b5e9c5 100644 --- a/handlers/twiml/twiml_test.go +++ b/handlers/twiml/handlers_test.go @@ -18,19 +18,19 @@ import ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "T", "2020", "US", map[string]interface{}{"auth_token": "6789"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "T", "2020", "US", map[string]any{"auth_token": "6789"}), } var tmsTestChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TMS", "2020", "US", map[string]interface{}{"auth_token": "6789"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TMS", "2020", "US", map[string]any{"auth_token": "6789"}), } var twTestChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TW", "2020", "US", map[string]interface{}{"auth_token": "6789"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TW", "2020", "US", map[string]any{"auth_token": "6789"}), } var swTestChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SW", "2020", "US", map[string]interface{}{"auth_token": "6789"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SW", "2020", "US", map[string]any{"auth_token": "6789"}), } var ( @@ -79,17 +79,45 @@ var ( waReceivePrefixlessURN = "ToCountry=US&ToState=CA&SmsMessageSid=SM681a1f26d9ec591431ce406e8f399525&NumMedia=0&ToCity=&FromZip=60625&SmsSid=SM681a1f26d9ec591431ce406e8f399525&FromState=IL&SmsStatus=received&FromCity=CHICAGO&Body=Msg&FromCountry=US&To=%2B12028831111&ToZip=&NumSegments=1&MessageSid=SM681a1f26d9ec591431ce406e8f399525&AccountSid=acctid&From=%2B14133881111&ApiVersion=2010-04-01" ) -var testCases = []ChannelHandleTestCase{ - {Label: "Receive Valid", URL: receiveURL, Data: receiveValid, ExpectedRespStatus: 200, ExpectedBodyContains: "", - ExpectedMsgText: Sp("Msg"), ExpectedURN: "tel:+14133881111", ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, - {Label: "Receive Button Ignored", URL: receiveURL, Data: receiveButtonIgnored, ExpectedRespStatus: 200, ExpectedBodyContains: "", - ExpectedMsgText: Sp("Msg"), ExpectedURN: "tel:+14133881111", ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, - {Label: "Receive Invalid Signature", URL: receiveURL, Data: receiveValid, ExpectedRespStatus: 400, ExpectedBodyContains: "invalid request signature", - PrepRequest: addInvalidSignature}, - {Label: "Receive Missing Signature", URL: receiveURL, Data: receiveValid, ExpectedRespStatus: 400, ExpectedBodyContains: "missing request signature"}, - {Label: "Receive No Params", URL: receiveURL, Data: " ", ExpectedRespStatus: 400, ExpectedBodyContains: "field 'messagesid' required", +var testCases = []IncomingTestCase{ + { + Label: "Receive Valid", + URL: receiveURL, + Data: receiveValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: "", + ExpectedMsgText: Sp("Msg"), + ExpectedURN: "tel:+14133881111", + ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", + PrepRequest: addValidSignature, + }, + { + Label: "Receive Button Ignored", + URL: receiveURL, + Data: receiveButtonIgnored, + ExpectedRespStatus: 200, + ExpectedBodyContains: "", + ExpectedMsgText: Sp("Msg"), + ExpectedURN: "tel:+14133881111", + ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", + PrepRequest: addValidSignature, + }, + { + Label: "Receive Invalid Signature", + URL: receiveURL, + Data: receiveValid, + ExpectedRespStatus: 400, + ExpectedBodyContains: "invalid request signature", + PrepRequest: addInvalidSignature, + }, + { + Label: "Receive Missing Signature", + URL: receiveURL, + Data: receiveValid, + ExpectedRespStatus: 400, + ExpectedBodyContains: "missing request signature"}, + { + Label: "Receive No Params", URL: receiveURL, Data: " ", ExpectedRespStatus: 400, ExpectedBodyContains: "field 'messagesid' required", PrepRequest: addValidSignature}, {Label: "Receive Media", URL: receiveURL, Data: receiveMedia, ExpectedRespStatus: 200, ExpectedBodyContains: "", ExpectedURN: "tel:+14133881111", ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", ExpectedAttachments: []string{"cat.jpg", "dog.jpg"}, @@ -106,27 +134,78 @@ var testCases = []ChannelHandleTestCase{ Data: statusStop, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, - ExpectedEvent: "stop_contact", - ExpectedURN: "tel:+12028831111", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("21610", "Attempt to send to unsubscribed recipient")}, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusFailed}, + }, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeStopContact, URN: "tel:+12028831111"}, + }, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("21610", "Attempt to send to unsubscribed recipient")}, + PrepRequest: addValidSignature, + }, + { + Label: "Status No Params", + URL: statusURL, + Data: " ", + ExpectedRespStatus: 200, + ExpectedBodyContains: "no msg status, ignoring", PrepRequest: addValidSignature, }, - {Label: "Status No Params", URL: statusURL, Data: " ", ExpectedRespStatus: 200, ExpectedBodyContains: "no msg status, ignoring", - PrepRequest: addValidSignature}, - {Label: "Status Invalid Status", URL: statusURL, Data: statusInvalid, ExpectedRespStatus: 400, ExpectedBodyContains: "unknown status 'huh'", - PrepRequest: addValidSignature}, - {Label: "Status Valid", URL: statusURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, - {Label: "Status Read", URL: statusURL, Data: statusRead, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, - {Label: "Status ID Valid", URL: statusIDURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedMsgID: 12345, - PrepRequest: addValidSignature}, - {Label: "Status ID Invalid", URL: statusInvalidIDURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, + { + Label: "Status Invalid Status", + URL: statusURL, + Data: statusInvalid, + ExpectedRespStatus: 400, + ExpectedBodyContains: "unknown status 'huh'", + PrepRequest: addValidSignature, + }, + { + Label: "Status Valid", + URL: statusURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status Read", + URL: statusURL, + Data: statusRead, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status ID Valid", + URL: statusIDURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {MsgID: 12345, Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status ID Invalid", + URL: statusInvalidIDURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, } -var tmsTestCases = []ChannelHandleTestCase{ +var tmsTestCases = []IncomingTestCase{ {Label: "Receive Valid", URL: tmsReceiveURL, Data: receiveValid, ExpectedRespStatus: 200, ExpectedBodyContains: "", ExpectedMsgText: Sp("Msg"), ExpectedURN: "tel:+14133881111", ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", PrepRequest: addValidSignature}, @@ -153,27 +232,78 @@ var tmsTestCases = []ChannelHandleTestCase{ Data: statusStop, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, - ExpectedEvent: "stop_contact", - ExpectedURN: "tel:+12028831111", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("21610", "Attempt to send to unsubscribed recipient")}, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusFailed}, + }, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeStopContact, URN: "tel:+12028831111"}, + }, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("21610", "Attempt to send to unsubscribed recipient")}, + PrepRequest: addValidSignature, + }, + { + Label: "Status TMS extra", + URL: tmsStatusURL, + Data: tmsStatusExtra, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"S"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SM0b6e2697aae04182a9f5b5c7a8994c7f", Status: courier.MsgStatusSent}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status No Params", + URL: tmsStatusURL, + Data: " ", + ExpectedRespStatus: 200, + ExpectedBodyContains: "no msg status, ignoring", PrepRequest: addValidSignature, }, - {Label: "Status TMS extra", URL: tmsStatusURL, Data: tmsStatusExtra, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"S"`, ExpectedMsgStatus: courier.MsgSent, - ExpectedExternalID: "SM0b6e2697aae04182a9f5b5c7a8994c7f", PrepRequest: addValidSignature}, - {Label: "Status No Params", URL: tmsStatusURL, Data: " ", ExpectedRespStatus: 200, ExpectedBodyContains: "no msg status, ignoring", - PrepRequest: addValidSignature}, - {Label: "Status Invalid Status", URL: tmsStatusURL, Data: statusInvalid, ExpectedRespStatus: 400, ExpectedBodyContains: "unknown status 'huh'", - PrepRequest: addValidSignature}, - {Label: "Status Valid", URL: tmsStatusURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, - {Label: "Status ID Valid", URL: tmsStatusIDURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedMsgID: 12345, - PrepRequest: addValidSignature}, - {Label: "Status ID Invalid", URL: tmsStatusInvalidIDURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, + { + Label: "Status Invalid Status", + URL: tmsStatusURL, + Data: statusInvalid, + ExpectedRespStatus: 400, + ExpectedBodyContains: "unknown status 'huh'", + PrepRequest: addValidSignature, + }, + { + Label: "Status Valid", + URL: tmsStatusURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status ID Valid", + URL: tmsStatusIDURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {MsgID: 12345, Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status ID Invalid", + URL: tmsStatusInvalidIDURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, } -var twTestCases = []ChannelHandleTestCase{ +var twTestCases = []IncomingTestCase{ {Label: "Receive Valid", URL: twReceiveURL, Data: receiveValid, ExpectedRespStatus: 200, ExpectedBodyContains: "", ExpectedMsgText: Sp("Msg"), ExpectedURN: "tel:+14133881111", ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", PrepRequest: addValidSignature}, @@ -202,25 +332,55 @@ var twTestCases = []ChannelHandleTestCase{ Data: statusStop, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, - ExpectedEvent: "stop_contact", - ExpectedURN: "tel:+12028831111", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("21610", "Attempt to send to unsubscribed recipient")}, - PrepRequest: addValidSignature, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusFailed}, + }, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeStopContact, URN: "tel:+12028831111"}, + }, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("21610", "Attempt to send to unsubscribed recipient")}, + PrepRequest: addValidSignature, }, {Label: "Status No Params", URL: twStatusURL, Data: " ", ExpectedRespStatus: 200, ExpectedBodyContains: "no msg status, ignoring", PrepRequest: addValidSignature}, {Label: "Status Invalid Status", URL: twStatusURL, Data: statusInvalid, ExpectedRespStatus: 400, ExpectedBodyContains: "unknown status 'huh'", PrepRequest: addValidSignature}, - {Label: "Status Valid", URL: twStatusURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, - {Label: "Status ID Valid", URL: twStatusIDURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedMsgID: 12345, - PrepRequest: addValidSignature}, - {Label: "Status ID Invalid", URL: twStatusInvalidIDURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, + { + Label: "Status Valid", + URL: twStatusURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status ID Valid", + URL: twStatusIDURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {MsgID: 12345, Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status ID Invalid", + URL: twStatusInvalidIDURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, } -var swTestCases = []ChannelHandleTestCase{ +var swTestCases = []IncomingTestCase{ {Label: "Receive Valid", URL: swReceiveURL, Data: receiveValid, ExpectedRespStatus: 200, ExpectedBodyContains: "", ExpectedMsgText: Sp("Msg"), ExpectedURN: "tel:+14133881111", ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b"}, {Label: "Receive No Params", URL: swReceiveURL, Data: " ", ExpectedRespStatus: 400, ExpectedBodyContains: "field 'messagesid' required"}, @@ -236,26 +396,57 @@ var swTestCases = []ChannelHandleTestCase{ Data: statusStop, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, - ExpectedMsgStatus: courier.MsgFailed, - ExpectedEvent: "stop_contact", - ExpectedURN: "tel:+12028831111", - ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("21610", "Attempt to send to unsubscribed recipient")}, - PrepRequest: addValidSignature, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusFailed}, + }, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeStopContact, URN: "tel:+12028831111"}, + }, + ExpectedErrors: []*courier.ChannelError{courier.ErrorExternal("21610", "Attempt to send to unsubscribed recipient")}, + PrepRequest: addValidSignature, }, {Label: "Status No Params", URL: swStatusURL, Data: " ", ExpectedRespStatus: 200, ExpectedBodyContains: "no msg status, ignoring"}, {Label: "Status Invalid Status", URL: swStatusURL, Data: statusInvalid, ExpectedRespStatus: 400, ExpectedBodyContains: "unknown status 'huh'"}, - {Label: "Status Valid", URL: swStatusURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b"}, - {Label: "Status ID Valid", URL: swStatusIDURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedMsgID: 12345}, - {Label: "Status ID Invalid", URL: swStatusInvalidIDURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b"}, + { + Label: "Status Valid", + URL: swStatusURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status ID Valid", + URL: swStatusIDURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {MsgID: 12345, Status: courier.MsgStatusDelivered}, + }, + }, + { + Label: "Status ID Invalid", + URL: swStatusInvalidIDURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + }, } -var waTestCases = []ChannelHandleTestCase{ +var waTestCases = []IncomingTestCase{ {Label: "Receive Valid", URL: receiveURL, Data: waReceiveValid, ExpectedRespStatus: 200, ExpectedBodyContains: "", ExpectedMsgText: Sp("Msg"), ExpectedURN: "whatsapp:14133881111", ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", PrepRequest: addValidSignature}, } -var twaTestCases = []ChannelHandleTestCase{ +var twaTestCases = []IncomingTestCase{ {Label: "Receive Valid", URL: twaReceiveURL, Data: waReceiveValid, ExpectedRespStatus: 200, ExpectedBodyContains: "", ExpectedMsgText: Sp("Msg"), ExpectedURN: "whatsapp:14133881111", ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", PrepRequest: addValidSignature}, @@ -269,12 +460,39 @@ var twaTestCases = []ChannelHandleTestCase{ PrepRequest: addValidSignature}, {Label: "Status Invalid Status", URL: twaStatusURL, Data: statusInvalid, ExpectedRespStatus: 400, ExpectedBodyContains: "unknown status 'huh'", PrepRequest: addValidSignature}, - {Label: "Status Valid", URL: twaStatusURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, - {Label: "Status ID Valid", URL: twaStatusIDURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedMsgID: 12345, - PrepRequest: addValidSignature}, - {Label: "Status ID Invalid", URL: twaStatusInvalidIDURL, Data: statusValid, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"D"`, ExpectedMsgStatus: courier.MsgDelivered, ExpectedExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", - PrepRequest: addValidSignature}, + { + Label: "Status Valid", + URL: twaStatusURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status ID Valid", + URL: twaStatusIDURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {MsgID: 12345, Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Status ID Invalid", + URL: twaStatusInvalidIDURL, + Data: statusValid, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"D"`, + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "SMe287d7109a5a925f182f0e07fe5b223b", Status: courier.MsgStatusDelivered}, + }, + PrepRequest: addValidSignature, + }, } func addValidSignature(r *http.Request) { @@ -294,29 +512,29 @@ func addInvalidSignature(r *http.Request) { r.Header.Set(signatureHeader, "invalidsig") } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newTWIMLHandler("T", "Twilio", true), testCases) - RunChannelTestCases(t, tmsTestChannels, newTWIMLHandler("TMS", "Twilio Messaging Service", true), tmsTestCases) - RunChannelTestCases(t, twTestChannels, newTWIMLHandler("TW", "TwiML API", true), twTestCases) - RunChannelTestCases(t, swTestChannels, newTWIMLHandler("SW", "SignalWire", false), swTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newTWIMLHandler("T", "Twilio", true), testCases) + RunIncomingTestCases(t, tmsTestChannels, newTWIMLHandler("TMS", "Twilio Messaging Service", true), tmsTestCases) + RunIncomingTestCases(t, twTestChannels, newTWIMLHandler("TW", "TwiML API", true), twTestCases) + RunIncomingTestCases(t, swTestChannels, newTWIMLHandler("SW", "SignalWire", false), swTestCases) waChannel := test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SW", "+12065551212", "US", - map[string]interface{}{ + map[string]any{ configAccountSID: "accountSID", courier.ConfigAuthToken: "6789", }, ) waChannel.SetScheme(urns.WhatsAppScheme) - RunChannelTestCases(t, []courier.Channel{waChannel}, newTWIMLHandler("T", "TwilioWhatsApp", true), waTestCases) + RunIncomingTestCases(t, []courier.Channel{waChannel}, newTWIMLHandler("T", "TwilioWhatsApp", true), waTestCases) twaChannel := test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TWA", "+12065551212", "US", - map[string]interface{}{ + map[string]any{ configAccountSID: "accountSID", courier.ConfigAuthToken: "6789", }, ) twaChannel.SetScheme(urns.WhatsAppScheme) - RunChannelTestCases(t, []courier.Channel{twaChannel}, newTWIMLHandler("TWA", "Twilio WhatsApp", true), twaTestCases) + RunIncomingTestCases(t, []courier.Channel{twaChannel}, newTWIMLHandler("TWA", "Twilio WhatsApp", true), twaTestCases) } func BenchmarkHandler(b *testing.B) { @@ -326,15 +544,15 @@ func BenchmarkHandler(b *testing.B) { } // 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) { - if c.ChannelType().String() == "TW" || c.ChannelType().String() == "SW" { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { + if c.ChannelType() == courier.ChannelType("TW") || c.ChannelType() == courier.ChannelType("SW") { c.(*test.MockChannel).SetConfig("send_url", s.URL) } else { twilioBaseURL = s.URL } } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -413,12 +631,16 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, MockResponseBody: `{ "sid": "1002" }`, MockResponseStatus: 200, - ExpectedPostForm: url.Values{ - "Body": []string{"My pic!"}, - "To": []string{"+250788383383"}, - "MediaUrl": []string{"https://foo.bar/image.jpg"}, - "From": []string{"2020"}, - "StatusCallback": []string{"https://localhost/c/t/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}, + ExpectedRequests: []ExpectedRequest{ + { + Form: url.Values{ + "Body": []string{"My pic!"}, + "To": []string{"+250788383383"}, + "MediaUrl": []string{"https://foo.bar/image.jpg"}, + "From": []string{"2020"}, + "StatusCallback": []string{"https://localhost/c/t/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}, + }, + }, }, ExpectedMsgStatus: "W", SendPrep: setSendURL, @@ -429,19 +651,23 @@ var defaultSendTestCases = []ChannelSendTestCase{ MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg", "audio/mp4:https://foo.bar/audio.m4a"}, MockResponseBody: `{ "sid": "1002" }`, MockResponseStatus: 200, - ExpectedPostForm: url.Values{ - "Body": []string{""}, - "To": []string{"+250788383383"}, - "MediaUrl": []string{"https://foo.bar/image.jpg", "https://foo.bar/audio.m4a"}, - "From": []string{"2020"}, - "StatusCallback": []string{"https://localhost/c/t/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}, + ExpectedRequests: []ExpectedRequest{ + { + Form: url.Values{ + "Body": []string{""}, + "To": []string{"+250788383383"}, + "MediaUrl": []string{"https://foo.bar/image.jpg", "https://foo.bar/audio.m4a"}, + "From": []string{"2020"}, + "StatusCallback": []string{"https://localhost/c/t/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}, + }, + }, }, ExpectedMsgStatus: "W", SendPrep: setSendURL, }, } -var tmsDefaultSendTestCases = []ChannelSendTestCase{ +var tmsDefaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -526,7 +752,7 @@ var tmsDefaultSendTestCases = []ChannelSendTestCase{ }, } -var twDefaultSendTestCases = []ChannelSendTestCase{ +var twDefaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -611,7 +837,7 @@ var twDefaultSendTestCases = []ChannelSendTestCase{ }, } -var swSendTestCases = []ChannelSendTestCase{ +var swSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -696,7 +922,7 @@ var swSendTestCases = []ChannelSendTestCase{ }, } -var waSendTestCases = []ChannelSendTestCase{ +var waSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -711,7 +937,7 @@ var waSendTestCases = []ChannelSendTestCase{ }, } -var twaSendTestCases = []ChannelSendTestCase{ +var twaSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -726,64 +952,64 @@ var twaSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "T", "2020", "US", - map[string]interface{}{ + map[string]any{ configAccountSID: "accountSID", courier.ConfigAuthToken: "authToken"}) var tmsDefaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56cd", "TMS", "2021", "US", - map[string]interface{}{ + map[string]any{ configMessagingServiceSID: "messageServiceSID", configAccountSID: "accountSID", courier.ConfigAuthToken: "authToken"}) var twDefaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TW", "2020", "US", - map[string]interface{}{ + map[string]any{ configAccountSID: "accountSID", courier.ConfigAuthToken: "authToken", configSendURL: "SEND_URL", }) var swChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SW", "2020", "US", - map[string]interface{}{ + map[string]any{ configAccountSID: "accountSID", courier.ConfigAuthToken: "authToken", configSendURL: "BASE_URL", }) - RunChannelSendTestCases(t, defaultChannel, newTWIMLHandler("T", "Twilio", true), defaultSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) - RunChannelSendTestCases(t, tmsDefaultChannel, newTWIMLHandler("TMS", "Twilio Messaging Service", true), tmsDefaultSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) - RunChannelSendTestCases(t, twDefaultChannel, newTWIMLHandler("TW", "TwiML", true), twDefaultSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) - RunChannelSendTestCases(t, swChannel, newTWIMLHandler("SW", "SignalWire", false), swSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) + RunOutgoingTestCases(t, defaultChannel, newTWIMLHandler("T", "Twilio", true), defaultSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) + RunOutgoingTestCases(t, tmsDefaultChannel, newTWIMLHandler("TMS", "Twilio Messaging Service", true), tmsDefaultSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) + RunOutgoingTestCases(t, twDefaultChannel, newTWIMLHandler("TW", "TwiML", true), twDefaultSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) + RunOutgoingTestCases(t, swChannel, newTWIMLHandler("SW", "SignalWire", false), swSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) waChannel := test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SW", "+12065551212", "US", - map[string]interface{}{ + map[string]any{ configAccountSID: "accountSID", courier.ConfigAuthToken: "authToken", }, ) waChannel.SetScheme(urns.WhatsAppScheme) - RunChannelSendTestCases(t, waChannel, newTWIMLHandler("T", "Twilio Whatsapp", true), waSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) + RunOutgoingTestCases(t, waChannel, newTWIMLHandler("T", "Twilio Whatsapp", true), waSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) twaChannel := test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TWA", "+12065551212", "US", - map[string]interface{}{ + map[string]any{ configAccountSID: "accountSID", courier.ConfigAuthToken: "authToken", }, ) twaChannel.SetScheme(urns.WhatsAppScheme) - RunChannelSendTestCases(t, twaChannel, newTWIMLHandler("TWA", "Twilio Whatsapp", true), twaSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) + RunOutgoingTestCases(t, twaChannel, newTWIMLHandler("TWA", "Twilio Whatsapp", true), twaSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil) } func TestBuildAttachmentRequest(t *testing.T) { mb := test.NewMockBackend() var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "T", "2020", "US", - map[string]interface{}{ + map[string]any{ configAccountSID: "accountSID", courier.ConfigAuthToken: "authToken"}) @@ -793,7 +1019,7 @@ func TestBuildAttachmentRequest(t *testing.T) { assert.Equal(t, "Basic YWNjb3VudFNJRDphdXRoVG9rZW4=", req.Header.Get("Authorization")) var swChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "SW", "2020", "US", - map[string]interface{}{ + map[string]any{ configAccountSID: "accountSID", courier.ConfigAuthToken: "authToken", configSendURL: "BASE_URL", diff --git a/handlers/twitter/twitter.go b/handlers/twitter/handler.go similarity index 91% rename from handlers/twitter/twitter.go rename to handlers/twitter/handler.go index cf8c4de1a..29b4fcaa0 100644 --- a/handlers/twitter/twitter.go +++ b/handlers/twitter/handler.go @@ -6,7 +6,6 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" - "encoding/json" "fmt" "io" "mime/multipart" @@ -20,7 +19,7 @@ import ( "github.com/dghubble/oauth1" "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/utils" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" "github.com/pkg/errors" ) @@ -45,7 +44,6 @@ const ( func init() { courier.RegisterHandler(newHandler("TWT", "Twitter Activity")) - courier.RegisterHandler(newHandler("TT", "Twitter")) } type handler struct { @@ -147,7 +145,7 @@ func (h *handler) receiveEvents(ctx context.Context, c courier.Channel, w http.R } // the list of messages we read - msgs := make([]courier.Msg, 0, 2) + msgs := make([]courier.MsgIn, 0, 2) // for each entry for _, entry := range payload.DirectMessageEvents { @@ -249,7 +247,7 @@ type mtAttachment struct { } `json:"media"` } -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { apiKey := msg.Channel().StringConfigForKey(configAPIKey, "") apiSecret := msg.Channel().StringConfigForKey(configAPISecret, "") accessToken := msg.Channel().StringConfigForKey(configAccessToken, "") @@ -263,7 +261,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann token := oauth1.NewToken(accessToken, accessSecret) client := config.Client(ctx, token) - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) // we build these as needed since our unit tests manipulate apiURL sendURL := sendDomain + "/1.1/direct_messages/events/new.json" @@ -289,7 +287,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann mimeType, s3url := handlers.SplitAttachment(attachment) mediaID := "" if strings.HasPrefix(mimeType, "image") || strings.HasPrefix(mimeType, "video") { - mediaID, err = uploadMediaToTwitter(msg, mediaURL, mimeType, s3url, client, clog) + mediaID, err = h.uploadMediaToTwitter(msg, mediaURL, mimeType, s3url, client, clog) if err != nil { clog.RawError(errors.Wrap(err, "unable to upload media to Twitter server")) } @@ -323,16 +321,13 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann payload.Event.MessageCreate.MessageData.QuickReply = qrs } - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, err - } + jsonBody := jsonx.MustMarshal(payload) req, _ := http.NewRequest(http.MethodPost, sendURL, bytes.NewReader(jsonBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTPWithClient(client, req, clog) + resp, respBody, err := h.RequestHTTPWithClient(client, req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -350,7 +345,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann if err == nil { // this was wired successfully - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } } @@ -364,11 +359,11 @@ func generateSignature(secret string, content string) string { return base64.StdEncoding.EncodeToString(h.Sum(nil)) } -func uploadMediaToTwitter(msg courier.Msg, mediaUrl string, attachmentMimeType string, attachmentURL string, client *http.Client, clog *courier.ChannelLog) (string, error) { +func (h *handler) uploadMediaToTwitter(msg courier.MsgOut, mediaUrl string, attachmentMimeType string, attachmentURL string, client *http.Client, clog *courier.ChannelLog) (string, error) { // retrieve the media to be sent from S3 req, _ := http.NewRequest(http.MethodGet, attachmentURL, nil) - s3Resp, s3RespBody, err := handlers.RequestHTTP(req, clog) + s3Resp, s3RespBody, err := h.RequestHTTP(req, clog) if err != nil || s3Resp.StatusCode/100 != 2 { return "", err } @@ -396,9 +391,8 @@ func uploadMediaToTwitter(msg courier.Msg, mediaUrl string, attachmentMimeType s twReq, _ := http.NewRequest(http.MethodPost, mediaUrl, strings.NewReader(form.Encode())) twReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") twReq.Header.Set("Accept", "application/json") - twReq.Header.Set("User-Agent", utils.HTTPUserAgent) - twResp, twRespBody, err := handlers.RequestHTTPWithClient(client, twReq, clog) + twResp, twRespBody, err := h.RequestHTTPWithClient(client, twReq, clog) if err != nil || twResp.StatusCode/100 != 2 { return "", err } @@ -439,9 +433,8 @@ func uploadMediaToTwitter(msg courier.Msg, mediaUrl string, attachmentMimeType s twReq, _ = http.NewRequest(http.MethodPost, mediaUrl, bytes.NewReader(body.Bytes())) twReq.Header.Set("Content-Type", contentType) twReq.Header.Set("Accept", "application/json") - twReq.Header.Set("User-Agent", utils.HTTPUserAgent) - twResp, twRespBody, err = handlers.RequestHTTPWithClient(client, twReq, clog) + twResp, twRespBody, err = h.RequestHTTPWithClient(client, twReq, clog) if err != nil || twResp.StatusCode/100 != 2 { return "", err } @@ -458,9 +451,8 @@ func uploadMediaToTwitter(msg courier.Msg, mediaUrl string, attachmentMimeType s twReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") twReq.Header.Set("Accept", "application/json") - twReq.Header.Set("User-Agent", utils.HTTPUserAgent) - twResp, twRespBody, err = handlers.RequestHTTPWithClient(client, twReq, clog) + twResp, twRespBody, err = h.RequestHTTPWithClient(client, twReq, clog) if err != nil || twResp.StatusCode/100 != 2 { return "", err } @@ -489,9 +481,8 @@ func uploadMediaToTwitter(msg courier.Msg, mediaUrl string, attachmentMimeType s twReq, _ = http.NewRequest(http.MethodGet, statusURL.String(), nil) twReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") twReq.Header.Set("Accept", "application/json") - twReq.Header.Set("User-Agent", utils.HTTPUserAgent) - twResp, twRespBody, err = handlers.RequestHTTPWithClient(client, twReq, clog) + twResp, twRespBody, err = h.RequestHTTPWithClient(client, twReq, clog) if err != nil || twResp.StatusCode/100 != 2 { return "", err } diff --git a/handlers/twitter/twitter_test.go b/handlers/twitter/handler_test.go similarity index 96% rename from handlers/twitter/twitter_test.go rename to handlers/twitter/handler_test.go index b9c20f780..f98fb7510 100644 --- a/handlers/twitter/twitter_test.go +++ b/handlers/twitter/handler_test.go @@ -17,7 +17,7 @@ import ( var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c568c", "TWT", "tweeter", "", - map[string]interface{}{ + map[string]any{ configHandleID: "835740314006511618", configAPIKey: "apiKey", configAPISecret: "apiSecret", @@ -166,7 +166,7 @@ var attachment = `{ var notJSON = `blargh` -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ {Label: "Receive Message", URL: "/c/twt/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive", Data: helloMsg, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedContactName: Sp("Nicolas Pottier"), ExpectedURN: "twitterid:272953809#nicpottier", ExpectedMsgText: Sp("Hello World & good wishes."), ExpectedExternalID: "958501034212564996", ExpectedDate: time.Date(2018, 1, 31, 0, 43, 49, 301000000, time.UTC)}, @@ -180,8 +180,8 @@ var testCases = []ChannelHandleTestCase{ {Label: "Webhook Verification Error", URL: "/c/twt/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive", ExpectedRespStatus: 400, ExpectedBodyContains: "missing required 'crc_token'"}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler("TWT", "Twitter Activity"), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler("TWT", "Twitter Activity"), testCases) } func BenchmarkHandler(b *testing.B) { @@ -189,12 +189,12 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendDomain = s.URL uploadDomain = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -370,8 +370,8 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func mockAttachmentURLs(mediaServer *httptest.Server, testCases []ChannelSendTestCase) []ChannelSendTestCase { - casesWithMockedUrls := make([]ChannelSendTestCase, len(testCases)) +func mockAttachmentURLs(mediaServer *httptest.Server, testCases []OutgoingTestCase) []OutgoingTestCase { + casesWithMockedUrls := make([]OutgoingTestCase, len(testCases)) for i, testCase := range testCases { mockedCase := testCase for j, attachment := range testCase.MsgAttachments { @@ -385,7 +385,7 @@ func mockAttachmentURLs(mediaServer *httptest.Server, testCases []ChannelSendTes } return casesWithMockedUrls } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { // fake media server that just replies with 200 and "media body" for content mediaServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { defer req.Body.Close() @@ -394,5 +394,5 @@ func TestSending(t *testing.T) { })) attachmentMockedSendTestCase := mockAttachmentURLs(mediaServer, defaultSendTestCases) - RunChannelSendTestCases(t, testChannels[0], newHandler("TWT", "Twitter Activity"), attachmentMockedSendTestCase, []string{"apiSecret", "accessTokenSecret"}, nil) + RunOutgoingTestCases(t, testChannels[0], newHandler("TWT", "Twitter Activity"), attachmentMockedSendTestCase, []string{"apiSecret", "accessTokenSecret"}, nil) } diff --git a/handlers/utils.go b/handlers/utils.go index d967b2cf9..c00367da0 100644 --- a/handlers/utils.go +++ b/handlers/utils.go @@ -18,7 +18,7 @@ var ( ) // GetTextAndAttachments returns both the text of our message as well as any attachments, newline delimited -func GetTextAndAttachments(m courier.Msg) string { +func GetTextAndAttachments(m courier.MsgOut) string { buf := bytes.NewBuffer([]byte(m.Text())) for _, a := range m.Attachments() { _, url := SplitAttachment(a) @@ -92,36 +92,6 @@ func DecodePossibleBase64(original string) string { return decoded } -// SplitMsgByChannel splits the passed in string into segments that are at most channel config max length or type max length -func SplitMsgByChannel(channel courier.Channel, text string, maxLength int) []string { - max := channel.IntConfigForKey(courier.ConfigMaxLength, maxLength) - - return SplitMsg(text, max) -} - -// SplitMsg splits the passed in string into segments that are at most max length -func SplitMsg(text string, max int) []string { - // smaller than our max, just return it - if len(text) <= max { - return []string{text} - } - - parts := make([]string, 0, 2) - part := bytes.Buffer{} - for _, r := range text { - part.WriteRune(r) - if part.Len() == max || (part.Len() > max-6 && r == ' ') { - parts = append(parts, strings.TrimSpace(part.String())) - part.Reset() - } - } - if part.Len() > 0 { - parts = append(parts, strings.TrimSpace(part.String())) - } - - return parts -} - // StrictTelForCountry wraps urns.NewURNTelForCountry but is stricter in // what it accepts. Incoming tels must be numeric or we will return an // error. (IE, alphanumeric shortcodes are not ok) diff --git a/handlers/utils_test.go b/handlers/utils_test.go index 9ac5dd966..51ef6643e 100644 --- a/handlers/utils_test.go +++ b/handlers/utils_test.go @@ -95,3 +95,32 @@ func TestIsURL(t *testing.T) { assert.Equal(t, tc.valid, handlers.IsURL(tc.text), "isURL mimatch for input %s", tc.text) } } + +var test6 = ` +SSByZWNlaXZlZCB5b3VyIGxldHRlciB0b2RheSwgaW4gd2hpY2ggeW91IHNheSB5b3Ugd2FudCB0 +byByZXNjdWUgTm9ydGggQ2Fyb2xpbmlhbnMgZnJvbSB0aGUgQUNBLCBvciBPYmFtYWNhcmUgYXMg +eW91IG9kZGx5IGluc2lzdCBvbiBjYWxsaW5nIGl0LiAKCkkgaGF2ZSB0byBjYWxsIHlvdXIgYXR0 +ZW50aW9uIHRvIHlvdXIgc2luIG9mIG9taXNzaW9uLiBZb3Ugc2F5IHRoYXQgd2UgYXJlIGRvd24g +dG8gb25lIGluc3VyZXIgYmVjYXVzZSBvZiBPYmFtYWNhcmUuIERpZCB5b3UgZm9yZ2V0IHRoYXQg +VGhlIEJhdGhyb29tIFN0YXRlIGhhcyBkb25lIGV2ZXJ5dGhpbmcgcG9zc2libGUgdG8gbWFrZSBU +aGUgQUNBIGZhaWw/ICBJbmNsdWRpbmcgbWlsbGlvbnMgb2YgZG9sbGFycyBmcm9tIHRoZSBmZWQ/ +CgpXZSBkb24ndCBuZWVkIHRvIGJlIHNhdmVkIGZyb20gYSBwcm9ncmFtIHRoYXQgaGFzIGhlbHBl +ZCB0aG91c2FuZHMuIFdlIG5lZWQgeW91IHRvIGJ1Y2tsZSBkb3duIGFuZCBpbXByb3ZlIHRoZSBB +Q0EuIFlvdSBoYWQgeWVhcnMgdG8gY29tZSB1cCB3aXRoIGEgcGxhbi4gWW91IGZhaWxlZC4gCgpU +aGUgbGF0ZXN0IHZlcnNpb24geW91ciBwYXJ0eSBoYXMgY29tZSB1cCB3aXRoIHVzIHdvcnNlIHRo +YW4gdGhlIGxhc3QuIFBsZWFzZSB2b3RlIGFnYWluc3QgaXQuIERvbid0IGNvbmRlbW4gdGhlIGdv +b2Qgb2YgcGVvcGxlIG9mIE5DIHRvIGxpdmVzIHRoYXQgYXJlIG5hc3R5LCBicnV0aXNoIGFuZCBz +aG9ydC4gSSdtIG9uZSBvZiB0aGUgZm9sa3Mgd2hvIHdpbGwgZGllIGlmIHlvdSByaXAgdGhlIHBy +b3RlY3Rpb25zIGF3YXkuIAoKVm90ZSBOTyBvbiBhbnkgYmlsbCB0aGF0IGRvZXNuJ3QgY29udGFp +biBwcm90ZWN0aW9ucyBpbnN0ZWFkIG9mIHB1bmlzaG1lbnRzLiBXZSBhcmUgd2F0Y2hpbmcgY2xv +c2VseS4g` + +func TestDecodePossibleBase64(t *testing.T) { + assert := assert.New(t) + assert.Equal("This test\nhas a newline", handlers.DecodePossibleBase64("This test\nhas a newline")) + assert.Equal("Please vote NO on the confirmation of Gorsuch.", handlers.DecodePossibleBase64("Please vote NO on the confirmation of Gorsuch.")) + assert.Equal("Bannon Explains The World ...\n“The Camp of the Saints", handlers.DecodePossibleBase64("QmFubm9uIEV4cGxhaW5zIFRoZSBXb3JsZCAuLi4K4oCcVGhlIENhbXAgb2YgdGhlIFNhaW50c+KA\r")) + assert.Equal("the sweat, the tears and the sacrifice of working America", handlers.DecodePossibleBase64("dGhlIHN3ZWF0LCB0aGUgdGVhcnMgYW5kIHRoZSBzYWNyaWZpY2Ugb2Ygd29ya2luZyBBbWVyaWNh\r")) + assert.Contains(handlers.DecodePossibleBase64("Tm93IGlzDQp0aGUgdGltZQ0KZm9yIGFsbCBnb29kDQpwZW9wbGUgdG8NCnJlc2lzdC4NCg0KSG93IGFib3V0IGhhaWt1cz8NCkkgZmluZCB0aGVtIHRvIGJlIGZyaWVuZGx5Lg0KcmVmcmlnZXJhdG9yDQoNCjAxMjM0NTY3ODkNCiFAIyQlXiYqKCkgW117fS09Xys7JzoiLC4vPD4/fFx+YA0KQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5eg=="), "I find them to be friendly") + assert.Contains(handlers.DecodePossibleBase64(test6), "I received your letter today") +} diff --git a/handlers/viber/viber.go b/handlers/viber/handler.go similarity index 88% rename from handlers/viber/viber.go rename to handlers/viber/handler.go index 27108a981..38f7ab4f4 100644 --- a/handlers/viber/viber.go +++ b/handlers/viber/handler.go @@ -146,7 +146,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) } // build the channel event - channelEvent := h.Backend().NewChannelEvent(channel, courier.WelcomeMessage, urn, clog).WithContactName(ContactName) + channelEvent := h.Backend().NewChannelEvent(channel, courier.EventTypeWelcomeMessage, urn, clog).WithContactName(ContactName) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) if err != nil { @@ -168,7 +168,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h } // build the channel event - channelEvent := h.Backend().NewChannelEvent(channel, courier.NewConversation, urn, clog).WithContactName(ContactName) + channelEvent := h.Backend().NewChannelEvent(channel, courier.EventTypeNewConversation, urn, clog).WithContactName(ContactName) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) if err != nil { @@ -188,7 +188,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) } // build the channel event - channelEvent := h.Backend().NewChannelEvent(channel, courier.StopContact, urn, clog) + channelEvent := h.Backend().NewChannelEvent(channel, courier.EventTypeStopContact, urn, clog) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) if err != nil { @@ -200,7 +200,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h case "failed": clog.SetType(courier.ChannelLogTypeMsgStatus) - msgStatus := h.Backend().NewMsgStatusForExternalID(channel, fmt.Sprintf("%d", payload.MessageToken), courier.MsgFailed, clog) + msgStatus := h.Backend().NewStatusUpdateByExternalID(channel, fmt.Sprintf("%d", payload.MessageToken), courier.MsgStatusFailed, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, msgStatus, w, r) case "delivered": @@ -267,7 +267,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h msg.WithAttachment(mediaURL) } // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } return nil, courier.WriteError(w, http.StatusBadRequest, fmt.Errorf("not handled, unknown event: %s", event)) @@ -349,37 +349,24 @@ type mtResponse struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { authToken := msg.Channel().StringConfigForKey(courier.ConfigAuthToken, "") if authToken == "" { return nil, fmt.Errorf("missing auth token in config") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) // figure out whether we have a keyboard to send as well qrs := msg.QuickReplies() var keyboard *Keyboard if len(qrs) > 0 { - buttonLayout := msg.Channel().ConfigForKey("button_layout", map[string]interface{}{}).(map[string]interface{}) + buttonLayout := msg.Channel().ConfigForKey("button_layout", map[string]any{}).(map[string]any) keyboard = NewKeyboardFromReplies(qrs, buttonLayout) } - parts := handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) - descriptionPart := "" - if len(msg.Attachments()) == 1 && len(msg.Text()) < descriptionMaxLength { - mediaType, _ := handlers.SplitAttachment(msg.Attachments()[0]) - isImage := strings.Split(mediaType, "/")[0] == "image" - - if isImage { - descriptionPart = msg.Text() - parts = []string{} - } - - } - - for i := 0; i < len(parts)+len(msg.Attachments()); i++ { + for _, part := range handlers.SplitMsg(msg, handlers.SplitOptions{MaxTextLen: maxMsgLength, MaxCaptionLen: descriptionMaxLength, Captionable: []handlers.MediaType{handlers.MediaTypeImage}}) { msgType := "text" attSize := -1 attURL := "" @@ -387,18 +374,18 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann msgText := "" var err error - if i < len(msg.Attachments()) { - mediaType, mediaURL := handlers.SplitAttachment(msg.Attachments()[0]) + if part.Type == handlers.MsgPartTypeAttachment || part.Type == handlers.MsgPartTypeCaptionedAttachment { + mediaType, mediaURL := handlers.SplitAttachment(part.Attachment) switch strings.Split(mediaType, "/")[0] { case "image": msgType = "picture" attURL = mediaURL - msgText = descriptionPart + msgText = part.Text case "video": msgType = "video" attURL = mediaURL - attSize, err = getAttachmentSize(mediaURL, clog) + attSize, err = h.getAttachmentSize(mediaURL, clog) if err != nil { return nil, err } @@ -407,7 +394,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann case "audio": msgType = "file" attURL = mediaURL - attSize, err = getAttachmentSize(mediaURL, clog) + attSize, err = h.getAttachmentSize(mediaURL, clog) if err != nil { return nil, err } @@ -419,7 +406,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } } else { - msgText = parts[i-len(msg.Attachments())] + msgText = part.Text } payload := mtPayload{ @@ -451,7 +438,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { clog.Error(courier.ErrorResponseStatusCode()) return status, nil @@ -473,19 +460,19 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) keyboard = nil } return status, nil } -func getAttachmentSize(u string, clog *courier.ChannelLog) (int, error) { +func (h *handler) getAttachmentSize(u string, clog *courier.ChannelLog) (int, error) { req, err := http.NewRequest(http.MethodHead, u, nil) if err != nil { return 0, err } - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return 0, errors.New("unable to get attachment size") } diff --git a/handlers/viber/viber_test.go b/handlers/viber/handler_test.go similarity index 89% rename from handlers/viber/viber_test.go rename to handlers/viber/handler_test.go index b33715d25..d49163ef6 100644 --- a/handlers/viber/viber_test.go +++ b/handlers/viber/handler_test.go @@ -16,11 +16,11 @@ import ( ) // setSend takes care of setting the sendURL to call -func setSendURL(server *httptest.Server, h courier.ChannelHandler, channel courier.Channel, msg courier.Msg) { +func setSendURL(server *httptest.Server, h courier.ChannelHandler, channel courier.Channel, msg courier.MsgOut) { sendURL = server.URL } -func buildMockAttachmentService(testCases []ChannelSendTestCase) *httptest.Server { +func buildMockAttachmentService(testCases []OutgoingTestCase) *httptest.Server { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { headers := w.Header() if r.Method == http.MethodHead { @@ -44,7 +44,7 @@ func buildMockAttachmentService(testCases []ChannelSendTestCase) *httptest.Serve return server } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", @@ -187,14 +187,14 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -var invalidTokenSendTestCases = []ChannelSendTestCase{ +var invalidTokenSendTestCases = []OutgoingTestCase{ { Label: "Invalid token", ExpectedErrors: []*courier.ChannelError{courier.NewChannelError("", "", "missing auth token in config")}, }, } -var buttonLayoutSendTestCases = []ChannelSendTestCase{ +var buttonLayoutSendTestCases = []OutgoingTestCase{ { Label: "Quick Reply With Layout With Column, Row and BgColor definitions", MsgText: "Select a, b, c or d.", @@ -208,37 +208,37 @@ var buttonLayoutSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { attachmentService := buildMockAttachmentService(defaultSendTestCases) defer attachmentService.Close() maxMsgLength = 160 descriptionMaxLength = 10 var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "VP", "2020", "", - map[string]interface{}{ + map[string]any{ courier.ConfigAuthToken: "Token", }) var invalidTokenChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "VP", "2020", "", - map[string]interface{}{}, + map[string]any{}, ) var buttonLayoutChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "VP", "2021", "", - map[string]interface{}{ + map[string]any{ courier.ConfigAuthToken: "Token", - "button_layout": map[string]interface{}{"bg_color": "#f7bb3f", "text": "*

", "text_size": "large"}, + "button_layout": map[string]any{"bg_color": "#f7bb3f", "text": "*

", "text_size": "large"}, }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Token"}, nil) - RunChannelSendTestCases(t, invalidTokenChannel, newHandler(), invalidTokenSendTestCases, []string{"Token"}, nil) - RunChannelSendTestCases(t, buttonLayoutChannel, newHandler(), buttonLayoutSendTestCases, []string{"Token"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"Token"}, nil) + RunOutgoingTestCases(t, invalidTokenChannel, newHandler(), invalidTokenSendTestCases, []string{"Token"}, nil) + RunOutgoingTestCases(t, buttonLayoutChannel, newHandler(), buttonLayoutSendTestCases, []string{"Token"}, nil) } var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "VP", "2020", "", map[string]interface{}{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "VP", "2020", "", map[string]any{ courier.ConfigAuthToken: "Token", }), } var testChannelsWithWelcomeMessage = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "VP", "2020", "", map[string]interface{}{ + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "VP", "2020", "", map[string]any{ courier.ConfigAuthToken: "Token", configViberWelcomeMessage: "Welcome to VP, Please subscribe here for more.", }), @@ -493,7 +493,7 @@ var ( }` ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ {Label: "Receive Valid", URL: receiveURL, Data: validMsg, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedMsgText: Sp("incoming msg"), ExpectedURN: "viber:xy5/5y6O81+/kbWHpLhBoA==", ExpectedExternalID: "4987381189870374000", PrepRequest: addValidSignature}, @@ -506,11 +506,46 @@ var testCases = []ChannelHandleTestCase{ {Label: "Receive invalid Message Type", URL: receiveURL, Data: receiveInvalidMessageType, ExpectedRespStatus: 400, ExpectedBodyContains: "unknown message type", PrepRequest: addValidSignature}, {Label: "Webhook validation", URL: receiveURL, Data: webhookCheck, ExpectedRespStatus: 200, ExpectedBodyContains: "webhook valid", PrepRequest: addValidSignature}, - {Label: "Failed Status Report", URL: receiveURL, Data: failedStatusReport, ExpectedRespStatus: 200, ExpectedBodyContains: `"status":"F"`, ExpectedMsgStatus: courier.MsgFailed, PrepRequest: addValidSignature}, + { + Label: "Failed Status Report", + URL: receiveURL, + Data: failedStatusReport, + ExpectedRespStatus: 200, + ExpectedBodyContains: `"status":"F"`, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "4912661846655238145", Status: courier.MsgStatusFailed}}, + PrepRequest: addValidSignature, + }, {Label: "Delivered Status Report", URL: receiveURL, Data: deliveredStatusReport, ExpectedRespStatus: 200, ExpectedBodyContains: `Ignored`, PrepRequest: addValidSignature}, - {Label: "Subcribe", URL: receiveURL, Data: validSubscribed, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedEvent: "new_conversation", ExpectedURN: "viber:01234567890A=", PrepRequest: addValidSignature}, - {Label: "Subcribe Invalid URN", URL: receiveURL, Data: invalidURNSubscribed, ExpectedRespStatus: 400, ExpectedBodyContains: "invalid viber id", PrepRequest: addValidSignature}, - {Label: "Unsubcribe", URL: receiveURL, Data: validUnsubscribed, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedEvent: courier.StopContact, ExpectedURN: "viber:01234567890A=", PrepRequest: addValidSignature}, + { + Label: "Subcribe", + URL: receiveURL, + Data: validSubscribed, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Accepted", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeNewConversation, URN: "viber:01234567890A="}, + }, + PrepRequest: addValidSignature, + }, + { + Label: "Subcribe Invalid URN", + URL: receiveURL, + Data: invalidURNSubscribed, + ExpectedRespStatus: 400, + ExpectedBodyContains: "invalid viber id", + PrepRequest: addValidSignature, + }, + { + Label: "Unsubcribe", + URL: receiveURL, + Data: validUnsubscribed, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Accepted", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeStopContact, URN: "viber:01234567890A="}, + }, + PrepRequest: addValidSignature, + }, {Label: "Unsubcribe Invalid URN", URL: receiveURL, Data: invalidURNUnsubscribed, ExpectedRespStatus: 400, ExpectedBodyContains: "invalid viber id", PrepRequest: addValidSignature}, {Label: "Conversation Started", URL: receiveURL, Data: validConversationStarted, ExpectedRespStatus: 200, ExpectedBodyContains: "ignored conversation start", PrepRequest: addValidSignature}, {Label: "Unexpected event", URL: receiveURL, Data: unexpectedEvent, ExpectedRespStatus: 400, @@ -534,7 +569,7 @@ var testCases = []ChannelHandleTestCase{ ExpectedAttachments: []string{"https://viber.github.io/docs/img/stickers/40133.png"}, PrepRequest: addValidSignature}, } -var testWelcomeMessageCases = []ChannelHandleTestCase{ +var testWelcomeMessageCases = []IncomingTestCase{ { Label: "Receive Valid", URL: receiveURL, @@ -551,9 +586,10 @@ var testWelcomeMessageCases = []ChannelHandleTestCase{ Data: validConversationStarted, ExpectedRespStatus: 200, ExpectedBodyContains: `{"auth_token":"Token","text":"Welcome to VP, Please subscribe here for more.","type":"text","tracking_data":"0"}`, - ExpectedEvent: "welcome_message", - ExpectedURN: "viber:xy5/5y6O81+/kbWHpLhBoA==", - PrepRequest: addValidSignature, + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeWelcomeMessage, URN: "viber:xy5/5y6O81+/kbWHpLhBoA=="}, + }, + PrepRequest: addValidSignature, }, } @@ -579,9 +615,9 @@ func addInvalidSignature(r *http.Request) { r.Header.Set(viberSignatureHeader, "invalidsig") } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) - RunChannelTestCases(t, testChannelsWithWelcomeMessage, newHandler(), testWelcomeMessageCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) + RunIncomingTestCases(t, testChannelsWithWelcomeMessage, newHandler(), testWelcomeMessageCases) } func BenchmarkHandler(b *testing.B) { diff --git a/handlers/viber/keyboard.go b/handlers/viber/keyboard.go index a57c18c60..ed031dffc 100644 --- a/handlers/viber/keyboard.go +++ b/handlers/viber/keyboard.go @@ -36,7 +36,7 @@ const ( var textSizes = map[string]bool{"small": true, "regular": true, "large": true} // NewKeyboardFromReplies create a keyboard from the given quick replies -func NewKeyboardFromReplies(replies []string, buttonConfig map[string]interface{}) *Keyboard { +func NewKeyboardFromReplies(replies []string, buttonConfig map[string]any) *Keyboard { rows := StringsToRows(replies, maxColumns, maxRowRunes, paddingRunes) buttons := []KeyboardButton{} @@ -60,8 +60,8 @@ func NewKeyboardFromReplies(replies []string, buttonConfig map[string]interface{ return &Keyboard{"keyboard", false, buttons} } -//ApplyConfig apply the configs from the channel to KeyboardButton -func (b *KeyboardButton) ApplyConfig(buttonConfig map[string]interface{}) { +// ApplyConfig apply the configs from the channel to KeyboardButton +func (b *KeyboardButton) ApplyConfig(buttonConfig map[string]any) { bgColor := strings.TrimSpace(fmt.Sprint(buttonConfig["bg_color"])) textStyle := strings.TrimSpace(fmt.Sprint(buttonConfig["text"])) textSize := strings.TrimSpace(fmt.Sprint(buttonConfig["text_size"])) diff --git a/handlers/viber/keyboard_test.go b/handlers/viber/keyboard_test.go index 719ffd38a..37d4d30f7 100644 --- a/handlers/viber/keyboard_test.go +++ b/handlers/viber/keyboard_test.go @@ -11,7 +11,7 @@ func TestKeyboardFromReplies(t *testing.T) { tsc := []struct { replies []string expected *viber.Keyboard - buttonConfig map[string]interface{} + buttonConfig map[string]any }{ { []string{"OK"}, @@ -22,7 +22,7 @@ func TestKeyboardFromReplies(t *testing.T) { {ActionType: "reply", TextSize: "regular", ActionBody: "OK", Text: "OK", Columns: "6"}, }, }, - map[string]interface{}{}, + map[string]any{}, }, { []string{"Yes", "No", "Maybe"}, @@ -35,7 +35,7 @@ func TestKeyboardFromReplies(t *testing.T) { {ActionType: "reply", TextSize: "regular", ActionBody: "Maybe", Text: "Maybe", Columns: "2"}, }, }, - map[string]interface{}{}, + map[string]any{}, }, { []string{"A", "B", "C", "D"}, @@ -49,7 +49,7 @@ func TestKeyboardFromReplies(t *testing.T) { {ActionType: "reply", TextSize: "regular", ActionBody: "D", Text: "D", Columns: "6"}, }, }, - map[string]interface{}{}, + map[string]any{}, }, { []string{"\"A\"", ""}, @@ -61,7 +61,7 @@ func TestKeyboardFromReplies(t *testing.T) { {ActionType: "reply", TextSize: "regular", ActionBody: "", Text: "<B>", Columns: "3"}, }, }, - map[string]interface{}{}, + map[string]any{}, }, { []string{"Vanilla", "Chocolate", "Mint", "Lemon Sorbet", "Papaya", "Strawberry"}, @@ -77,7 +77,7 @@ func TestKeyboardFromReplies(t *testing.T) { {ActionType: "reply", TextSize: "regular", ActionBody: "Strawberry", Text: "Strawberry", Columns: "6"}, }, }, - map[string]interface{}{}, + map[string]any{}, }, { []string{"A", "B", "C", "D", "Chicken", "Fish", "Peanut Butter Pickle"}, @@ -94,7 +94,7 @@ func TestKeyboardFromReplies(t *testing.T) { {ActionType: "reply", TextSize: "regular", ActionBody: "Peanut Butter Pickle", Text: "Peanut Butter Pickle", Columns: "6"}, }, }, - map[string]interface{}{}, + map[string]any{}, }, { []string{"Foo", "Bar", "Baz"}, @@ -107,7 +107,7 @@ func TestKeyboardFromReplies(t *testing.T) { {ActionType: "reply", TextSize: "large", ActionBody: "Baz", Text: "Baz
", Columns: "2", BgColor: "#f7bb3f"}, }, }, - map[string]interface{}{ + map[string]any{ "bg_color": "#f7bb3f", "text": "*
", "text_size": "large", @@ -124,7 +124,7 @@ func TestKeyboardFromReplies(t *testing.T) { {ActionType: "reply", TextSize: "small", ActionBody: "Maybe", Text: "Maybe
", Columns: "2"}, }, }, - map[string]interface{}{ + map[string]any{ "text": "*
", "text_size": "small", }, diff --git a/handlers/vk/vk.go b/handlers/vk/handler.go similarity index 87% rename from handlers/vk/vk.go rename to handlers/vk/handler.go index 90b94f60e..dc7e191b1 100644 --- a/handlers/vk/vk.go +++ b/handlers/vk/handler.go @@ -16,7 +16,6 @@ import ( "github.com/buger/jsonparser" "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/utils" "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" "github.com/pkg/errors" @@ -273,7 +272,7 @@ func (h *handler) DescribeURN(ctx context.Context, channel courier.Channel, urn req.URL.RawQuery = params.Encode() - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return nil, errors.New("unable to look up user info") } @@ -374,14 +373,14 @@ func takeFirstAttachmentUrl(payload moNewMessagePayload) string { return "" } -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) params := buildApiBaseParams(msg.Channel()) params.Set(paramUserId, msg.URN().Path()) params.Set(paramRandomId, msg.ID().String()) - text, attachments := buildTextAndAttachmentParams(msg, clog) + text, attachments := h.buildTextAndAttachmentParams(msg, clog) params.Set(paramMessage, text) params.Set(paramAttachments, attachments) @@ -399,7 +398,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.URL.RawQuery = params.Encode() - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -410,13 +409,13 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return status, errors.Errorf("no '%s' value in response", responseOutgoingMessageKey) } status.SetExternalID(strconv.FormatInt(externalMsgId, 10)) - status.SetStatus(courier.MsgSent) + status.SetStatus(courier.MsgStatusSent) return status, nil } -// buildTextAndAttachmentParams builds msg text with attachment links (if needed) and attachments list param, also returns the errors that occurred -func buildTextAndAttachmentParams(msg courier.Msg, clog *courier.ChannelLog) (string, string) { +// builds msg text with attachment links (if needed) and attachments list param, also returns the errors that occurred +func (h *handler) buildTextAndAttachmentParams(msg courier.MsgOut, clog *courier.ChannelLog) (string, string) { var msgAttachments []string textBuf := bytes.Buffer{} @@ -434,7 +433,7 @@ func buildTextAndAttachmentParams(msg courier.Msg, clog *courier.ChannelLog) (st switch mediaType { case mediaTypeImage: - if attachment, err := handleMediaUploadAndGetAttachment(msg.Channel(), mediaTypeImage, mediaExt, mediaURL, clog); err == nil { + if attachment, err := h.handleMediaUploadAndGetAttachment(msg.Channel(), mediaTypeImage, mediaExt, mediaURL, clog); err == nil { msgAttachments = append(msgAttachments, attachment) } else { clog.RawError(err) @@ -448,24 +447,24 @@ func buildTextAndAttachmentParams(msg courier.Msg, clog *courier.ChannelLog) (st return textBuf.String(), strings.Join(msgAttachments, ",") } -// handleMediaUploadAndGetAttachment handles media downloading, uploading, saving information and returns the attachment string -func handleMediaUploadAndGetAttachment(channel courier.Channel, mediaType, mediaExt, mediaURL string, clog *courier.ChannelLog) (string, error) { +// handles media downloading, uploading, saving information and returns the attachment string +func (h *handler) handleMediaUploadAndGetAttachment(channel courier.Channel, mediaType, mediaExt, mediaURL string, clog *courier.ChannelLog) (string, error) { switch mediaType { case mediaTypeImage: uploadKey := "photo" // initialize server URL to upload photos if URLPhotoUploadServer == "" { - if serverURL, err := getUploadServerURL(channel, apiBaseURL+actionGetPhotoUploadServer, clog); err == nil { + if serverURL, err := h.getUploadServerURL(channel, apiBaseURL+actionGetPhotoUploadServer, clog); err == nil { URLPhotoUploadServer = serverURL } } - download, err := downloadMedia(mediaURL) + download, err := h.downloadMedia(mediaURL) if err != nil { return "", err } - uploadResponse, err := uploadMedia(URLPhotoUploadServer, uploadKey, mediaExt, download, clog) + uploadResponse, err := h.uploadMedia(URLPhotoUploadServer, uploadKey, mediaExt, download, clog) if err != nil { return "", err @@ -476,7 +475,7 @@ func handleMediaUploadAndGetAttachment(channel courier.Channel, mediaType, media return "", err } serverId := strconv.FormatInt(payload.ServerId, 10) - info, err := saveUploadedMediaInfo(channel, apiBaseURL+actionSaveUploadedPhotoInfo, serverId, payload.Hash, uploadKey, payload.Photo, clog) + info, err := h.saveUploadedMediaInfo(channel, apiBaseURL+actionSaveUploadedPhotoInfo, serverId, payload.Hash, uploadKey, payload.Photo, clog) if err != nil { return "", err @@ -491,7 +490,7 @@ func handleMediaUploadAndGetAttachment(channel courier.Channel, mediaType, media } // getUploadServerURL gets VK's media upload server -func getUploadServerURL(channel courier.Channel, sendURL string, clog *courier.ChannelLog) (string, error) { +func (h *handler) getUploadServerURL(channel courier.Channel, sendURL string, clog *courier.ChannelLog) (string, error) { req, err := http.NewRequest(http.MethodPost, sendURL, nil) if err != nil { @@ -500,7 +499,7 @@ func getUploadServerURL(channel courier.Channel, sendURL string, clog *courier.C params := buildApiBaseParams(channel) req.URL.RawQuery = params.Encode() - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return "", errors.New("unable to get upload server URL") } @@ -514,13 +513,13 @@ func getUploadServerURL(channel courier.Channel, sendURL string, clog *courier.C } // downloadMedia GET request to given media URL -func downloadMedia(mediaURL string) (io.Reader, error) { +func (h *handler) downloadMedia(mediaURL string) (io.Reader, error) { req, err := http.NewRequest(http.MethodGet, mediaURL, nil) if err != nil { return nil, err } - if res, err := utils.GetHTTPClient().Do(req); err == nil { + if res, err := h.Backend().HttpClient(true).Do(req); err == nil { return res.Body, nil } else { return nil, err @@ -528,7 +527,7 @@ func downloadMedia(mediaURL string) (io.Reader, error) { } // uploadMedia multiform request that passes file key as uploadKey and file value as media to upload server -func uploadMedia(serverURL, uploadKey, mediaExt string, media io.Reader, clog *courier.ChannelLog) ([]byte, error) { +func (h *handler) uploadMedia(serverURL, uploadKey, mediaExt string, media io.Reader, clog *courier.ChannelLog) ([]byte, error) { body := &bytes.Buffer{} writer := multipart.NewWriter(body) fileName := fmt.Sprintf("%s.%s", uploadKey, mediaExt) @@ -555,7 +554,7 @@ func uploadMedia(serverURL, uploadKey, mediaExt string, media io.Reader, clog *c req.Header.Set("Content-Type", writer.FormDataContentType()) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return nil, errors.New("unable to upload media") } @@ -563,7 +562,7 @@ func uploadMedia(serverURL, uploadKey, mediaExt string, media io.Reader, clog *c } // saveUploadedMediaInfo saves uploaded media info and returns an object containing media/owner id -func saveUploadedMediaInfo(channel courier.Channel, sendURL, serverId, hash, mediaKey, mediaValue string, clog *courier.ChannelLog) (*mediaUploadInfoPayload, error) { +func (h *handler) saveUploadedMediaInfo(channel courier.Channel, sendURL, serverId, hash, mediaKey, mediaValue string, clog *courier.ChannelLog) (*mediaUploadInfoPayload, error) { params := buildApiBaseParams(channel) params.Set(paramServerId, serverId) params.Set(paramHash, hash) @@ -576,7 +575,7 @@ func saveUploadedMediaInfo(channel courier.Channel, sendURL, serverId, hash, med req.URL.RawQuery = params.Encode() - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return nil, errors.New("unable to save uploaded media info") } diff --git a/handlers/vk/vk_test.go b/handlers/vk/handler_test.go similarity index 95% rename from handlers/vk/vk_test.go rename to handlers/vk/handler_test.go index 6c1c12b60..d1c9af0b6 100644 --- a/handlers/vk/vk_test.go +++ b/handlers/vk/handler_test.go @@ -29,7 +29,7 @@ var testChannels = []courier.Channel{ "VK", "123456789", "", - map[string]interface{}{ + map[string]any{ courier.ConfigAuthToken: "token123xyz", courier.ConfigSecret: "abc123xyz", configServerVerificationString: "a1b2c3", @@ -215,7 +215,7 @@ const msgKeyboard = `{ const keyboardJson = `{"one_time":true,"buttons":[[{"action":{"type":"text","label":"A","payload":"\"A\""},"color":"primary"},{"action":{"type":"text","label":"B","payload":"\"B\""},"color":"primary"},{"action":{"type":"text","label":"C","payload":"\"C\""},"color":"primary"},{"action":{"type":"text","label":"D","payload":"\"D\""},"color":"primary"},{"action":{"type":"text","label":"E","payload":"\"E\""},"color":"primary"}]],"inline":false}` -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Message", URL: receiveURL, @@ -344,11 +344,11 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } -func buildMockVKService(testCases []ChannelHandleTestCase) *httptest.Server { +func buildMockVKService(testCases []IncomingTestCase) *httptest.Server { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, actionGetUser) { userId := r.URL.Query()["user_ids"][0] @@ -365,10 +365,11 @@ func buildMockVKService(testCases []ChannelHandleTestCase) *httptest.Server { } func TestDescribeURN(t *testing.T) { - server := buildMockVKService([]ChannelHandleTestCase{}) + server := buildMockVKService([]IncomingTestCase{}) defer server.Close() handler := newHandler() + handler.Initialize(test.NewMockServer(courier.NewConfig(), test.NewMockBackend())) clog := courier.NewChannelLog(courier.ChannelLogTypeUnknown, testChannels[0], handler.RedactValues(testChannels[0])) urn, _ := urns.NewURNFromParts(urns.VKScheme, "123456789", "", "") data := map[string]string{"name": "John Doe"} @@ -381,12 +382,12 @@ func TestDescribeURN(t *testing.T) { } // setSendURL takes care of setting the send_url to our test server host -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { apiBaseURL = s.URL URLPhotoUploadServer = s.URL + "/upload/photo" } -var sendTestCases = []ChannelSendTestCase{ +var sendTestCases = []OutgoingTestCase{ { Label: "Send simple message", MsgText: "Simple message", @@ -472,8 +473,8 @@ var sendTestCases = []ChannelSendTestCase{ }, } -func mockAttachmentURLs(mediaServer *httptest.Server, testCases []ChannelSendTestCase) []ChannelSendTestCase { - casesWithMockedUrls := make([]ChannelSendTestCase, len(testCases)) +func mockAttachmentURLs(mediaServer *httptest.Server, testCases []OutgoingTestCase) []OutgoingTestCase { + casesWithMockedUrls := make([]OutgoingTestCase, len(testCases)) for i, testCase := range testCases { mockedCase := testCase @@ -497,5 +498,5 @@ func TestSend(t *testing.T) { res.Write([]byte("media body")) })) mockedSendTestCases := mockAttachmentURLs(mediaServer, sendTestCases) - RunChannelSendTestCases(t, testChannels[0], newHandler(), mockedSendTestCases, []string{"token123xyz", "abc123xyz"}, nil) + RunOutgoingTestCases(t, testChannels[0], newHandler(), mockedSendTestCases, []string{"token123xyz", "abc123xyz"}, nil) } diff --git a/handlers/wavy/wavy.go b/handlers/wavy/handler.go similarity index 81% rename from handlers/wavy/wavy.go rename to handlers/wavy/handler.go index ec12c46ff..e4531ff37 100644 --- a/handlers/wavy/wavy.go +++ b/handlers/wavy/handler.go @@ -3,7 +3,6 @@ package wavy import ( "bytes" "context" - "encoding/json" "fmt" "net/http" "strings" @@ -12,6 +11,7 @@ import ( "github.com/buger/jsonparser" "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/gocommon/jsonx" ) var ( @@ -39,20 +39,20 @@ func (h *handler) Initialize(s courier.Server) error { return nil } -var statusMapping = map[int]courier.MsgStatusValue{ - 2: courier.MsgSent, - 4: courier.MsgDelivered, - 101: courier.MsgFailed, - 102: courier.MsgFailed, - 103: courier.MsgFailed, - 104: courier.MsgSent, - 201: courier.MsgFailed, - 202: courier.MsgFailed, - 203: courier.MsgFailed, - 204: courier.MsgFailed, - 205: courier.MsgFailed, - 207: courier.MsgFailed, - 301: courier.MsgErrored, +var statusMapping = map[int]courier.MsgStatus{ + 2: courier.MsgStatusSent, + 4: courier.MsgStatusDelivered, + 101: courier.MsgStatusFailed, + 102: courier.MsgStatusFailed, + 103: courier.MsgStatusFailed, + 104: courier.MsgStatusSent, + 201: courier.MsgStatusFailed, + 202: courier.MsgStatusFailed, + 203: courier.MsgStatusFailed, + 204: courier.MsgStatusFailed, + 205: courier.MsgStatusFailed, + 207: courier.MsgStatusFailed, + 301: courier.MsgStatusErrored, } type sentStatusPayload struct { @@ -68,7 +68,7 @@ func (h *handler) sentStatusMessage(ctx context.Context, channel courier.Channel } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, payload.CollerationID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, payload.CollerationID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } @@ -85,7 +85,7 @@ func (h *handler) deliveredStatusMessage(ctx context.Context, channel courier.Ch } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, payload.CollerationID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, payload.CollerationID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } @@ -110,7 +110,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w msg := h.Backend().NewIncomingMsg(channel, urn, payload.Message, payload.ID, clog).WithReceivedOn(date.UTC()) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } @@ -120,7 +120,7 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for %s channel", msg.Channel().ChannelType()) @@ -131,16 +131,13 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no token set for %s channel", msg.Channel().ChannelType()) } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) payload := mtPayload{} payload.Destination = strings.TrimPrefix(msg.URN().Path(), "+") payload.Message = handlers.GetTextAndAttachments(msg) - jsonPayload, err := json.Marshal(payload) - if err != nil { - return nil, err - } + jsonPayload := jsonx.MustMarshal(payload) req, err := http.NewRequest(http.MethodPost, sendURL, bytes.NewReader(jsonPayload)) if err != nil { @@ -151,7 +148,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("username", username) req.Header.Set("authenticationtoken", token) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -161,6 +158,6 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann status.SetExternalID(externalID) } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/wavy/wavy_test.go b/handlers/wavy/handler_test.go similarity index 91% rename from handlers/wavy/wavy_test.go rename to handlers/wavy/handler_test.go index 70bb30263..88cdf78be 100644 --- a/handlers/wavy/wavy_test.go +++ b/handlers/wavy/handler_test.go @@ -74,7 +74,7 @@ var ( ` ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Message", URL: receiveURL, @@ -106,7 +106,7 @@ var testCases = []ChannelHandleTestCase{ Data: validSentStatus, ExpectedRespStatus: 200, ExpectedBodyContains: "Status Update Accepted", - ExpectedMsgStatus: courier.MsgSent, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusSent}}, }, { Label: "Unknown Sent Status Valid", @@ -135,7 +135,7 @@ var testCases = []ChannelHandleTestCase{ Data: validDeliveredStatus, ExpectedRespStatus: 200, ExpectedBodyContains: "Status Update Accepted", - ExpectedMsgStatus: courier.MsgDelivered, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "12345", Status: courier.MsgStatusDelivered}}, }, { Label: "Unknown Delivered Status Valid", @@ -160,19 +160,19 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { RunChannelBenchmarks(b, testChannels, newHandler(), testCases) } -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -206,11 +206,11 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "WV", "2020", "BR", - map[string]interface{}{ + map[string]any{ courier.ConfigUsername: "user1", courier.ConfigAuthToken: "token", }) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"token"}, nil) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"token"}, nil) } diff --git a/handlers/webhook.go b/handlers/webhook.go index 4188db79c..6b25c2e0f 100644 --- a/handlers/webhook.go +++ b/handlers/webhook.go @@ -6,7 +6,6 @@ import ( "net/url" "github.com/nyaruka/courier" - "github.com/nyaruka/courier/utils" "github.com/nyaruka/gocommon/httpx" "github.com/pkg/errors" ) @@ -39,7 +38,7 @@ func SendWebhooks(channel courier.Channel, r *http.Request, configWebhook interf req.Header.Set(name, value.(string)) } - trace, err := httpx.DoTrace(utils.GetHTTPClient(), req, nil, nil, 1024) + trace, err := httpx.DoTrace(http.DefaultClient, req, nil, nil, 1024) if trace != nil { clog.HTTP(trace) } diff --git a/handlers/wechat/wechat.go b/handlers/wechat/handler.go similarity index 93% rename from handlers/wechat/wechat.go rename to handlers/wechat/handler.go index 79308a8c9..d104d6d4e 100644 --- a/handlers/wechat/wechat.go +++ b/handlers/wechat/handler.go @@ -127,7 +127,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w if payload.MsgType == "event" && payload.Event == "subscribe" { clog.SetType(courier.ChannelLogTypeEventReceive) - channelEvent := h.Backend().NewChannelEvent(channel, courier.NewConversation, urn, clog) + channelEvent := h.Backend().NewChannelEvent(channel, courier.EventTypeNewConversation, urn, clog) err := h.Backend().WriteChannelEvent(ctx, channelEvent, clog) if err != nil { @@ -152,11 +152,11 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w } // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } // WriteMsgSuccessResponse writes our response -func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *handler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { w.WriteHeader(200) _, err := fmt.Fprint(w, "") // WeChat expected empty string to not retry looking for passive reply return err @@ -177,7 +177,7 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { accessToken, err := h.getAccessToken(ctx, msg.Channel(), clog) if err != nil { return nil, err @@ -190,7 +190,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann partSendURL, _ := url.Parse(fmt.Sprintf("%s/%s", sendURL, "message/custom/send")) partSendURL.RawQuery = form.Encode() - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) for _, part := range parts { wcMsg := &mtPayload{} @@ -209,12 +209,12 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - resp, _, err := handlers.RequestHTTP(req, clog) + resp, _, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil @@ -239,7 +239,7 @@ func (h *handler) DescribeURN(ctx context.Context, channel courier.Channel, urn req, _ := http.NewRequest(http.MethodGet, reqURL.String(), nil) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return nil, errors.New("unable to look up contact data") } @@ -326,7 +326,7 @@ func (h *handler) fetchAccessToken(ctx context.Context, channel courier.Channel, req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return "", 0, err } diff --git a/handlers/wechat/wechat_test.go b/handlers/wechat/handler_test.go similarity index 91% rename from handlers/wechat/wechat_test.go rename to handlers/wechat/handler_test.go index 2ff51aa8d..843e238e4 100644 --- a/handlers/wechat/wechat_test.go +++ b/handlers/wechat/handler_test.go @@ -5,6 +5,8 @@ import ( "crypto/sha1" "encoding/hex" "io" + "log" + "log/slog" "net/http" "net/http/httptest" "net/url" @@ -18,13 +20,12 @@ import ( "github.com/nyaruka/courier/test" "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/urns" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) var testChannels = []courier.Channel{ test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "WC", "2020", "US", - map[string]interface{}{courier.ConfigSecret: "secret123", configAppSecret: "app-secret123", configAppID: "app-id"}), + map[string]any{courier.ConfigSecret: "secret123", configAppSecret: "app-secret123", configAppID: "app-id"}), } var ( @@ -138,7 +139,7 @@ func addInvalidSignature(r *http.Request) { r.URL.RawQuery = query.Encode() } -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ {Label: "Receive Message", URL: receiveURL, Data: validMsg, ExpectedRespStatus: 200, ExpectedBodyContains: "", ExpectedMsgText: Sp("Simple Message"), ExpectedURN: "wechat:1234", ExpectedExternalID: "123456", ExpectedDate: time.Date(2018, 2, 16, 9, 47, 4, 438000000, time.UTC)}, @@ -151,8 +152,16 @@ var testCases = []ChannelHandleTestCase{ ExpectedAttachments: []string{"https://api.weixin.qq.com/cgi-bin/media/get?media_id=12"}, ExpectedDate: time.Date(2018, 2, 16, 9, 47, 4, 438000000, time.UTC)}, - {Label: "Subscribe Event", URL: receiveURL, Data: subscribeEvent, ExpectedRespStatus: 200, ExpectedBodyContains: "Event Accepted", - ExpectedEvent: courier.NewConversation, ExpectedURN: "wechat:1234"}, + { + Label: "Subscribe Event", + URL: receiveURL, + Data: subscribeEvent, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Event Accepted", + ExpectedEvents: []ExpectedEvent{ + {Type: courier.EventTypeNewConversation, URN: "wechat:1234"}, + }, + }, {Label: "Unsubscribe Event", URL: receiveURL, Data: unsubscribeEvent, ExpectedRespStatus: 200, ExpectedBodyContains: "unknown event"}, @@ -163,8 +172,8 @@ var testCases = []ChannelHandleTestCase{ PrepRequest: addInvalidSignature}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -172,7 +181,7 @@ func BenchmarkHandler(b *testing.B) { } // mocks the call to the WeChat API -func buildMockWCAPI(testCases []ChannelHandleTestCase) *httptest.Server { +func buildMockWCAPI(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() @@ -204,9 +213,8 @@ func buildMockWCAPI(testCases []ChannelHandleTestCase) *httptest.Server { func newServer(backend courier.Backend) courier.Server { // for benchmarks, log to null - logger := logrus.New() - logger.Out = io.Discard - logrus.SetOutput(io.Discard) + logger := slog.Default() + log.SetOutput(io.Discard) config := courier.NewConfig() config.DB = "postgres://courier_test:temba@localhost:5432/courier_test?sslmode=disable" config.Redis = "redis://localhost:6379/0" @@ -285,11 +293,11 @@ func TestBuildAttachmentRequest(t *testing.T) { } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURL = s.URL } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -350,8 +358,8 @@ func setupBackend(mb *test.MockBackend) { rc.Do("SET", "channel-token:8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ACCESS_TOKEN") } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 - var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "WC", "2020", "US", map[string]interface{}{configAppSecret: "secret123", configAppID: "app-id"}) - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"secret123"}, setupBackend) + var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "WC", "2020", "US", map[string]any{configAppSecret: "secret123", configAppID: "app-id"}) + RunOutgoingTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{"secret123"}, setupBackend) } diff --git a/handlers/weniwebchat/weniwebchat.go b/handlers/weniwebchat/weniwebchat.go index ba21bea84..95b18f47c 100644 --- a/handlers/weniwebchat/weniwebchat.go +++ b/handlers/weniwebchat/weniwebchat.go @@ -97,7 +97,7 @@ func (h *handler) receiveEvent(ctx context.Context, channel courier.Channel, w h msg.WithAttachment(mediaURL) } - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{msg}, w, r, clog) } var timestamp = "" @@ -120,12 +120,12 @@ type moMessage struct { QuickReplies []string `json:"quick_replies,omitempty"` } -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgSent, clog) +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusSent, clog) baseURL := msg.Channel().StringConfigForKey(courier.ConfigBaseURL, "") if baseURL == "" { - return nil, errors.New("blank base_url") + return status, errors.New("blank base_url") } sendURL := fmt.Sprintf("%s/send", baseURL) @@ -161,7 +161,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } } else { logrus.WithField("channel_uuid", msg.Channel().UUID()).Error("unknown attachment mime type: ", mimeType) - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) break attachmentsLoop } @@ -180,18 +180,18 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann body, err := json.Marshal(&payload) if err != nil { logrus.WithField("channel_uuid", msg.Channel().UUID()).WithError(err).Error("Error sending message") - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) break attachmentsLoop } req, _ := http.NewRequest(http.MethodPost, sendURL, bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") - _, _, err = handlers.RequestHTTP(req, clog) + _, _, err = h.RequestHTTP(req, clog) if err != nil { logrus.WithField("channel_uuid", msg.Channel().UUID()).WithError(err).Error("Message Send Error") - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) } if err != nil { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) break attachmentsLoop } } @@ -206,14 +206,14 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann body, err := json.Marshal(&payload) if err != nil { logrus.WithField("channel_uuid", msg.Channel().UUID()).WithError(err).Error("Message Send Error") - status.SetStatus(courier.MsgFailed) - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) + status.SetStatus(courier.MsgStatusFailed) } else { req, _ := http.NewRequest(http.MethodPost, sendURL, bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") - _, _, err := handlers.RequestHTTP(req, clog) + _, _, err := h.RequestHTTP(req, clog) if err != nil { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) } } diff --git a/handlers/weniwebchat/weniwebchat_test.go b/handlers/weniwebchat/weniwebchat_test.go index 037a23c8a..301d2cb82 100644 --- a/handlers/weniwebchat/weniwebchat_test.go +++ b/handlers/weniwebchat/weniwebchat_test.go @@ -74,7 +74,7 @@ const ( ` ) -var testCases = []ChannelHandleTestCase{ +var testCases = []IncomingTestCase{ { Label: "Receive Valid Text Msg", URL: receiveURL, @@ -135,8 +135,8 @@ var testCases = []ChannelHandleTestCase{ }, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), testCases) } func BenchmarkHandler(b *testing.B) { @@ -145,13 +145,13 @@ func BenchmarkHandler(b *testing.B) { // SendMsg test -func prepareSendMsg(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { c.(*test.MockChannel).SetConfig(courier.ConfigBaseURL, s.URL) timestamp = "1616700878" } -func mockAttachmentURLs(mediaServer *httptest.Server, testCases []ChannelSendTestCase) []ChannelSendTestCase { - casesWithMockedUrls := make([]ChannelSendTestCase, len(testCases)) +func mockAttachmentURLs(mediaServer *httptest.Server, testCases []OutgoingTestCase) []OutgoingTestCase { + casesWithMockedUrls := make([]OutgoingTestCase, len(testCases)) for i, testCase := range testCases { mockedCase := testCase @@ -164,38 +164,38 @@ func mockAttachmentURLs(mediaServer *httptest.Server, testCases []ChannelSendTes return casesWithMockedUrls } -var sendTestCases = []ChannelSendTestCase{ +var sendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message", MsgURN: "ext:371298371241", - ExpectedMsgStatus: courier.MsgSent, + ExpectedMsgStatus: courier.MsgStatusSent, ExpectedRequestPath: "/send", ExpectedHeaders: map[string]string{"Content-type": "application/json"}, ExpectedRequestBody: `{"type":"message","to":"371298371241","from":"250788383383","message":{"type":"text","timestamp":"1616700878","text":"Simple Message"}}`, MockResponseStatus: 200, - SendPrep: prepareSendMsg, + SendPrep: setSendURL, }, { Label: "Unicode Send", MsgText: "☺", MsgURN: "ext:371298371241", - ExpectedMsgStatus: courier.MsgSent, + ExpectedMsgStatus: courier.MsgStatusSent, ExpectedRequestPath: "/send", ExpectedHeaders: map[string]string{"Content-type": "application/json"}, ExpectedRequestBody: `{"type":"message","to":"371298371241","from":"250788383383","message":{"type":"text","timestamp":"1616700878","text":"☺"}}`, MockResponseStatus: 200, - SendPrep: prepareSendMsg, + SendPrep: setSendURL, }, { Label: "invalid Text Send", MsgText: "Error", MsgURN: "ext:371298371241", - ExpectedMsgStatus: courier.MsgFailed, + ExpectedMsgStatus: courier.MsgStatusFailed, ExpectedRequestPath: "/send", ExpectedHeaders: map[string]string{"Content-type": "application/json"}, ExpectedRequestBody: `{"type":"message","to":"371298371241","from":"250788383383","message":{"type":"text","timestamp":"1616700878","text":"Error"}}`, - SendPrep: prepareSendMsg, + SendPrep: setSendURL, }, { Label: "Medias Send", @@ -207,41 +207,41 @@ var sendTestCases = []ChannelSendTestCase{ "video/mp4:https://foo.bar/video.mp4", }, MsgURN: "ext:371298371241", - ExpectedMsgStatus: courier.MsgSent, + ExpectedMsgStatus: courier.MsgStatusSent, MockResponseStatus: 200, - SendPrep: prepareSendMsg, + SendPrep: setSendURL, }, { Label: "Invalid Media Type Send", MsgText: "Medias", MsgAttachments: []string{"foo/bar:https://foo.bar/foo.bar"}, MsgURN: "ext:371298371241", - ExpectedMsgStatus: courier.MsgFailed, + ExpectedMsgStatus: courier.MsgStatusFailed, MockResponseStatus: 400, - SendPrep: prepareSendMsg, + SendPrep: setSendURL, }, { Label: "Invalid Media Send", MsgText: "Medias", MsgAttachments: []string{"image/png:https://foo.bar/image.png"}, MsgURN: "ext:371298371241", - ExpectedMsgStatus: courier.MsgFailed, - SendPrep: prepareSendMsg, + ExpectedMsgStatus: courier.MsgStatusFailed, + SendPrep: setSendURL, }, { Label: "No Timestamp Prepare", MsgText: "No prepare", MsgURN: "ext:371298371241", - ExpectedMsgStatus: courier.MsgSent, + ExpectedMsgStatus: courier.MsgStatusSent, MockResponseStatus: 200, - SendPrep: func(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { + SendPrep: func(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { c.(*test.MockChannel).SetConfig(courier.ConfigBaseURL, s.URL) timestamp = "" }, }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { mediaServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { defer req.Body.Close() res.WriteHeader(200) @@ -251,5 +251,5 @@ func TestSending(t *testing.T) { mockedSendTestCases := mockAttachmentURLs(mediaServer, sendTestCases) mediaServer.Close() - RunChannelSendTestCases(t, testChannels[0], newHandler(), mockedSendTestCases, nil, nil) + RunOutgoingTestCases(t, testChannels[0], newHandler(), mockedSendTestCases, nil, nil) } diff --git a/handlers/whatsapp/whatsapp.go b/handlers/whatsapp_legacy/handler.go similarity index 92% rename from handlers/whatsapp/whatsapp.go rename to handlers/whatsapp_legacy/handler.go index db0949c26..28796796c 100644 --- a/handlers/whatsapp/whatsapp.go +++ b/handlers/whatsapp_legacy/handler.go @@ -1,10 +1,11 @@ -package whatsapp +package whatsapp_legacy import ( "bytes" "context" "encoding/json" "fmt" + "log/slog" "net/http" "net/url" "strconv" @@ -18,6 +19,8 @@ import ( "github.com/nyaruka/courier/backends/rapidpro" "github.com/nyaruka/courier/handlers" "github.com/nyaruka/courier/utils" + "github.com/nyaruka/gocommon/i18n" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/redisx" "github.com/patrickmn/go-cache" @@ -180,12 +183,12 @@ type eventsPayload struct { func checkBlockedContact(payload *eventsPayload, ctx context.Context, channel courier.Channel, h *handler, clog *courier.ChannelLog) error { if len(payload.Contacts) > 0 { if contactURN, err := urns.NewWhatsAppURN(payload.Contacts[0].WaID); err == nil { - if contact, err := h.Backend().GetContact(ctx, channel, contactURN, channel.StringConfigForKey(courier.ConfigAuthToken, ""), payload.Contacts[0].Profile.Name, clog); err == nil { + if contact, err := h.Backend().GetContact(ctx, channel, contactURN, nil, payload.Contacts[0].Profile.Name, clog); err == nil { c, err := json.Marshal(contact) if err != nil { return err } - var dbc rapidpro.DBContact + var dbc rapidpro.Contact if err = json.Unmarshal(c, &dbc); err != nil { return err } @@ -203,7 +206,7 @@ func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w events := make([]courier.Event, 0, 2) // the list of data we will return in our response - data := make([]interface{}, 0, 2) + data := make([]any, 0, 2) seenMsgIDs := make(map[string]bool, 2) @@ -313,15 +316,8 @@ func (h *handler) receiveEvents(ctx context.Context, channel courier.Channel, w continue } - event := h.Backend().NewMsgStatusForExternalID(channel, status.ID, msgStatus, clog) - err := h.Backend().WriteMsgStatus(ctx, event) - - // we don't know about this message, just tell them we ignored it - if err == courier.ErrMsgNotFound { - data = append(data, courier.NewInfoData(fmt.Sprintf("message id: %s not found, ignored", status.ID))) - continue - } - + event := h.Backend().NewStatusUpdateByExternalID(channel, status.ID, msgStatus, clog) + err := h.Backend().WriteStatusUpdate(ctx, event) if err != nil { return nil, err } @@ -370,19 +366,18 @@ func (h *handler) BuildAttachmentRequest(ctx context.Context, b courier.Backend, // set the access token as the authorization header req, _ := http.NewRequest(http.MethodGet, attachmentURL, nil) - req.Header.Set("User-Agent", utils.HTTPUserAgent) setWhatsAppAuthHeader(&req.Header, channel) return req, nil } var _ courier.AttachmentRequestBuilder = (*handler)(nil) -var waStatusMapping = map[string]courier.MsgStatusValue{ - "sending": courier.MsgWired, - "sent": courier.MsgSent, - "delivered": courier.MsgDelivered, - "read": courier.MsgDelivered, - "failed": courier.MsgFailed, +var waStatusMapping = map[string]courier.MsgStatus{ + "sending": courier.MsgStatusWired, + "sent": courier.MsgStatusSent, + "delivered": courier.MsgStatusDelivered, + "read": courier.MsgStatusDelivered, + "failed": courier.MsgStatusFailed, } var waIgnoreStatuses = map[string]bool{ @@ -554,7 +549,7 @@ type mtErrorPayload struct { const maxMsgLength = 4096 // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { conn := h.Backend().RedisPool().Get() defer conn.Close() @@ -571,7 +566,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } sendPath, _ := url.Parse("/v1/messages") - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) var wppID string @@ -584,7 +579,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann for i, payload := range payloads { externalID := "" - wppID, externalID, err = sendWhatsAppMsg(conn, msg, sendPath, payload, clog) + wppID, externalID, err = h.sendWhatsAppMsg(conn, msg, sendPath, payload, clog) if err != nil { break } @@ -600,13 +595,13 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // so update contact URN if wppID != "" if wppID != "" { newURN, _ := urns.NewWhatsAppURN(wppID) - err = status.SetUpdatedURN(msg.URN(), newURN) + err = status.SetURNUpdate(msg.URN(), newURN) if err != nil { clog.RawError(err) } } - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) } return status, nil @@ -617,8 +612,8 @@ func (h *handler) WriteRequestError(ctx context.Context, w http.ResponseWriter, return courier.WriteError(w, http.StatusOK, err) } -func buildPayloads(msg courier.Msg, h *handler, clog *courier.ChannelLog) ([]interface{}, error) { - var payloads []interface{} +func buildPayloads(msg courier.MsgOut, h *handler, clog *courier.ChannelLog) ([]any, error) { + var payloads []any var err error parts := handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength) @@ -644,7 +639,7 @@ func buildPayloads(msg courier.Msg, h *handler, clog *courier.ChannelLog) ([]int mimeType, mediaURL := handlers.SplitAttachment(attachment) mediaID, err := h.fetchMediaID(msg, mimeType, mediaURL, clog) if err != nil { - logrus.WithField("channel_uuid", msg.Channel().UUID()).WithError(err).Error("error while uploading media to whatsapp") + slog.Error("error while uploading media to whatsapp", "error", err, "channel_uuid", msg.Channel().UUID()) } fileURL := mediaURL if err == nil && mediaID != "" { @@ -671,7 +666,7 @@ func buildPayloads(msg courier.Msg, h *handler, clog *courier.ChannelLog) ([]int // Logging error if err != nil { - logrus.WithField("channel_uuid", msg.Channel().UUID()).WithError(err).Error("Error while parsing the media URL") + slog.Error("Error while parsing the media URL", "error", err, "channel_uuid", msg.Channel().UUID()) } payload.Document = mediaPayload payloads = append(payloads, payload) @@ -949,7 +944,7 @@ func buildPayloads(msg courier.Msg, h *handler, clog *courier.ChannelLog) ([]int } // fetchMediaID tries to fetch the id for the uploaded media, setting the result in redis. -func (h *handler) fetchMediaID(msg courier.Msg, mimeType, mediaURL string, clog *courier.ChannelLog) (string, error) { +func (h *handler) fetchMediaID(msg courier.MsgOut, mimeType, mediaURL string, clog *courier.ChannelLog) (string, error) { // check in cache first rc := h.Backend().RedisPool().Get() defer rc.Close() @@ -978,7 +973,7 @@ func (h *handler) fetchMediaID(msg courier.Msg, mimeType, mediaURL string, clog return "", errors.Wrapf(err, "error building media request") } - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { failedMediaCache.Set(failKey, true, cache.DefaultExpiration) return "", nil @@ -1005,7 +1000,7 @@ func (h *handler) fetchMediaID(msg courier.Msg, mimeType, mediaURL string, clog } else { req.Header.Add("Content-Type", mtype) } - resp, respBody, err = handlers.RequestHTTP(req, clog) + resp, respBody, err = h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { failedMediaCache.Set(failKey, true, cache.DefaultExpiration) return "", errors.Wrapf(err, "error uploading media to whatsapp") @@ -1026,17 +1021,13 @@ func (h *handler) fetchMediaID(msg courier.Msg, mimeType, mediaURL string, clog return mediaID, nil } -func sendWhatsAppMsg(rc redis.Conn, msg courier.Msg, sendPath *url.URL, payload interface{}, clog *courier.ChannelLog) (string, string, error) { - jsonBody, err := json.Marshal(payload) - - if err != nil { - return "", "", err - } +func (h *handler) sendWhatsAppMsg(rc redis.Conn, msg courier.MsgOut, sendPath *url.URL, payload any, clog *courier.ChannelLog) (string, string, error) { + jsonBody := jsonx.MustMarshal(payload) req, _ := http.NewRequest(http.MethodPost, sendPath.String(), bytes.NewReader(jsonBody)) req.Header = buildWhatsAppHeaders(msg.Channel()) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil { return "", "", err } @@ -1076,7 +1067,7 @@ func sendWhatsAppMsg(rc redis.Conn, msg courier.Msg, sendPath *url.URL, payload } // check contact baseURL := fmt.Sprintf("%s://%s", sendPath.Scheme, sendPath.Host) - checkResp, err := checkWhatsAppContact(msg.Channel(), baseURL, msg.URN(), clog) + checkResp, err := h.checkWhatsAppContact(msg.Channel(), baseURL, msg.URN(), clog) if checkResp == nil { return "", "", err } @@ -1087,7 +1078,7 @@ func sendWhatsAppMsg(rc redis.Conn, msg courier.Msg, sendPath *url.URL, payload wppID, err := jsonparser.GetString(checkResp, "contacts", "[0]", "wa_id") if err == nil { - var updatedPayload interface{} + var updatedPayload any // handle msg type casting switch v := payload.(type) { @@ -1116,11 +1107,7 @@ func sendWhatsAppMsg(rc redis.Conn, msg courier.Msg, sendPath *url.URL, payload // marshal updated payload if updatedPayload != nil { payload = updatedPayload - jsonBody, err = json.Marshal(payload) - - if err != nil { - return "", "", err - } + jsonBody = jsonx.MustMarshal(payload) } } // try send msg again @@ -1134,7 +1121,7 @@ func sendWhatsAppMsg(rc redis.Conn, msg courier.Msg, sendPath *url.URL, payload reqRetry.URL.RawQuery = fmt.Sprintf("%s=1", retryParam) } - retryResp, retryRespBody, err := handlers.RequestHTTP(reqRetry, clog) + retryResp, retryRespBody, err := h.RequestHTTP(reqRetry, clog) if err != nil || retryResp.StatusCode/100 != 2 { return "", "", errors.New("error making retry request") } @@ -1166,7 +1153,6 @@ func buildWhatsAppHeaders(channel courier.Channel) http.Header { header := http.Header{ "Content-Type": []string{"application/json"}, "Accept": []string{"application/json"}, - "User-Agent": []string{utils.HTTPUserAgent}, } setWhatsAppAuthHeader(&header, channel) return header @@ -1204,22 +1190,18 @@ type mtContactCheckPayload struct { ForceCheck bool `json:"force_check"` } -func checkWhatsAppContact(channel courier.Channel, baseURL string, urn urns.URN, clog *courier.ChannelLog) ([]byte, error) { +func (h *handler) checkWhatsAppContact(channel courier.Channel, baseURL string, urn urns.URN, clog *courier.ChannelLog) ([]byte, error) { payload := mtContactCheckPayload{ Blocking: "wait", Contacts: []string{fmt.Sprintf("+%s", urn.Path())}, ForceCheck: true, } - reqBody, err := json.Marshal(payload) - - if err != nil { - return nil, err - } + reqBody := jsonx.MustMarshal(payload) sendURL := fmt.Sprintf("%s/v1/contacts", baseURL) req, _ := http.NewRequest(http.MethodPost, sendURL, bytes.NewReader(reqBody)) req.Header = buildWhatsAppHeaders(channel) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return nil, errors.New("error checking contact") } @@ -1235,7 +1217,7 @@ func checkWhatsAppContact(channel courier.Channel, baseURL string, urn urns.URN, } } -func (h *handler) getTemplating(msg courier.Msg) (*MsgTemplating, error) { +func (h *handler) getTemplating(msg courier.MsgOut) (*MsgTemplating, error) { if len(msg.Metadata()) == 0 { return nil, nil } @@ -1267,16 +1249,16 @@ type MsgTemplating struct { Variables []string `json:"variables"` } -func getSupportedLanguage(lc courier.Locale) string { +func getSupportedLanguage(lc i18n.Locale) string { // look for exact match if lang := supportedLanguages[lc]; lang != "" { return lang } // if we have a country, strip that off and look again for a match - l, c := lc.ToParts() + l, c := lc.Split() if c != "" { - if lang := supportedLanguages[courier.Locale(l)]; lang != "" { + if lang := supportedLanguages[i18n.Locale(l)]; lang != "" { return lang } } @@ -1284,7 +1266,7 @@ func getSupportedLanguage(lc courier.Locale) string { } // Mapping from engine locales to supported languages, see https://developers.facebook.com/docs/whatsapp/api/messages/message-templates/ -var supportedLanguages = map[courier.Locale]string{ +var supportedLanguages = map[i18n.Locale]string{ "afr": "af", // Afrikaans "sqi": "sq", // Albanian "ara": "ar", // Arabic diff --git a/handlers/whatsapp/whatsapp_test.go b/handlers/whatsapp_legacy/handler_test.go similarity index 92% rename from handlers/whatsapp/whatsapp_test.go rename to handlers/whatsapp_legacy/handler_test.go index dc9129f18..180f2c92b 100644 --- a/handlers/whatsapp/whatsapp_test.go +++ b/handlers/whatsapp_legacy/handler_test.go @@ -1,4 +1,4 @@ -package whatsapp +package whatsapp_legacy import ( "context" @@ -14,6 +14,7 @@ import ( . "github.com/nyaruka/courier/handlers" "github.com/nyaruka/courier/test" "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/gocommon/i18n" "github.com/stretchr/testify/assert" ) @@ -23,7 +24,7 @@ var testChannels = []courier.Channel{ "WA", "250788383383", "RW", - map[string]interface{}{ + map[string]any{ "auth_token": "the-auth-token", "base_url": "https://foo.bar/", }), @@ -32,7 +33,7 @@ var testChannels = []courier.Channel{ "D3", "250788383383", "RW", - map[string]interface{}{ + map[string]any{ "auth_token": "the-auth-token", "base_url": "https://foo.bar/", }), @@ -41,7 +42,7 @@ var testChannels = []courier.Channel{ "TXW", "250788383383", "RW", - map[string]interface{}{ + map[string]any{ "auth_token": "the-auth-token", "base_url": "https://foo.bar/", }), @@ -354,7 +355,7 @@ var ( txReceiveURL = "/c/txw/8eb23e93-5ecb-45ba-b726-3b064e0c568c/receive" ) -var waTestCases = []ChannelHandleTestCase{ +var waTestCases = []IncomingTestCase{ { Label: "Receive Valid Message", URL: waReceiveURL, @@ -525,8 +526,9 @@ var waTestCases = []ChannelHandleTestCase{ Data: validStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `"type":"status"`, - ExpectedMsgStatus: "S", - ExpectedExternalID: "9712A34B4A8B6AD50F", + ExpectedStatuses: []ExpectedStatus{ + {ExternalID: "9712A34B4A8B6AD50F", Status: courier.MsgStatusSent}, + }, }, { Label: "Receive invalid JSON", @@ -570,8 +572,8 @@ func TestBuildAttachmentRequest(t *testing.T) { assert.Equal(t, "Bearer the-auth-token", req.Header.Get("Authorization")) } -func replaceTestcaseURLs(tcs []ChannelHandleTestCase, url string) []ChannelHandleTestCase { - replaced := make([]ChannelHandleTestCase, len(tcs)) +func replaceTestcaseURLs(tcs []IncomingTestCase, url string) []IncomingTestCase { + replaced := make([]IncomingTestCase, len(tcs)) for i, tc := range tcs { tc.URL = url replaced[i] = tc @@ -579,10 +581,10 @@ func replaceTestcaseURLs(tcs []ChannelHandleTestCase, url string) []ChannelHandl return replaced } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newWAHandler(courier.ChannelType("WA"), "WhatsApp"), waTestCases) - RunChannelTestCases(t, testChannels, newWAHandler(courier.ChannelType("D3"), "360Dialog"), replaceTestcaseURLs(waTestCases, d3ReceiveURL)) - RunChannelTestCases(t, testChannels, newWAHandler(courier.ChannelType("TXW"), "TextIt"), replaceTestcaseURLs(waTestCases, txReceiveURL)) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newWAHandler(courier.ChannelType("WA"), "WhatsApp"), waTestCases) + RunIncomingTestCases(t, testChannels, newWAHandler(courier.ChannelType("D3"), "360Dialog"), replaceTestcaseURLs(waTestCases, d3ReceiveURL)) + RunIncomingTestCases(t, testChannels, newWAHandler(courier.ChannelType("TXW"), "TextIt"), replaceTestcaseURLs(waTestCases, txReceiveURL)) } func BenchmarkHandler(b *testing.B) { @@ -592,12 +594,12 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { retryParam = "retry" c.(*test.MockChannel).SetConfig("base_url", s.URL) } -var defaultSendTestCases = []ChannelSendTestCase{ +var defaultSendTestCases = []OutgoingTestCase{ { Label: "Link Sending", MsgText: "Link Sending https://link.com", @@ -1038,7 +1040,7 @@ var defaultSendTestCases = []ChannelSendTestCase{ }, } -var mediaCacheSendTestCases = []ChannelSendTestCase{ +var mediaCacheSendTestCases = []OutgoingTestCase{ { Label: "Media Upload Error", MsgText: "document caption", @@ -1152,23 +1154,8 @@ var mediaCacheSendTestCases = []ChannelSendTestCase{ }, } -var hsmSupportSendTestCases = []ChannelSendTestCase{ - { - Label: "Template Send", - MsgText: "templated message", - MsgURN: "whatsapp:250788123123", - MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "language": "eng", "variables": ["Chef", "tomorrow"]}}`), - MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`, - MockResponseStatus: 200, - ExpectedRequestBody: `{"to":"250788123123","type":"hsm","hsm":{"namespace":"waba_namespace","element_name":"revive_issue","language":{"policy":"deterministic","code":"en"},"localizable_params":[{"default":"Chef"},{"default":"tomorrow"}]}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "157b5e14568e8", - SendPrep: setSendURL, - }, -} - -func mockAttachmentURLs(mediaServer *httptest.Server, testCases []ChannelSendTestCase) []ChannelSendTestCase { - casesWithMockedUrls := make([]ChannelSendTestCase, len(testCases)) +func mockAttachmentURLs(mediaServer *httptest.Server, testCases []OutgoingTestCase) []OutgoingTestCase { + casesWithMockedUrls := make([]OutgoingTestCase, len(testCases)) for i, testCase := range testCases { mockedCase := testCase @@ -1181,26 +1168,17 @@ func mockAttachmentURLs(mediaServer *httptest.Server, testCases []ChannelSendTes return casesWithMockedUrls } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "WA", "250788383383", "US", - map[string]interface{}{ - "auth_token": "token123", - "base_url": "https://foo.bar/", - "fb_namespace": "waba_namespace", - "version": "v2.35.2", - }) - - var hsmSupportChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "WA", "250788383383", "US", - map[string]interface{}{ + map[string]any{ "auth_token": "token123", "base_url": "https://foo.bar/", "fb_namespace": "waba_namespace", - "hsm_support": true, "version": "v2.35.2", }) var d3Channel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "D3", "250788383383", "US", - map[string]interface{}{ + map[string]any{ "auth_token": "token123", "base_url": "https://foo.bar/", "fb_namespace": "waba_namespace", @@ -1208,17 +1186,16 @@ func TestSending(t *testing.T) { }) var txwChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TXW", "250788383383", "US", - map[string]interface{}{ + map[string]any{ "auth_token": "token123", "base_url": "https://foo.bar/", "fb_namespace": "waba_namespace", "version": "v2.35.2", }) - RunChannelSendTestCases(t, defaultChannel, newWAHandler(courier.ChannelType("WA"), "WhatsApp"), defaultSendTestCases, []string{"token123"}, nil) - RunChannelSendTestCases(t, hsmSupportChannel, newWAHandler(courier.ChannelType("WA"), "WhatsApp"), hsmSupportSendTestCases, []string{"token123"}, nil) - RunChannelSendTestCases(t, d3Channel, newWAHandler(courier.ChannelType("D3"), "360Dialog"), defaultSendTestCases, []string{"token123"}, nil) - RunChannelSendTestCases(t, txwChannel, newWAHandler(courier.ChannelType("TXW"), "TextIt"), defaultSendTestCases, []string{"token123"}, nil) + RunOutgoingTestCases(t, defaultChannel, newWAHandler(courier.ChannelType("WA"), "WhatsApp"), defaultSendTestCases, []string{"token123"}, nil) + RunOutgoingTestCases(t, d3Channel, newWAHandler(courier.ChannelType("D3"), "360Dialog"), defaultSendTestCases, []string{"token123"}, nil) + RunOutgoingTestCases(t, txwChannel, newWAHandler(courier.ChannelType("TXW"), "TextIt"), defaultSendTestCases, []string{"token123"}, nil) mediaServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { defer req.Body.Close() @@ -1228,17 +1205,17 @@ func TestSending(t *testing.T) { defer mediaServer.Close() mediaCacheSendTestCases := mockAttachmentURLs(mediaServer, mediaCacheSendTestCases) - RunChannelSendTestCases(t, defaultChannel, newWAHandler(courier.ChannelType("WA"), "WhatsApp"), mediaCacheSendTestCases, []string{"token123"}, nil) + RunOutgoingTestCases(t, defaultChannel, newWAHandler(courier.ChannelType("WA"), "WhatsApp"), mediaCacheSendTestCases, []string{"token123"}, nil) } func TestGetSupportedLanguage(t *testing.T) { - assert.Equal(t, "en", getSupportedLanguage(courier.NilLocale)) - assert.Equal(t, "en", getSupportedLanguage(courier.Locale("eng"))) - assert.Equal(t, "en_US", getSupportedLanguage(courier.Locale("eng-US"))) - assert.Equal(t, "pt_PT", getSupportedLanguage(courier.Locale("por"))) - assert.Equal(t, "pt_PT", getSupportedLanguage(courier.Locale("por-PT"))) - assert.Equal(t, "pt_BR", getSupportedLanguage(courier.Locale("por-BR"))) - assert.Equal(t, "fil", getSupportedLanguage(courier.Locale("fil"))) - assert.Equal(t, "fr", getSupportedLanguage(courier.Locale("fra-CA"))) - assert.Equal(t, "en", getSupportedLanguage(courier.Locale("run"))) + assert.Equal(t, "en", getSupportedLanguage(i18n.NilLocale)) + assert.Equal(t, "en", getSupportedLanguage(i18n.Locale("eng"))) + assert.Equal(t, "en_US", getSupportedLanguage(i18n.Locale("eng-US"))) + assert.Equal(t, "pt_PT", getSupportedLanguage(i18n.Locale("por"))) + assert.Equal(t, "pt_PT", getSupportedLanguage(i18n.Locale("por-PT"))) + assert.Equal(t, "pt_BR", getSupportedLanguage(i18n.Locale("por-BR"))) + assert.Equal(t, "fil", getSupportedLanguage(i18n.Locale("fil"))) + assert.Equal(t, "fr", getSupportedLanguage(i18n.Locale("fra-CA"))) + assert.Equal(t, "en", getSupportedLanguage(i18n.Locale("run"))) } diff --git a/handlers/yo/yo.go b/handlers/yo/handler.go similarity index 89% rename from handlers/yo/yo.go rename to handlers/yo/handler.go index 9ea9ab280..d225b08c0 100644 --- a/handlers/yo/yo.go +++ b/handlers/yo/handler.go @@ -93,11 +93,11 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w dbMsg := h.Backend().NewIncomingMsg(channel, urn, form.Message, "", clog).WithReceivedOn(date) // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{dbMsg}, w, r, clog) + return handlers.WriteMsgsAndResponse(ctx, h, []courier.MsgIn{dbMsg}, w, r, clog) } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") if username == "" { return nil, fmt.Errorf("no username set for YO channel") @@ -108,7 +108,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann return nil, fmt.Errorf("no password set for YO channel") } - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusErrored, clog) var err error for _, part := range handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) { @@ -130,7 +130,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -140,10 +140,10 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // check whether we were blacklisted createMessage := responseQS["ybs_autocreate_message"] if len(createMessage) > 0 && strings.Contains(createMessage[0], "BLACKLISTED") { - status.SetStatus(courier.MsgFailed) + status.SetStatus(courier.MsgStatusFailed) // create a stop channel event - channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.StopContact, msg.URN(), clog) + channelEvent := h.Backend().NewChannelEvent(msg.Channel(), courier.EventTypeStopContact, msg.URN(), clog) err = h.Backend().WriteChannelEvent(ctx, channelEvent, clog) if err != nil { return nil, err @@ -155,7 +155,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann // finally check that we were sent createStatus := responseQS["ybs_autocreate_status"] if len(createStatus) > 0 && createStatus[0] == "OK" { - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } } diff --git a/handlers/yo/yo_test.go b/handlers/yo/handler_test.go similarity index 90% rename from handlers/yo/yo_test.go rename to handlers/yo/handler_test.go index 87b4286d8..7e58b406b 100644 --- a/handlers/yo/yo_test.go +++ b/handlers/yo/handler_test.go @@ -22,10 +22,10 @@ var ( ) var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "YO", "2020", "US", map[string]interface{}{"username": "yo-username", "password": "yo-password"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "YO", "2020", "US", map[string]any{"username": "yo-username", "password": "yo-password"}), } -var handleTestCases = []ChannelHandleTestCase{ +var handleTestCases = []IncomingTestCase{ {Label: "Receive Valid Message", URL: receiveValidMessage, Data: "", ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedMsgText: Sp("Join"), ExpectedURN: "tel:+2349067554729"}, {Label: "Receive Valid From", URL: receiveValidMessageFrom, Data: "", ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", @@ -40,8 +40,8 @@ var handleTestCases = []ChannelHandleTestCase{ {Label: "Receive Invalid Date", URL: receiveInvalidDate, Data: "", ExpectedRespStatus: 400, ExpectedBodyContains: "invalid date format, must be RFC 3339"}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), handleTestCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testChannels, newHandler(), handleTestCases) } func BenchmarkHandler(b *testing.B) { @@ -49,11 +49,11 @@ func BenchmarkHandler(b *testing.B) { } // 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) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { sendURLs = []string{s.URL} } -var getSendTestCases = []ChannelSendTestCase{ +var getSendTestCases = []OutgoingTestCase{ {Label: "Plain Send", MsgText: "Simple Message", MsgURN: "tel:+250788383383", ExpectedMsgStatus: "W", @@ -98,8 +98,8 @@ var getSendTestCases = []ChannelSendTestCase{ SendPrep: setSendURL}, } -func TestSending(t *testing.T) { - var getChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "YO", "2020", "US", map[string]interface{}{"username": "yo-username", "password": "yo-password"}) +func TestOutgoing(t *testing.T) { + var getChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "YO", "2020", "US", map[string]any{"username": "yo-username", "password": "yo-password"}) - RunChannelSendTestCases(t, getChannel, newHandler(), getSendTestCases, []string{"yo-password"}, nil) + RunOutgoingTestCases(t, getChannel, newHandler(), getSendTestCases, []string{"yo-password"}, nil) } diff --git a/handlers/zenvia/zenvia.go b/handlers/zenvia/handlers.go similarity index 89% rename from handlers/zenvia/zenvia.go rename to handlers/zenvia/handlers.go index bfe67b7b2..173beb857 100644 --- a/handlers/zenvia/zenvia.go +++ b/handlers/zenvia/handlers.go @@ -3,7 +3,6 @@ package zenvia import ( "bytes" "context" - "encoding/json" "fmt" "net/http" "strings" @@ -12,6 +11,7 @@ import ( "github.com/buger/jsonparser" "github.com/nyaruka/courier" "github.com/nyaruka/courier/handlers" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" ) @@ -99,7 +99,7 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w contactName := payload.Visitor.Name - msgs := []courier.Msg{} + msgs := []courier.MsgIn{} for _, content := range payload.Message.Contents { @@ -129,12 +129,12 @@ func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w return handlers.WriteMsgsAndResponse(ctx, h, msgs, w, r, clog) } -var statusMapping = map[string]courier.MsgStatusValue{ - "REJECTED": courier.MsgFailed, - "NOT_DELIVERED": courier.MsgFailed, - "SENT": courier.MsgSent, - "DELIVERED": courier.MsgDelivered, - "READ": courier.MsgDelivered, +var statusMapping = map[string]courier.MsgStatus{ + "REJECTED": courier.MsgStatusFailed, + "NOT_DELIVERED": courier.MsgStatusFailed, + "SENT": courier.MsgStatusSent, + "DELIVERED": courier.MsgStatusDelivered, + "READ": courier.MsgStatusDelivered, } type statusPayload struct { @@ -155,11 +155,11 @@ func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w msgStatus, found := statusMapping[strings.ToUpper(payload.MessageStatus.Code)] if !found { - msgStatus = courier.MsgErrored + msgStatus = courier.MsgStatusErrored } // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, payload.MessageID, msgStatus, clog) + status := h.Backend().NewStatusUpdateByExternalID(channel, payload.MessageID, msgStatus, clog) return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) } @@ -179,7 +179,7 @@ type mtPayload struct { } // Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *handler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { channel := msg.Channel() token := channel.StringConfigForKey(courier.ConfigAPIKey, "") @@ -192,7 +192,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann To: strings.TrimLeft(msg.URN().Path(), "+"), } - status := h.Backend().NewMsgStatusForID(channel, msg.ID(), courier.MsgErrored, clog) + status := h.Backend().NewStatusUpdate(channel, msg.ID(), courier.MsgStatusErrored, clog) text := "" if channel.ChannelType() == "ZVW" { @@ -223,11 +223,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann }) } - jsonBody, err := json.Marshal(payload) - if err != nil { - return status, err - } - + jsonBody := jsonx.MustMarshal(payload) sendURL := whatsappSendURL if channel.ChannelType() == "ZVS" { sendURL = smsSendURL @@ -242,7 +238,7 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann req.Header.Set("Accept", "application/json") req.Header.Set("X-API-TOKEN", token) - resp, respBody, err := handlers.RequestHTTP(req, clog) + resp, respBody, err := h.RequestHTTP(req, clog) if err != nil || resp.StatusCode/100 != 2 { return status, nil } @@ -254,6 +250,6 @@ func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.Chann } status.SetExternalID(externalID) - status.SetStatus(courier.MsgWired) + status.SetStatus(courier.MsgStatusWired) return status, nil } diff --git a/handlers/zenvia/zenvia_test.go b/handlers/zenvia/handlers_test.go similarity index 85% rename from handlers/zenvia/zenvia_test.go rename to handlers/zenvia/handlers_test.go index d9eb3077e..0d1e15bce 100644 --- a/handlers/zenvia/zenvia_test.go +++ b/handlers/zenvia/handlers_test.go @@ -11,12 +11,12 @@ import ( ) var testWhatsappChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZVW", "2020", "BR", map[string]interface{}{"api_key": "zv-api-token"}), - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZVS", "2020", "BR", map[string]interface{}{"api_key": "zv-api-token"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZVW", "2020", "BR", map[string]any{"api_key": "zv-api-token"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZVS", "2020", "BR", map[string]any{"api_key": "zv-api-token"}), } var testSMSChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZVS", "2020", "BR", map[string]interface{}{"api_key": "zv-api-token"}), + test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZVS", "2020", "BR", map[string]any{"api_key": "zv-api-token"}), } var ( @@ -162,7 +162,7 @@ var missingFieldsReceive = `{ } }` -var testWhatappCases = []ChannelHandleTestCase{ +var testWhatappCases = []IncomingTestCase{ {Label: "Receive Valid", URL: receiveWhatsappURL, Data: validReceive, ExpectedRespStatus: 200, ExpectedBodyContains: "Message Accepted", ExpectedMsgText: Sp("Msg"), ExpectedURN: "whatsapp:254791541111", ExpectedDate: time.Date(2017, 5, 3, 03, 04, 45, 0, time.UTC)}, @@ -177,13 +177,27 @@ var testWhatappCases = []ChannelHandleTestCase{ {Label: "Missing field", URL: receiveWhatsappURL, Data: missingFieldsReceive, ExpectedRespStatus: 400, ExpectedBodyContains: "validation for 'ID' failed on the 'required'"}, {Label: "Bad Date", URL: receiveWhatsappURL, Data: invalidDateReceive, ExpectedRespStatus: 400, ExpectedBodyContains: "invalid date format"}, - {Label: "Valid Status", URL: statusWhatsppURL, Data: validStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `Accepted`, ExpectedMsgStatus: "S"}, - {Label: "Unkown Status", URL: statusWhatsppURL, Data: unknownStatus, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedMsgStatus: "E"}, + { + Label: "Valid Status", + URL: statusWhatsppURL, + Data: validStatus, + ExpectedRespStatus: 200, + ExpectedBodyContains: `Accepted`, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "hs765939216", Status: courier.MsgStatusSent}}, + }, + { + Label: "Unkown Status", + URL: statusWhatsppURL, + Data: unknownStatus, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Accepted", + ExpectedStatuses: []ExpectedStatus{{ExternalID: "hs765939216", Status: courier.MsgStatusErrored}}, + }, {Label: "Not JSON body", URL: statusWhatsppURL, Data: notJSON, ExpectedRespStatus: 400, ExpectedBodyContains: "unable to parse request JSON"}, {Label: "Wrong JSON schema", URL: statusWhatsppURL, Data: wrongJSONSchema, ExpectedRespStatus: 400, ExpectedBodyContains: "request JSON doesn't match required schema"}, } -var testSMSCases = []ChannelHandleTestCase{ +var testSMSCases = []IncomingTestCase{ {Label: "Receive Valid", URL: receiveSMSURL, Data: validReceive, ExpectedRespStatus: 200, ExpectedBodyContains: "Message Accepted", ExpectedMsgText: Sp("Msg"), ExpectedURN: "whatsapp:254791541111", ExpectedDate: time.Date(2017, 5, 3, 03, 04, 45, 0, time.UTC)}, @@ -198,15 +212,29 @@ var testSMSCases = []ChannelHandleTestCase{ {Label: "Missing field", URL: receiveSMSURL, Data: missingFieldsReceive, ExpectedRespStatus: 400, ExpectedBodyContains: "validation for 'ID' failed on the 'required'"}, {Label: "Bad Date", URL: receiveSMSURL, Data: invalidDateReceive, ExpectedRespStatus: 400, ExpectedBodyContains: "invalid date format"}, - {Label: "Valid Status", URL: statusSMSURL, Data: validStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `Accepted`, ExpectedMsgStatus: "S"}, - {Label: "Unkown Status", URL: statusSMSURL, Data: unknownStatus, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedMsgStatus: "E"}, + { + Label: "Valid Status", + URL: statusSMSURL, + Data: validStatus, + ExpectedRespStatus: 200, + ExpectedBodyContains: `Accepted`, + ExpectedStatuses: []ExpectedStatus{{ExternalID: "hs765939216", Status: courier.MsgStatusSent}}, + }, + { + Label: "Unknown Status", + URL: statusSMSURL, + Data: unknownStatus, + ExpectedRespStatus: 200, + ExpectedBodyContains: "Accepted", + ExpectedStatuses: []ExpectedStatus{{ExternalID: "hs765939216", Status: courier.MsgStatusErrored}}, + }, {Label: "Not JSON body", URL: statusSMSURL, Data: notJSON, ExpectedRespStatus: 400, ExpectedBodyContains: "unable to parse request JSON"}, {Label: "Wrong JSON schema", URL: statusSMSURL, Data: wrongJSONSchema, ExpectedRespStatus: 400, ExpectedBodyContains: "request JSON doesn't match required schema"}, } -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testWhatsappChannels, newHandler("ZVW", "Zenvia WhatsApp"), testWhatappCases) - RunChannelTestCases(t, testSMSChannels, newHandler("ZVS", "Zenvia SMS"), testSMSCases) +func TestIncoming(t *testing.T) { + RunIncomingTestCases(t, testWhatsappChannels, newHandler("ZVW", "Zenvia WhatsApp"), testWhatappCases) + RunIncomingTestCases(t, testSMSChannels, newHandler("ZVS", "Zenvia SMS"), testSMSCases) } func BenchmarkHandler(b *testing.B) { @@ -215,12 +243,12 @@ func BenchmarkHandler(b *testing.B) { } // setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { +func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.MsgOut) { whatsappSendURL = s.URL smsSendURL = s.URL } -var defaultWhatsappSendTestCases = []ChannelSendTestCase{ +var defaultWhatsappSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -298,7 +326,7 @@ var defaultWhatsappSendTestCases = []ChannelSendTestCase{ }, } -var defaultSMSSendTestCases = []ChannelSendTestCase{ +var defaultSMSSendTestCases = []OutgoingTestCase{ { Label: "Plain Send", MsgText: "Simple Message ☺", @@ -374,11 +402,11 @@ var defaultSMSSendTestCases = []ChannelSendTestCase{ }, } -func TestSending(t *testing.T) { +func TestOutgoing(t *testing.T) { maxMsgLength = 160 - var defaultWhatsappChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZVW", "2020", "BR", map[string]interface{}{"api_key": "zv-api-token"}) - RunChannelSendTestCases(t, defaultWhatsappChannel, newHandler("ZVW", "Zenvia WhatsApp"), defaultWhatsappSendTestCases, []string{"zv-api-token"}, nil) + var defaultWhatsappChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZVW", "2020", "BR", map[string]any{"api_key": "zv-api-token"}) + RunOutgoingTestCases(t, defaultWhatsappChannel, newHandler("ZVW", "Zenvia WhatsApp"), defaultWhatsappSendTestCases, []string{"zv-api-token"}, nil) - var defaultSMSChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZVS", "2020", "BR", map[string]interface{}{"api_key": "zv-api-token"}) - RunChannelSendTestCases(t, defaultSMSChannel, newHandler("ZVS", "Zenvia SMS"), defaultSMSSendTestCases, []string{"zv-api-token"}, nil) + var defaultSMSChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZVS", "2020", "BR", map[string]any{"api_key": "zv-api-token"}) + RunOutgoingTestCases(t, defaultSMSChannel, newHandler("ZVS", "Zenvia SMS"), defaultSMSSendTestCases, []string{"zv-api-token"}, nil) } diff --git a/handlers/zenviaold/zenviaold.go b/handlers/zenviaold/zenviaold.go deleted file mode 100644 index ef1de04b2..000000000 --- a/handlers/zenviaold/zenviaold.go +++ /dev/null @@ -1,207 +0,0 @@ -package zenvia_old - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "time" - - "github.com/buger/jsonparser" - "github.com/nyaruka/courier" - "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/gocommon/httpx" - "github.com/pkg/errors" -) - -var ( - maxMsgLength = 1152 - sendURL = "https://api-rest.zenvia.com/services/send-sms" -) - -func init() { - courier.RegisterHandler(newHandler()) -} - -type handler struct { - handlers.BaseHandler -} - -func newHandler() courier.ChannelHandler { - return &handler{handlers.NewBaseHandler(courier.ChannelType("ZV"), "Zenvia")} -} - -// 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.ChannelLogTypeMsgReceive, handlers.JSONPayload(h, h.receiveMessage)) - s.AddHandlerRoute(h, http.MethodPost, "status", courier.ChannelLogTypeMsgStatus, handlers.JSONPayload(h, h.receiveStatus)) - return nil -} - -// { -// "callbackMoRequest": { -// "id": "20690090", -// "mobile": "555191951711", -// "shortCode": "40001", -// "account": "zenvia.envio", -// "body": "Content of reply SMS", -// "received": "2014-08-26T12:27:08.488-03:00", -// "correlatedMessageSmsId": "hs765939061" -// } -// } -type moPayload struct { - CallbackMORequest struct { - ID string `json:"id" validate:"required" ` - From string `json:"mobile" validate:"required" ` - Text string `json:"body"` - Date string `json:"received" validate:"required" ` - ExternalID string `json:"correlatedMessageSmsId"` - } `json:"callbackMoRequest"` -} - -// { -// "callbackMtRequest": { -// "status": "03", -// "statusMessage": "Delivered", -// "statusDetail": "120", -// "statusDetailMessage": "Message received by mobile", -// "id": "hs765939216", -// "received": "2014-08-26T12:55:48.593-03:00", -// "mobileOperatorName": "Claro" -// } -// } -type statusPayload struct { - CallbackMTRequest struct { - StatusCode string `json:"status" validate:"required"` - ID string `json:"id" validate:"required" ` - } -} - -// { -// "sendSmsRequest": { -// "to": "555199999999", -// "schedule": "2014-08-22T14:55:00", -// "msg": "Test message.", -// "callbackOption": "NONE", -// "id": "002", -// "aggregateId": "1111" -// } -// } -type mtPayload struct { - SendSMSRequest struct { - To string `json:"to"` - Schedule string `json:"schedule"` - Msg string `json:"msg"` - CallbackOption string `json:"callbackOption"` - ID string `json:"id"` - AggregateID string `json:"aggregateId"` - } `json:"sendSmsRequest"` -} - -var statusMapping = map[string]courier.MsgStatusValue{ - "00": courier.MsgSent, - "01": courier.MsgSent, - "02": courier.MsgSent, - "03": courier.MsgDelivered, - "04": courier.MsgErrored, - "05": courier.MsgErrored, - "06": courier.MsgErrored, - "07": courier.MsgErrored, - "08": courier.MsgErrored, - "09": courier.MsgErrored, - "10": courier.MsgErrored, -} - -// receiveMessage is our HTTP handler function for incoming messages -func (h *handler) receiveMessage(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, payload *moPayload, clog *courier.ChannelLog) ([]courier.Event, error) { - // create our date from the timestamp - // 2017-05-03T06:04:45.345-03:00 - date, err := time.Parse("2006-01-02T15:04:05.000-07:00", payload.CallbackMORequest.Date) - if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, fmt.Errorf("invalid date format: %s", payload.CallbackMORequest.Date)) - } - - // create our URN - urn, err := handlers.StrictTelForCountry(payload.CallbackMORequest.From, channel.Country()) - if err != nil { - return nil, handlers.WriteAndLogRequestError(ctx, h, channel, w, r, err) - } - - // build our msg - msg := h.Backend().NewIncomingMsg(channel, urn, payload.CallbackMORequest.Text, payload.CallbackMORequest.ID, clog).WithReceivedOn(date.UTC()) - // and finally write our message - return handlers.WriteMsgsAndResponse(ctx, h, []courier.Msg{msg}, w, r, clog) -} - -// receiveStatus is our HTTP handler function for status updates -func (h *handler) receiveStatus(ctx context.Context, channel courier.Channel, w http.ResponseWriter, r *http.Request, payload *statusPayload, clog *courier.ChannelLog) ([]courier.Event, error) { - msgStatus, found := statusMapping[payload.CallbackMTRequest.StatusCode] - if !found { - msgStatus = courier.MsgErrored - } - - // write our status - status := h.Backend().NewMsgStatusForExternalID(channel, payload.CallbackMTRequest.ID, msgStatus, clog) - return handlers.WriteMsgStatusAndResponse(ctx, h, channel, status, w, r) - -} - -// Send sends the given message, logging any HTTP calls or errors -func (h *handler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { - username := msg.Channel().StringConfigForKey(courier.ConfigUsername, "") - if username == "" { - return nil, fmt.Errorf("no username set for ZV channel") - } - - password := msg.Channel().StringConfigForKey(courier.ConfigPassword, "") - if password == "" { - return nil, fmt.Errorf("no password set for ZV channel") - } - - status := h.Backend().NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgErrored, clog) - parts := handlers.SplitMsgByChannel(msg.Channel(), handlers.GetTextAndAttachments(msg), maxMsgLength) - for _, part := range parts { - zvMsg := mtPayload{} - zvMsg.SendSMSRequest.To = strings.TrimLeft(msg.URN().Path(), "+") - zvMsg.SendSMSRequest.Msg = part - zvMsg.SendSMSRequest.ID = msg.ID().String() - zvMsg.SendSMSRequest.CallbackOption = "FINAL" - - requestBody := new(bytes.Buffer) - json.NewEncoder(requestBody).Encode(zvMsg) - - // build our request - req, err := http.NewRequest(http.MethodPost, sendURL, requestBody) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.SetBasicAuth(username, password) - - resp, respBody, err := handlers.RequestHTTP(req, clog) - if err != nil || resp.StatusCode/100 != 2 { - return status, nil - } - - // was this request successful? - responseMsgStatus, _ := jsonparser.GetString(respBody, "sendSmsResponse", "statusCode") - msgStatus, found := statusMapping[responseMsgStatus] - if msgStatus == courier.MsgErrored || !found { - clog.RawError(errors.Errorf("received non-success response: '%s'", responseMsgStatus)) - return status, nil - } - - status.SetStatus(courier.MsgWired) - } - return status, nil -} - -func (h *handler) RedactValues(ch courier.Channel) []string { - return []string{ - httpx.BasicAuth(ch.StringConfigForKey(courier.ConfigUsername, ""), ch.StringConfigForKey(courier.ConfigPassword, "")), - } -} diff --git a/handlers/zenviaold/zenviaold_test.go b/handlers/zenviaold/zenviaold_test.go deleted file mode 100644 index 0cd62fed1..000000000 --- a/handlers/zenviaold/zenviaold_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package zenvia_old - -import ( - "net/http/httptest" - "testing" - "time" - - "github.com/nyaruka/courier" - . "github.com/nyaruka/courier/handlers" - "github.com/nyaruka/courier/test" - "github.com/nyaruka/gocommon/httpx" -) - -var testChannels = []courier.Channel{ - test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZV", "2020", "BR", map[string]interface{}{"username": "zv-username", "password": "zv-password"}), -} - -var ( - receiveURL = "/c/zv/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/receive/" - statusURL = "/c/zv/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status/" - - notJSON = "empty" -) - -var wrongJSONSchema = `{}` - -var validWithMoreFieldsStatus = `{ - "callbackMtRequest": { - "status": "03", - "statusMessage": "Delivered", - "statusDetail": "120", - "statusDetailMessage": "Message received by mobile", - "id": "hs765939216", - "received": "2014-08-26T12:55:48.593-03:00", - "mobileOperatorName": "Claro" - } -}` - -var validStatus = `{ - "callbackMtRequest": { - "status": "03", - "id": "hs765939216" - } -}` - -var unknownStatus = `{ - "callbackMtRequest": { - "status": "038", - "id": "hs765939216" - } -}` - -var missingFieldsStatus = `{ - "callbackMtRequest": { - "status": "", - "id": "hs765939216" - } -}` - -var validReceive = `{ - "callbackMoRequest": { - "id": "20690090", - "mobile": "254791541111", - "shortCode": "40001", - "account": "zenvia.envio", - "body": "Msg", - "received": "2017-05-03T03:04:45.123-03:00", - "correlatedMessageSmsId": "hs765939061" - } -}` - -var invalidURN = `{ - "callbackMoRequest": { - "id": "20690090", - "mobile": "MTN", - "shortCode": "40001", - "account": "zenvia.envio", - "body": "Msg", - "received": "2017-05-03T03:04:45.123-03:00", - "correlatedMessageSmsId": "hs765939061" - } -}` - -var invalidDateReceive = `{ - "callbackMoRequest": { - "id": "20690090", - "mobile": "254791541111", - "shortCode": "40001", - "account": "zenvia.envio", - "body": "Msg", - "received": "yesterday?", - "correlatedMessageSmsId": "hs765939061" - } -}` - -var missingFieldsReceive = `{ - "callbackMoRequest": { - "id": "", - "mobile": "254791541111", - "shortCode": "40001", - "account": "zenvia.envio", - "body": "Msg", - "received": "2017-05-03T03:04:45.123-03:00", - "correlatedMessageSmsId": "hs765939061" - } -}` - -var testCases = []ChannelHandleTestCase{ - {Label: "Receive Valid", URL: receiveURL, Data: validReceive, ExpectedRespStatus: 200, ExpectedBodyContains: "Message Accepted", - ExpectedMsgText: Sp("Msg"), ExpectedURN: "tel:+254791541111", ExpectedDate: time.Date(2017, 5, 3, 06, 04, 45, 123000000, time.UTC)}, - - {Label: "Invalid URN", URL: receiveURL, Data: invalidURN, ExpectedRespStatus: 400, ExpectedBodyContains: "phone number supplied is not a number"}, - {Label: "Not JSON body", URL: receiveURL, Data: notJSON, ExpectedRespStatus: 400, ExpectedBodyContains: "unable to parse request JSON"}, - {Label: "Wrong JSON schema", URL: receiveURL, Data: wrongJSONSchema, ExpectedRespStatus: 400, ExpectedBodyContains: "request JSON doesn't match required schema"}, - {Label: "Missing field", URL: receiveURL, Data: missingFieldsReceive, ExpectedRespStatus: 400, ExpectedBodyContains: "validation for 'ID' failed on the 'required'"}, - {Label: "Bad Date", URL: receiveURL, Data: invalidDateReceive, ExpectedRespStatus: 400, ExpectedBodyContains: "invalid date format"}, - - {Label: "Valid Status", URL: statusURL, Data: validStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `Accepted`, ExpectedMsgStatus: "D"}, - {Label: "Valid Status with more fields", URL: statusURL, Data: validWithMoreFieldsStatus, ExpectedRespStatus: 200, ExpectedBodyContains: `Accepted`, ExpectedMsgStatus: "D"}, - {Label: "Unkown Status", URL: statusURL, Data: unknownStatus, ExpectedRespStatus: 200, ExpectedBodyContains: "Accepted", ExpectedMsgStatus: "E"}, - {Label: "Not JSON body", URL: statusURL, Data: notJSON, ExpectedRespStatus: 400, ExpectedBodyContains: "unable to parse request JSON"}, - {Label: "Wrong JSON schema", URL: statusURL, Data: wrongJSONSchema, ExpectedRespStatus: 400, ExpectedBodyContains: "request JSON doesn't match required schema"}, - {Label: "Missing field", URL: statusURL, Data: missingFieldsStatus, ExpectedRespStatus: 400, ExpectedBodyContains: "validation for 'StatusCode' failed on the 'required'"}, -} - -func TestHandler(t *testing.T) { - RunChannelTestCases(t, testChannels, newHandler(), testCases) -} - -func BenchmarkHandler(b *testing.B) { - RunChannelBenchmarks(b, testChannels, newHandler(), testCases) -} - -// setSendURL takes care of setting the sendURL to call -func setSendURL(s *httptest.Server, h courier.ChannelHandler, c courier.Channel, m courier.Msg) { - sendURL = s.URL -} - -var defaultSendTestCases = []ChannelSendTestCase{ - { - Label: "Plain Send", - MsgText: "Simple Message ☺", - MsgURN: "tel:+250788383383", - MockResponseBody: `{"sendSmsResponse":{"statusCode":"00","statusDescription":"Ok","detailCode":"000","detailDescription":"Message Sent"}}`, - MockResponseStatus: 200, - ExpectedHeaders: map[string]string{ - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": "Basic enYtdXNlcm5hbWU6enYtcGFzc3dvcmQ=", - }, - ExpectedRequestBody: `{"sendSmsRequest":{"to":"250788383383","schedule":"","msg":"Simple Message ☺","callbackOption":"FINAL","id":"10","aggregateId":""}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "", - SendPrep: setSendURL, - }, - { - Label: "Long Send", - MsgText: "This is a longer message than 160 characters and will cause us to split it into two separate parts, isn't that right but it is even longer than before I say, I need to keep adding more things to make it work", - MsgURN: "tel:+250788383383", - ExpectedMsgStatus: "W", - ExpectedExternalID: "", - MockResponseBody: `{"sendSmsResponse":{"statusCode":"00","statusDescription":"Ok","detailCode":"000","detailDescription":"Message Sent"}}`, - MockResponseStatus: 200, - ExpectedHeaders: map[string]string{ - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": "Basic enYtdXNlcm5hbWU6enYtcGFzc3dvcmQ=", - }, - ExpectedRequestBody: `{"sendSmsRequest":{"to":"250788383383","schedule":"","msg":"I need to keep adding more things to make it work","callbackOption":"FINAL","id":"10","aggregateId":""}}`, - SendPrep: setSendURL, - }, - { - Label: "Send Attachment", - MsgText: "My pic!", - MsgURN: "tel:+250788383383", - MsgAttachments: []string{"image/jpeg:https://foo.bar/image.jpg"}, - MockResponseBody: `{"sendSmsResponse":{"statusCode":"00","statusDescription":"Ok","detailCode":"000","detailDescription":"Message Sent"}}`, - MockResponseStatus: 200, - ExpectedHeaders: map[string]string{ - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": "Basic enYtdXNlcm5hbWU6enYtcGFzc3dvcmQ=", - }, - ExpectedRequestBody: `{"sendSmsRequest":{"to":"250788383383","schedule":"","msg":"My pic!\nhttps://foo.bar/image.jpg","callbackOption":"FINAL","id":"10","aggregateId":""}}`, - ExpectedMsgStatus: "W", - ExpectedExternalID: "", - SendPrep: setSendURL, - }, - { - Label: "No External ID", - MsgText: "No External ID", - MsgURN: "tel:+250788383383", - MockResponseBody: `{"sendSmsResponse" :{"statusCode" :"05","statusDescription" :"Blocked","detailCode":"140","detailDescription":"Mobile number not covered"}}`, - MockResponseStatus: 200, - ExpectedHeaders: map[string]string{ - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": "Basic enYtdXNlcm5hbWU6enYtcGFzc3dvcmQ=", - }, - ExpectedRequestBody: `{"sendSmsRequest":{"to":"250788383383","schedule":"","msg":"No External ID","callbackOption":"FINAL","id":"10","aggregateId":""}}`, - ExpectedMsgStatus: "E", - ExpectedErrors: []*courier.ChannelError{courier.NewChannelError("", "", "received non-success response: '05'")}, - SendPrep: setSendURL}, - { - Label: "Error Sending", - MsgText: "Error Message", - MsgURN: "tel:+250788383383", - MockResponseBody: `{ "error": "failed" }`, - MockResponseStatus: 401, - ExpectedRequestBody: `{"sendSmsRequest":{"to":"250788383383","schedule":"","msg":"Error Message","callbackOption":"FINAL","id":"10","aggregateId":""}}`, - ExpectedMsgStatus: "E", - SendPrep: setSendURL, - }, -} - -func TestSending(t *testing.T) { - maxMsgLength = 160 - var defaultChannel = test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "ZV", "2020", "BR", map[string]interface{}{"username": "zv-username", "password": "zv-password"}) - - RunChannelSendTestCases(t, defaultChannel, newHandler(), defaultSendTestCases, []string{httpx.BasicAuth("zv-username", "zv-password")}, nil) -} diff --git a/log.go b/log.go index 773ec78fe..fc9dfdac6 100644 --- a/log.go +++ b/log.go @@ -1,83 +1,90 @@ package courier import ( + "log/slog" "net/http" "time" - - "github.com/sirupsen/logrus" ) // LogMsgStatusReceived logs our that we received a new MsgStatus -func LogMsgStatusReceived(r *http.Request, status MsgStatus) { - log := logrus.WithFields(logrus.Fields{ - "channel_uuid": status.ChannelUUID(), - "url": r.Context().Value(contextRequestURL), - "elapsed_ms": getElapsedMS(r), - "status": status.Status(), - }) - - if status.ID() != NilMsgID { - log = log.WithField("msg_id", status.ID()) - } else { - log = log.WithField("msg_external_id", status.ExternalID()) +func LogMsgStatusReceived(r *http.Request, status StatusUpdate) { + if slog.Default().Enabled(r.Context(), slog.LevelDebug) { + slog.Debug("status updated", + "channel_uuid", status.ChannelUUID(), + "url", r.Context().Value(contextRequestURL), + "elapsed_ms", getElapsedMS(r), + "status", status.Status(), + "msg_id", status.MsgID(), + "msg_external_id", status.ExternalID(), + ) } - log.Info("status updated") + } // LogMsgReceived logs that we received the passed in message -func LogMsgReceived(r *http.Request, msg Msg) { - logrus.WithFields(logrus.Fields{ - "channel_uuid": msg.Channel().UUID(), - "url": r.Context().Value(contextRequestURL), - "elapsed_ms": getElapsedMS(r), - "msg_uuid": msg.UUID(), - "msg_id": msg.ID(), - "msg_urn": msg.URN().Identity(), - "msg_text": msg.Text(), - "msg_attachments": msg.Attachments(), - }).Info("msg received") +func LogMsgReceived(r *http.Request, msg MsgIn) { + if slog.Default().Enabled(r.Context(), slog.LevelDebug) { + slog.Debug("msg received", + "channel_uuid", msg.Channel().UUID(), + "url", r.Context().Value(contextRequestURL), + "elapsed_ms", getElapsedMS(r), + "msg_uuid", msg.UUID(), + "msg_id", msg.ID(), + "msg_urn", msg.URN().Identity(), + "msg_text", msg.Text(), + "msg_attachments", msg.Attachments(), + ) + } + } // LogChannelEventReceived logs that we received the passed in channel event func LogChannelEventReceived(r *http.Request, event ChannelEvent) { - logrus.WithFields(logrus.Fields{ - "channel_uuid": event.ChannelUUID(), - "url": r.Context().Value(contextRequestURL), - "elapsed_ms": getElapsedMS(r), - "event_type": event.EventType(), - "event_urn": event.URN().Identity(), - }).Info("evt received") + if slog.Default().Enabled(r.Context(), slog.LevelDebug) { + slog.Debug("event received", + "channel_uuid", event.ChannelUUID(), + "url", r.Context().Value(contextRequestURL), + "elapsed_ms", getElapsedMS(r), + "event_type", event.EventType(), + "event_urn", event.URN().Identity(), + ) + } } // LogRequestIgnored logs that we ignored the passed in request func LogRequestIgnored(r *http.Request, channel Channel, details string) { - logrus.WithFields(logrus.Fields{ - "channel_uuid": channel.UUID(), - "url": r.Context().Value(contextRequestURL), - "elapsed_ms": getElapsedMS(r), - "details": details, - }).Info("request ignored") + if slog.Default().Enabled(r.Context(), slog.LevelDebug) { + slog.Debug("request ignored", + "channel_uuid", channel.UUID(), + "url", r.Context().Value(contextRequestURL), + "elapsed_ms", getElapsedMS(r), + "details", details, + ) + } } // LogRequestHandled logs that we handled the passed in request but didn't create any events func LogRequestHandled(r *http.Request, channel Channel, details string) { - logrus.WithFields(logrus.Fields{ - "channel_uuid": channel.UUID(), - "url": r.Context().Value(contextRequestURL), - "elapsed_ms": getElapsedMS(r), - "details": details, - }).Info("request handled") + if slog.Default().Enabled(r.Context(), slog.LevelDebug) { + slog.Debug("request handled", + "channel_uuid", channel.UUID(), + "url", r.Context().Value(contextRequestURL), + "elapsed_ms", getElapsedMS(r), + "details", details, + ) + } } // LogRequestError logs that errored during parsing (this is logged as an info as it isn't an error on our side) func LogRequestError(r *http.Request, channel Channel, err error) { - log := logrus.WithFields(logrus.Fields{ - "url": r.Context().Value(contextRequestURL), - "elapsed_ms": getElapsedMS(r), - "error": err.Error(), - }) + log := slog.With( + "url", r.Context().Value(contextRequestURL), + "elapsed_ms", getElapsedMS(r), + "error", err, + ) + if channel != nil { - log = log.WithField("channel_uuid", channel.UUID()) + log = log.With("channel_uuid", channel.UUID()) } log.Info("request errored") } diff --git a/msg.go b/msg.go index 7b81d6b44..c7f36ae7a 100644 --- a/msg.go +++ b/msg.go @@ -3,22 +3,15 @@ package courier import ( "database/sql/driver" "encoding/json" - "errors" "strconv" - "strings" "time" + "github.com/nyaruka/gocommon/i18n" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/gocommon/uuids" - "github.com/nyaruka/null/v2" + "github.com/nyaruka/null/v3" ) -// ErrMsgNotFound is returned when trying to queue the status for a Msg that doesn't exit -var ErrMsgNotFound = errors.New("message not found") - -// ErrWrongIncomingMsgStatus use do ignore the status update if the DB raise this -var ErrWrongIncomingMsgStatus = errors.New("incoming messages can only be PENDING or HANDLED") - // MsgID is our typing of the db int type type MsgID null.Int64 @@ -44,6 +37,11 @@ type FlowReference struct { Name string `json:"name"` } +type OptInReference struct { + ID int64 `json:"id" validate:"required"` + Name string `json:"name" validate:"required"` +} + type MsgOrigin string const ( @@ -53,75 +51,52 @@ const ( MsgOriginChat MsgOrigin = "chat" ) -//----------------------------------------------------------------------------- -// Locale -//----------------------------------------------------------------------------- - -// Locale is the combination of a language and optional country, e.g. US English, Brazilian Portuguese, encoded as the -// language code followed by the country code, e.g. eng-US, por-BR -type Locale string - -func (l Locale) ToParts() (string, string) { - if l == NilLocale || len(l) < 3 { - return "", "" - } - - parts := strings.SplitN(string(l), "-", 2) - lang := parts[0] - country := "" - if len(parts) > 1 { - country = parts[1] - } - - return lang, country -} - -var NilLocale = Locale("") - //----------------------------------------------------------------------------- // Msg interface //----------------------------------------------------------------------------- -// Msg is our interface to represent an incoming or outgoing message +// Msg is our interface for common methods for an incoming or outgoing message type Msg interface { + Event + ID() MsgID UUID() MsgUUID + ExternalID() string Text() string Attachments() []string - Locale() Locale - ExternalID() string URN() urns.URN - URNAuth() string - ContactName() string + Channel() Channel +} + +// MsgOut is our interface to represent an outgoing +type MsgOut interface { + Msg + + // outgoing specific QuickReplies() []string + Locale() i18n.Locale + URNAuth() string Origin() MsgOrigin ContactLastSeenOn() *time.Time Topic() string Metadata() json.RawMessage ResponseToExternalID() string + SentOn() *time.Time IsResend() bool - Flow() *FlowReference - FlowName() string - FlowUUID() string + OptIn() *OptInReference + SessionStatus() string + HighPriority() bool +} - Channel() Channel +// MsgIn is our interface to represent an incoming +type MsgIn interface { + Msg + // incoming specific ReceivedOn() *time.Time - SentOn() *time.Time - - HighPriority() bool - - WithContactName(name string) Msg - WithReceivedOn(date time.Time) Msg - WithID(id MsgID) Msg - WithUUID(uuid MsgUUID) Msg - WithAttachment(url string) Msg - WithLocale(Locale) Msg - WithURNAuth(auth string) Msg - WithMetadata(metadata json.RawMessage) Msg - WithFlow(flow *FlowReference) Msg - - EventID() int64 - SessionStatus() string + WithAttachment(url string) MsgIn + WithContactName(name string) MsgIn + WithURNAuthTokens(tokens map[string]string) MsgIn + WithReceivedOn(date time.Time) MsgIn } diff --git a/queue/queue.go b/queue/queue.go index 2dd5b3123..f261463ad 100644 --- a/queue/queue.go +++ b/queue/queue.go @@ -1,12 +1,12 @@ package queue import ( + "log/slog" "strconv" "sync" "time" "github.com/gomodule/redigo/redis" - "github.com/sirupsen/logrus" ) // Priority represents the priority of an item in a queue @@ -201,7 +201,7 @@ func PopFromQueue(conn redis.Conn, qType string) (WorkerToken, string, error) { epochMS := strconv.FormatFloat(float64(time.Now().UnixNano()/int64(time.Microsecond))/float64(1000000), 'f', 6, 64) values, err := redis.Strings(luaPop.Do(conn, epochMS, qType)) if err != nil { - logrus.Error(err) + slog.Error("error popping from queue", "error", err) return "", "", err } return WorkerToken(values[0]), values[1], nil @@ -275,7 +275,7 @@ func StartDethrottler(redis *redis.Pool, quitter chan bool, wg *sync.WaitGroup, conn := redis.Get() _, err := luaDethrottle.Do(conn, qType) if err != nil { - logrus.WithError(err).Error("error dethrottling") + slog.Error("error dethrottling", "error", err) } conn.Close() diff --git a/responses.go b/responses.go index 94b8d6900..665d1e097 100644 --- a/responses.go +++ b/responses.go @@ -20,7 +20,7 @@ func writeAndLogRequestError(ctx context.Context, h ChannelHandler, w http.Respo // WriteError writes a JSON response for the passed in error func WriteError(w http.ResponseWriter, statusCode int, err error) error { - errors := []interface{}{NewErrorData(err.Error())} + errors := []any{NewErrorData(err.Error())} vErrs, isValidation := err.(validator.ValidationErrors) if isValidation { @@ -33,23 +33,23 @@ func WriteError(w http.ResponseWriter, statusCode int, err error) error { // WriteIgnored writes a JSON response indicating that we ignored the request func WriteIgnored(w http.ResponseWriter, details string) error { - return WriteDataResponse(w, http.StatusOK, "Ignored", []interface{}{NewInfoData(details)}) + return WriteDataResponse(w, http.StatusOK, "Ignored", []any{NewInfoData(details)}) } // WriteAndLogUnauthorized writes a JSON response for the passed in message and logs an info message func WriteAndLogUnauthorized(w http.ResponseWriter, r *http.Request, c Channel, err error) error { LogRequestError(r, c, err) - return WriteDataResponse(w, http.StatusUnauthorized, "Unauthorized", []interface{}{NewErrorData(err.Error())}) + return WriteDataResponse(w, http.StatusUnauthorized, "Unauthorized", []any{NewErrorData(err.Error())}) } // WriteChannelEventSuccess writes a JSON response for the passed in event indicating we handled it func WriteChannelEventSuccess(w http.ResponseWriter, event ChannelEvent) error { - return WriteDataResponse(w, http.StatusOK, "Event Accepted", []interface{}{NewEventReceiveData(event)}) + return WriteDataResponse(w, http.StatusOK, "Event Accepted", []any{NewEventReceiveData(event)}) } // WriteMsgSuccess writes a JSON response for the passed in msg indicating we handled it -func WriteMsgSuccess(w http.ResponseWriter, msgs []Msg) error { - data := []interface{}{} +func WriteMsgSuccess(w http.ResponseWriter, msgs []MsgIn) error { + data := []any{} for _, msg := range msgs { data = append(data, NewMsgReceiveData(msg)) } @@ -58,8 +58,8 @@ func WriteMsgSuccess(w http.ResponseWriter, msgs []Msg) error { } // WriteStatusSuccess writes a JSON response for the passed in status update indicating we handled it -func WriteStatusSuccess(w http.ResponseWriter, statuses []MsgStatus) error { - data := []interface{}{} +func WriteStatusSuccess(w http.ResponseWriter, statuses []StatusUpdate) error { + data := []any{} for _, status := range statuses { data = append(data, NewStatusData(status)) } @@ -68,7 +68,7 @@ func WriteStatusSuccess(w http.ResponseWriter, statuses []MsgStatus) error { } // WriteDataResponse writes a JSON formatted response with the passed in status code, message and data -func WriteDataResponse(w http.ResponseWriter, statusCode int, message string, data []interface{}) error { +func WriteDataResponse(w http.ResponseWriter, statusCode int, message string, data []any) error { return writeJSONResponse(w, statusCode, &dataResponse{message, data}) } @@ -85,7 +85,7 @@ type MsgReceiveData struct { } // NewMsgReceiveData creates a new data response for the passed in msg parameters -func NewMsgReceiveData(msg Msg) MsgReceiveData { +func NewMsgReceiveData(msg MsgIn) MsgReceiveData { return MsgReceiveData{ "msg", msg.Channel().UUID(), @@ -100,12 +100,12 @@ func NewMsgReceiveData(msg Msg) MsgReceiveData { // EventReceiveData is our response payload for a channel event type EventReceiveData struct { - Type string `json:"type"` - ChannelUUID ChannelUUID `json:"channel_uuid"` - EventType ChannelEventType `json:"event_type"` - URN urns.URN `json:"urn"` - ReceivedOn time.Time `json:"received_on"` - Extra map[string]interface{} `json:"extra,omitempty"` + Type string `json:"type"` + ChannelUUID ChannelUUID `json:"channel_uuid"` + EventType ChannelEventType `json:"event_type"` + URN urns.URN `json:"urn"` + ReceivedOn time.Time `json:"received_on"` + Extra map[string]string `json:"extra,omitempty"` } // NewEventReceiveData creates a new receive data for the passed in event @@ -122,20 +122,20 @@ func NewEventReceiveData(event ChannelEvent) EventReceiveData { // StatusData is our response payload for a status update type StatusData struct { - Type string `json:"type"` - ChannelUUID ChannelUUID `json:"channel_uuid"` - Status MsgStatusValue `json:"status"` - MsgID MsgID `json:"msg_id,omitempty"` - ExternalID string `json:"external_id,omitempty"` + Type string `json:"type"` + ChannelUUID ChannelUUID `json:"channel_uuid"` + Status MsgStatus `json:"status"` + MsgID MsgID `json:"msg_id,omitempty"` + ExternalID string `json:"external_id,omitempty"` } // NewStatusData creates a new status data object for the passed in status -func NewStatusData(status MsgStatus) StatusData { +func NewStatusData(status StatusUpdate) StatusData { return StatusData{ "status", status.ChannelUUID(), status.Status(), - status.ID(), + status.MsgID(), status.ExternalID(), } } @@ -163,11 +163,11 @@ func NewInfoData(info string) InfoData { } type dataResponse struct { - Message string `json:"message"` - Data []interface{} `json:"data"` + Message string `json:"message"` + Data []any `json:"data"` } -func writeJSONResponse(w http.ResponseWriter, statusCode int, response interface{}) error { +func writeJSONResponse(w http.ResponseWriter, statusCode int, response any) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) return json.NewEncoder(w).Encode(response) diff --git a/responses_test.go b/responses_test.go index 2dbe4b9f6..6a017b0bf 100644 --- a/responses_test.go +++ b/responses_test.go @@ -43,10 +43,10 @@ func TestWriteAndLogUnauthorized(t *testing.T) { func TestWriteMsgSuccess(t *testing.T) { ch := test.NewMockChannel("5fccf4b6-48d7-4f5a-bce8-b0d1fd5342ec", "NX", "+1234567890", "US", nil) - msg := test.NewMockBackend().NewIncomingMsg(ch, "tel:+0987654321", "hi there", "", nil).WithUUID("588aafc4-ab5c-48ce-89e8-05c9fdeeafb7") + msg := test.NewMockBackend().NewIncomingMsg(ch, "tel:+0987654321", "hi there", "", nil).(*test.MockMsg).WithUUID("588aafc4-ab5c-48ce-89e8-05c9fdeeafb7") w := httptest.NewRecorder() - err := courier.WriteMsgSuccess(w, []courier.Msg{msg}) + err := courier.WriteMsgSuccess(w, []courier.MsgIn{msg.(courier.MsgIn)}) assert.NoError(t, err) assert.Equal(t, 200, w.Code) assert.Equal(t, "{\"message\":\"Message Accepted\",\"data\":[{\"type\":\"msg\",\"channel_uuid\":\"5fccf4b6-48d7-4f5a-bce8-b0d1fd5342ec\",\"msg_uuid\":\"588aafc4-ab5c-48ce-89e8-05c9fdeeafb7\",\"text\":\"hi there\",\"urn\":\"tel:+0987654321\"}]}\n", w.Body.String()) @@ -54,7 +54,7 @@ func TestWriteMsgSuccess(t *testing.T) { func TestWriteChannelEventSuccess(t *testing.T) { ch := test.NewMockChannel("5fccf4b6-48d7-4f5a-bce8-b0d1fd5342ec", "NX", "+1234567890", "US", nil) - evt := test.NewMockBackend().NewChannelEvent(ch, courier.StopContact, "tel:+0987654321", nil).WithOccurredOn(time.Date(2022, 9, 15, 12, 7, 30, 0, time.UTC)) + evt := test.NewMockBackend().NewChannelEvent(ch, courier.EventTypeStopContact, "tel:+0987654321", nil).WithOccurredOn(time.Date(2022, 9, 15, 12, 7, 30, 0, time.UTC)) w := httptest.NewRecorder() err := courier.WriteChannelEventSuccess(w, evt) diff --git a/sender.go b/sender.go index 715243ae7..771cef7b0 100644 --- a/sender.go +++ b/sender.go @@ -3,10 +3,10 @@ package courier import ( "context" "fmt" + "log/slog" "time" "github.com/nyaruka/gocommon/analytics" - "github.com/sirupsen/logrus" ) // Foreman takes care of managing our set of sending workers and assigns msgs for each to send @@ -47,7 +47,7 @@ func (f *Foreman) Stop() { sender.Stop() } close(f.quit) - logrus.WithField("comp", "foreman").WithField("state", "stopping").Info("foreman stopping") + slog.Info("foreman stopping", "comp", "foreman", "state", "stopping") } // Assign is our main loop for the Foreman, it takes care of popping the next outgoing messages from our @@ -55,12 +55,11 @@ func (f *Foreman) Stop() { func (f *Foreman) Assign() { f.server.WaitGroup().Add(1) defer f.server.WaitGroup().Done() - log := logrus.WithField("comp", "foreman") + log := slog.With("comp", "foreman") - log.WithFields(logrus.Fields{ - "state": "started", - "senders": len(f.senders), - }).Info("senders started and waiting") + log.Info("senders started and waiting", + "state", "started", + "senders", len(f.senders)) backend := f.server.Backend() lastSleep := false @@ -69,7 +68,7 @@ func (f *Foreman) Assign() { select { // return if we have been told to stop case <-f.quit: - log.WithField("state", "stopped").Info("foreman stopped") + log.Info("foreman stopped", "state", "stopped") return // otherwise, grab the next msg and assign it to a sender @@ -86,7 +85,7 @@ func (f *Foreman) Assign() { } else { // we received an error getting the next message, log it if err != nil { - log.WithError(err).Error("error popping outgoing msg") + log.Error("error popping outgoing msg", "error", err) } // add our sender back to our queue and sleep a bit @@ -105,7 +104,7 @@ func (f *Foreman) Assign() { type Sender struct { id int foreman *Foreman - job chan Msg + job chan MsgOut } // NewSender creates a new sender responsible for sending messages @@ -113,7 +112,7 @@ func NewSender(foreman *Foreman, id int) *Sender { sender := &Sender{ id: id, foreman: foreman, - job: make(chan Msg, 1), + job: make(chan MsgOut, 1), } return sender } @@ -124,10 +123,7 @@ func (w *Sender) Start() { go func() { defer w.foreman.server.WaitGroup().Done() - - log := logrus.WithField("comp", "sender").WithField("sender_id", w.id) - log.Debug("started") - + slog.Debug("started", "comp", "sender", "sender_id", w.id) for { // list ourselves as available for work w.foreman.availableSenders <- w @@ -137,7 +133,7 @@ func (w *Sender) Start() { // exit if we were stopped if msg == nil { - log.Debug("stopped") + slog.Debug("stopped") return } @@ -151,8 +147,9 @@ func (w *Sender) Stop() { close(w.job) } -func (w *Sender) sendMessage(msg Msg) { - log := logrus.WithField("comp", "sender").WithField("sender_id", w.id).WithField("channel_uuid", msg.Channel().UUID()) +func (w *Sender) sendMessage(msg MsgOut) { + + log := slog.With("comp", "sender", "sender_id", w.id, "channel_uuid", msg.Channel().UUID()) server := w.foreman.server backend := server.Backend() @@ -161,12 +158,12 @@ func (w *Sender) sendMessage(msg Msg) { sendCTX, cancel := context.WithTimeout(context.Background(), time.Second*35) defer cancel() - log = log.WithField("msg_id", msg.ID()).WithField("msg_text", msg.Text()).WithField("msg_urn", msg.URN().Identity()) + log = log.With("msg_id", msg.ID(), "msg_text", msg.Text(), "msg_urn", msg.URN().Identity()) if len(msg.Attachments()) > 0 { - log = log.WithField("attachments", msg.Attachments()) + log = log.With("attachments", msg.Attachments()) } if len(msg.QuickReplies()) > 0 { - log = log.WithField("quick_replies", msg.QuickReplies()) + log = log.With("quick_replies", msg.QuickReplies()) } start := time.Now() @@ -175,7 +172,7 @@ func (w *Sender) sendMessage(msg Msg) { if msg.IsResend() { err := backend.ClearMsgSent(sendCTX, msg.ID()) if err != nil { - log.WithError(err).Error("error clearing sent status for msg") + log.Error("error clearing sent status for msg", "error", err) } } @@ -184,10 +181,10 @@ func (w *Sender) sendMessage(msg Msg) { // failing on a lookup isn't a halting problem but we should log it if err != nil { - log.WithError(err).Error("error looking up msg was sent") + log.Error("error looking up msg was sent", "error", err) } - var status MsgStatus + var status StatusUpdate var redactValues []string handler := server.GetHandler(msg.Channel()) if handler != nil { @@ -198,13 +195,13 @@ func (w *Sender) sendMessage(msg Msg) { if handler == nil { // if there's no handler, create a FAILED status for it - status = backend.NewMsgStatusForID(msg.Channel(), msg.ID(), MsgFailed, clog) - log.Errorf("unable to find handler for channel type: %s", msg.Channel().ChannelType()) + status = backend.NewStatusUpdate(msg.Channel(), msg.ID(), MsgStatusFailed, clog) + log.Error(fmt.Sprintf("unable to find handler for channel type: %s", msg.Channel().ChannelType())) } else if sent { // if this message was already sent, create a WIRED status for it - status = backend.NewMsgStatusForID(msg.Channel(), msg.ID(), MsgWired, clog) - log.Warning("duplicate send, marking as wired") + status = backend.NewStatusUpdate(msg.Channel(), msg.ID(), MsgStatusWired, clog) + log.Warn("duplicate send, marking as wired") } else { // send our message @@ -213,7 +210,7 @@ func (w *Sender) sendMessage(msg Msg) { secondDuration := float64(duration) / float64(time.Second) if err != nil { - log.WithError(err).WithField("elapsed", duration).Error("error sending message") + log.Error("error sending message", "error", err, "elapsed", duration) // handlers should log errors implicitly with user friendly messages.. but if not.. add what we have if len(clog.Errors()) == 0 { @@ -222,16 +219,16 @@ func (w *Sender) sendMessage(msg Msg) { // possible for handlers to only return an error in which case we construct an error status if status == nil { - status = backend.NewMsgStatusForID(msg.Channel(), msg.ID(), MsgErrored, clog) + status = backend.NewStatusUpdate(msg.Channel(), msg.ID(), MsgStatusErrored, clog) } } // report to librato and log locally - if status.Status() == MsgErrored || status.Status() == MsgFailed { - log.WithField("elapsed", duration).Warning("msg errored") + if status.Status() == MsgStatusErrored || status.Status() == MsgStatusFailed { + log.Warn("msg errored", "elapsed", duration) analytics.Gauge(fmt.Sprintf("courier.msg_send_error_%s", msg.Channel().ChannelType()), secondDuration) } else { - log.WithField("elapsed", duration).Info("msg sent") + log.Debug("msg sent", "elapsed", duration) analytics.Gauge(fmt.Sprintf("courier.msg_send_%s", msg.Channel().ChannelType()), secondDuration) } } @@ -240,9 +237,9 @@ func (w *Sender) sendMessage(msg Msg) { writeCTX, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() - err = backend.WriteMsgStatus(writeCTX, status) + err = backend.WriteStatusUpdate(writeCTX, status) if err != nil { - log.WithError(err).Info("error writing msg status") + log.Info("error writing msg status", "error", err) } clog.End() @@ -250,7 +247,7 @@ func (w *Sender) sendMessage(msg Msg) { // write our logs as well err = backend.WriteChannelLog(writeCTX, clog) if err != nil { - log.WithError(err).Info("error writing msg logs") + log.Info("error writing msg logs", "error", err) } // mark our send task as complete diff --git a/server.go b/server.go index 5d5e5cdb2..f080a1c73 100644 --- a/server.go +++ b/server.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "log" + "log/slog" "net/http" "os" "runtime/debug" @@ -21,7 +22,6 @@ import ( "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/jsonx" "github.com/pkg/errors" - "github.com/sirupsen/logrus" ) // for use in request.Context @@ -56,13 +56,13 @@ type Server interface { // afterwards, which is when configuration options are checked. func NewServer(config *Config, backend Backend) Server { // create our top level router - logger := logrus.New() + logger := slog.Default() return NewServerWithLogger(config, backend, logger) } // NewServerWithLogger creates a new Server for the passed in configuration. The server will have to be started // afterwards, which is when configuration options are checked. -func NewServerWithLogger(config *Config, backend Backend, logger *logrus.Logger) Server { +func NewServerWithLogger(config *Config, backend Backend, logger *slog.Logger) Server { router := chi.NewRouter() router.Use(middleware.Compress(flate.DefaultCompression)) router.Use(middleware.StripSlashes) @@ -91,9 +91,6 @@ func NewServerWithLogger(config *Config, backend Backend, logger *logrus.Logger) // if it encounters any unrecoverable (or ignorable) error, though its bias is to move forward despite // connection errors func (s *server) Start() error { - // set our user agent, needs to happen before we do anything so we don't change have threading issues - utils.HTTPUserAgent = fmt.Sprintf("Courier/%s", s.config.Version) - // configure librato if we have configuration options for it host, _ := os.Hostname() if s.config.LibratoUsername != "" { @@ -137,7 +134,7 @@ func (s *server) Start() error { defer s.waitGroup.Done() err := s.httpServer.ListenAndServe() if err != nil && err != http.ErrServerClosed { - logrus.WithFields(logrus.Fields{"comp": "server", "state": "stopping"}).Error(err) + slog.Error("failed to start server", "error", err, "comp", "server", "state", "stopping") } }() @@ -154,18 +151,18 @@ func (s *server) Start() error { case <-time.After(time.Minute): err := s.backend.Heartbeat() if err != nil { - logrus.WithError(err).Error("error running backend heartbeat") + slog.Error("error running backend heartbeat", "error", err) } } } }() - logrus.WithFields(logrus.Fields{ - "comp": "server", - "port": s.config.Port, - "state": "started", - "version": s.config.Version, - }).Info("server listening on ", s.config.Port) + slog.Info(fmt.Sprintf("server listening on %d", s.config.Port), + "comp", "server", + "port", s.config.Port, + "state", "started", + "version", s.config.Version, + ) // start our foreman for outgoing messages s.foreman = NewForeman(s, s.config.MaxWorkers) @@ -176,15 +173,15 @@ func (s *server) Start() error { // Stop stops the server, returning only after all threads have stopped func (s *server) Stop() error { - log := logrus.WithField("comp", "server") - log.WithField("state", "stopping").Info("stopping server") + log := slog.With("comp", "server") + log.Info("stopping server", "state", "stopping") // stop our foreman s.foreman.Stop() // shut down our HTTP server if err := s.httpServer.Shutdown(context.Background()); err != nil { - log.WithField("state", "stopping").WithError(err).Error("error shutting down server") + log.Error("error shutting down server", "error", err, "state", "stopping") } // stop everything @@ -204,8 +201,7 @@ func (s *server) Stop() error { // clean things up, tearing down any connections s.backend.Cleanup() - - log.WithField("state", "stopped").Info("server stopped") + log.Info("server stopped", "state", "stopped") return nil } @@ -251,7 +247,7 @@ func (s *server) initializeChannelHandlers() { } activeHandlers[handler.ChannelType()] = handler - logrus.WithField("comp", "server").WithField("handler", handler.ChannelName()).WithField("handler_type", channelType).Info("handler initialized") + slog.Info("handler initialized", "comp", "server", "handler", handler.ChannelName(), "handler_type", channelType) } } @@ -295,7 +291,7 @@ func (s *server) channelHandleWrapper(handler ChannelHandler, handlerFunc Channe panicLog := recover() if panicLog != nil { debug.PrintStack() - logrus.WithError(err).WithField("channel_uuid", channelUUID).WithField("request", string(recorder.Trace.RequestTrace)).WithField("trace", panicLog).Error("panic handling request") + slog.Error("panic handling request", "error", err, "channel_uuid", channelUUID, "request", recorder.Trace.RequestTrace, "trace", panicLog) writeAndLogRequestError(ctx, handler, recorder.ResponseWriter, r, channel, errors.New("panic handling msg")) } }() @@ -308,13 +304,13 @@ func (s *server) channelHandleWrapper(handler ChannelHandler, handlerFunc Channe // if we received an error, write it out and report it if hErr != nil { - logrus.WithError(hErr).WithField("channel_uuid", channelUUID).WithField("request", string(recorder.Trace.RequestTrace)).Error("error handling request") + slog.Error("error handling request", "error", err, "channel_uuid", channelUUID, "request", recorder.Trace.RequestTrace) writeAndLogRequestError(ctx, handler, recorder.ResponseWriter, r, channel, hErr) } // end recording of the request so that we have a response trace if err := recorder.End(); err != nil { - logrus.WithError(err).WithField("channel_uuid", channelUUID).WithField("request", string(recorder.Trace.RequestTrace)).Error("error recording request") + slog.Error("error recording request", "error", err, "channel_uuid", channelUUID, "request", recorder.Trace.RequestTrace) writeAndLogRequestError(ctx, handler, w, r, channel, err) } @@ -330,12 +326,12 @@ func (s *server) channelHandleWrapper(handler ChannelHandler, handlerFunc Channe for _, event := range events { switch e := event.(type) { - case Msg: - clog.SetMsgID(e.ID()) + case MsgIn: + clog.SetAttached(true) analytics.Gauge(fmt.Sprintf("courier.msg_receive_%s", channel.ChannelType()), secondDuration) LogMsgReceived(r, e) - case MsgStatus: - clog.SetMsgID(e.ID()) + case StatusUpdate: + clog.SetAttached(true) analytics.Gauge(fmt.Sprintf("courier.msg_status_%s", channel.ChannelType()), secondDuration) LogMsgStatusReceived(r, e) case ChannelEvent: @@ -347,8 +343,11 @@ func (s *server) channelHandleWrapper(handler ChannelHandler, handlerFunc Channe clog.End() if err := s.backend.WriteChannelLog(ctx, clog); err != nil { - logrus.WithError(err).Error("error writing channel log") + slog.Error("error writing channel log", "error", err) } + } else { + slog.Info("non-channel specific request", "error", err, "channel_type", handler.ChannelType(), "request", recorder.Trace.RequestTrace, "status", recorder.Trace.Response.StatusCode) + } } } @@ -399,7 +398,7 @@ func (s *server) handleFetchAttachment(w http.ResponseWriter, r *http.Request) { resp, err := fetchAttachment(ctx, s.backend, r) if err != nil { - logrus.WithError(err).Error() + slog.Error("error fetching attachment", "error", err) WriteError(w, http.StatusBadRequest, err) return } @@ -410,20 +409,21 @@ func (s *server) handleFetchAttachment(w http.ResponseWriter, r *http.Request) { } func (s *server) handle404(w http.ResponseWriter, r *http.Request) { - logrus.WithField("url", r.URL.String()).WithField("method", r.Method).WithField("resp_status", "404").Info("not found") - errors := []interface{}{NewErrorData(fmt.Sprintf("not found: %s", r.URL.String()))} + slog.Info("not found", "url", r.URL.String(), "method", r.Method, "resp_status", "404") + errors := []any{NewErrorData(fmt.Sprintf("not found: %s", r.URL.String()))} err := WriteDataResponse(w, http.StatusNotFound, "Not Found", errors) if err != nil { - logrus.WithError(err).Error() + slog.Error("error writing response", "error", err) } } func (s *server) handle405(w http.ResponseWriter, r *http.Request) { - logrus.WithField("url", r.URL.String()).WithField("method", r.Method).WithField("resp_status", "405").Info("invalid method") - errors := []interface{}{NewErrorData(fmt.Sprintf("method not allowed: %s", r.Method))} + slog.Info("invalid method", "url", r.URL.String(), "method", r.Method, "resp_status", "405") + errors := []any{NewErrorData(fmt.Sprintf("method not allowed: %s", r.Method))} err := WriteDataResponse(w, http.StatusMethodNotAllowed, "Method Not Allowed", errors) if err != nil { - logrus.WithError(err).Error() + slog.Error("error writing response", "error", err) + } } diff --git a/server_test.go b/server_test.go index 94d989181..a34a6caf8 100644 --- a/server_test.go +++ b/server_test.go @@ -1,6 +1,7 @@ package courier_test import ( + "log/slog" "net/http" "strings" "testing" @@ -10,13 +11,12 @@ import ( "github.com/nyaruka/courier/test" "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/uuids" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestServer(t *testing.T) { - logger := logrus.New() + logger := slog.Default() config := courier.NewConfig() config.StatusUsername = "admin" config.StatusPassword = "password123" @@ -89,12 +89,12 @@ func TestFetchAttachment(t *testing.T) { defer uuids.SetGenerator(uuids.DefaultGenerator) uuids.SetGenerator(uuids.NewSeededGenerator(1234)) - logger := logrus.New() + logger := slog.Default() config := courier.NewConfig() config.AuthToken = "sesame" mb := test.NewMockBackend() - mockChannel := test.NewMockChannel("e4bb1578-29da-4fa5-a214-9da19dd24230", "MCK", "2020", "US", map[string]interface{}{}) + mockChannel := test.NewMockChannel("e4bb1578-29da-4fa5-a214-9da19dd24230", "MCK", "2020", "US", map[string]any{}) mb.AddChannel(mockChannel) server := courier.NewServerWithLogger(config, mb, logger) diff --git a/spool.go b/spool.go index 00fbe6ead..fc451bed1 100644 --- a/spool.go +++ b/spool.go @@ -4,13 +4,12 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "os" "path" "path/filepath" "strings" "time" - - "github.com/sirupsen/logrus" ) // FlusherFunc defines our interface for flushers, they are handed a filename and byte blob and are expected @@ -23,7 +22,7 @@ func RegisterFlusher(directory string, flusherFunc FlusherFunc) { } // WriteToSpool writes the passed in object to the passed in subdir -func WriteToSpool(spoolDir string, subdir string, contents interface{}) error { +func WriteToSpool(spoolDir string, subdir string, contents any) error { contentBytes, err := json.MarshalIndent(contents, "", " ") if err != nil { return err @@ -46,8 +45,8 @@ func startSpoolFlushers(s Server) { go func() { defer s.WaitGroup().Done() - log := logrus.WithField("comp", "spool") - log.WithField("state", "started").Info("spool started") + log := slog.With("comp", "spool") + log.Info("spool started", "state", "started") // runs until stopped, checking every 30 seconds if there is anything to flush from our spool for { @@ -55,7 +54,7 @@ func startSpoolFlushers(s Server) { // our server is shutting down, exit case <-s.StopChan(): - log.WithField("state", "stopped").Info("spool stopped") + log.Info("spool stopped", "state", "stopped") return // every 30 seconds we check to see if there are any files to spool @@ -99,18 +98,18 @@ func newSpoolFlusher(s Server, dir string, flusherFunc FlusherFunc) *flusher { return nil } - log := logrus.WithField("comp", "spool").WithField("filename", filename) + log := slog.With("comp", "spool", "filename", filename) // otherwise, read our msg json contents, err := os.ReadFile(filename) if err != nil { - log.WithError(err).Error("reading spool file") + log.Error("reading spool file", "error", err) return nil } err = flusherFunc(filename, contents) if err != nil { - log.WithError(err).Error("flushing spool file") + log.Error("flushing spool file", "error", err) return err } log.Info("flushed") diff --git a/status.go b/status.go index f957f6677..b729b8ce7 100644 --- a/status.go +++ b/status.go @@ -2,39 +2,38 @@ package courier import "github.com/nyaruka/gocommon/urns" -// MsgStatusValue is the status of a message -type MsgStatusValue string +// MsgStatus is the status of a message +type MsgStatus string // Possible values for MsgStatus const ( - MsgPending MsgStatusValue = "P" - MsgQueued MsgStatusValue = "Q" - MsgSent MsgStatusValue = "S" - MsgWired MsgStatusValue = "W" - MsgErrored MsgStatusValue = "E" - MsgDelivered MsgStatusValue = "D" - MsgFailed MsgStatusValue = "F" - NilMsgStatus MsgStatusValue = "" + MsgStatusPending MsgStatus = "P" + MsgStatusQueued MsgStatus = "Q" + MsgStatusSent MsgStatus = "S" + MsgStatusWired MsgStatus = "W" + MsgStatusErrored MsgStatus = "E" + MsgStatusDelivered MsgStatus = "D" + MsgStatusFailed MsgStatus = "F" + NilMsgStatus MsgStatus = "" ) //----------------------------------------------------------------------------- -// MsgStatusUpdate Interface +// StatusUpdate Interface //----------------------------------------------------------------------------- -// MsgStatus represents a status update on a message -type MsgStatus interface { - EventID() int64 +// StatusUpdate represents a status update on a message +type StatusUpdate interface { + Event ChannelUUID() ChannelUUID - ID() MsgID + MsgID() MsgID - SetUpdatedURN(old, new urns.URN) error - UpdatedURN() (old, new urns.URN) - HasUpdatedURN() bool + SetURNUpdate(old, new urns.URN) error + URNUpdate() (old, new urns.URN) ExternalID() string SetExternalID(string) - Status() MsgStatusValue - SetStatus(MsgStatusValue) + Status() MsgStatus + SetStatus(MsgStatus) } diff --git a/test/backend.go b/test/backend.go index 565259c6f..b4ba905c9 100644 --- a/test/backend.go +++ b/test/backend.go @@ -4,12 +4,15 @@ import ( "context" "fmt" "log" + "net/http" "sync" "time" "github.com/gomodule/redigo/redis" _ "github.com/lib/pq" "github.com/nyaruka/courier" + "github.com/nyaruka/courier/utils" + "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/gocommon/uuids" "github.com/pkg/errors" @@ -35,15 +38,15 @@ type MockBackend struct { channels map[courier.ChannelUUID]courier.Channel channelsByAddress map[courier.ChannelAddress]courier.Channel contacts map[urns.URN]courier.Contact - outgoingMsgs []courier.Msg + outgoingMsgs []courier.MsgOut media map[string]courier.Media // url -> Media errorOnQueue bool mutex sync.RWMutex redisPool *redis.Pool - writtenMsgs []courier.Msg - writtenMsgStatuses []courier.MsgStatus + writtenMsgs []courier.MsgIn + writtenMsgStatuses []courier.StatusUpdate writtenChannelEvents []courier.ChannelEvent writtenChannelLogs []*courier.ChannelLog savedAttachments []*SavedAttachment @@ -51,6 +54,7 @@ type MockBackend struct { lastMsgID courier.MsgID lastContactName string + urnAuthTokens map[urns.URN]map[string]string sentMsgs map[courier.MsgID]bool seenExternalIDs map[string]courier.MsgUUID } @@ -90,14 +94,14 @@ func NewMockBackend() *MockBackend { } } -// DeleteMsgWithExternalID delete a message we receive an event that it should be deleted -func (mb *MockBackend) DeleteMsgWithExternalID(ctx context.Context, channel courier.Channel, externalID string) error { +// DeleteMsgByExternalID delete a message we receive an event that it should be deleted +func (mb *MockBackend) DeleteMsgByExternalID(ctx context.Context, channel courier.Channel, externalID string) error { return nil } // NewIncomingMsg creates a new message from the given params -func (mb *MockBackend) NewIncomingMsg(channel courier.Channel, urn urns.URN, text string, extID string, clog *courier.ChannelLog) courier.Msg { - m := &mockMsg{ +func (mb *MockBackend) NewIncomingMsg(channel courier.Channel, urn urns.URN, text string, extID string, clog *courier.ChannelLog) courier.MsgIn { + m := &MockMsg{ channel: channel, urn: urn, text: text, externalID: extID, } @@ -112,9 +116,9 @@ func (mb *MockBackend) NewIncomingMsg(channel courier.Channel, urn urns.URN, tex // NewOutgoingMsg creates a new outgoing message from the given params func (mb *MockBackend) NewOutgoingMsg(channel courier.Channel, id courier.MsgID, urn urns.URN, text string, highPriority bool, quickReplies []string, - topic string, responseToExternalID string, origin courier.MsgOrigin, contactLastSeenOn *time.Time) courier.Msg { + topic string, responseToExternalID string, origin courier.MsgOrigin, contactLastSeenOn *time.Time) courier.MsgOut { - return &mockMsg{ + return &MockMsg{ channel: channel, id: id, urn: urn, @@ -129,7 +133,7 @@ func (mb *MockBackend) NewOutgoingMsg(channel courier.Channel, id courier.MsgID, } // PushOutgoingMsg is a test method to add a message to our queue of messages to send -func (mb *MockBackend) PushOutgoingMsg(msg courier.Msg) { +func (mb *MockBackend) PushOutgoingMsg(msg courier.MsgOut) { mb.mutex.Lock() defer mb.mutex.Unlock() @@ -137,7 +141,7 @@ func (mb *MockBackend) PushOutgoingMsg(msg courier.Msg) { } // PopNextOutgoingMsg returns the next message that should be sent, or nil if there are none to send -func (mb *MockBackend) PopNextOutgoingMsg(ctx context.Context) (courier.Msg, error) { +func (mb *MockBackend) PopNextOutgoingMsg(ctx context.Context) (courier.MsgOut, error) { mb.mutex.Lock() defer mb.mutex.Unlock() @@ -167,7 +171,7 @@ func (mb *MockBackend) ClearMsgSent(ctx context.Context, id courier.MsgID) error } // MarkOutgoingMsgComplete marks the passed msg as having been dealt with -func (mb *MockBackend) MarkOutgoingMsgComplete(ctx context.Context, msg courier.Msg, s courier.MsgStatus) { +func (mb *MockBackend) MarkOutgoingMsgComplete(ctx context.Context, msg courier.MsgOut, s courier.StatusUpdate) { mb.mutex.Lock() defer mb.mutex.Unlock() @@ -189,23 +193,27 @@ func (mb *MockBackend) SetErrorOnQueue(shouldError bool) { } // WriteMsg queues the passed in message internally -func (mb *MockBackend) WriteMsg(ctx context.Context, m courier.Msg, clog *courier.ChannelLog) error { - mock := m.(*mockMsg) +func (mb *MockBackend) WriteMsg(ctx context.Context, m courier.MsgIn, clog *courier.ChannelLog) error { + mm := m.(*MockMsg) // this msg has already been written (we received it twice), we are a no op - if mock.alreadyWritten { + if mm.alreadyWritten { return nil } mb.lastMsgID++ - mock.id = mb.lastMsgID + mm.id = mb.lastMsgID if mb.errorOnQueue { return errors.New("unable to queue message") } mb.writtenMsgs = append(mb.writtenMsgs, m) - mb.lastContactName = m.(*mockMsg).contactName + mb.lastContactName = mm.contactName + + if mm.urnAuthTokens != nil { + mb.recordURNAuthTokens(mm.urn, mm.urnAuthTokens) + } if m.ExternalID() != "" { mb.seenExternalIDs[fmt.Sprintf("%s|%s", m.Channel().UUID(), m.ExternalID())] = m.UUID() @@ -214,19 +222,19 @@ func (mb *MockBackend) WriteMsg(ctx context.Context, m courier.Msg, clog *courie return nil } -// NewMsgStatusForID creates a new Status object for the given message id -func (mb *MockBackend) NewMsgStatusForID(channel courier.Channel, id courier.MsgID, status courier.MsgStatusValue, clog *courier.ChannelLog) courier.MsgStatus { - return &mockMsgStatus{ +// NewStatusUpdate creates a new Status object for the given message id +func (mb *MockBackend) NewStatusUpdate(channel courier.Channel, id courier.MsgID, status courier.MsgStatus, clog *courier.ChannelLog) courier.StatusUpdate { + return &MockStatusUpdate{ channel: channel, - id: id, + msgID: id, status: status, createdOn: time.Now().In(time.UTC), } } -// NewMsgStatusForExternalID creates a new Status object for the given external id -func (mb *MockBackend) NewMsgStatusForExternalID(channel courier.Channel, externalID string, status courier.MsgStatusValue, clog *courier.ChannelLog) courier.MsgStatus { - return &mockMsgStatus{ +// NewStatusUpdateByExternalID creates a new Status object for the given external id +func (mb *MockBackend) NewStatusUpdateByExternalID(channel courier.Channel, externalID string, status courier.MsgStatus, clog *courier.ChannelLog) courier.StatusUpdate { + return &MockStatusUpdate{ channel: channel, externalID: externalID, status: status, @@ -234,8 +242,8 @@ func (mb *MockBackend) NewMsgStatusForExternalID(channel courier.Channel, extern } } -// WriteMsgStatus writes the status update to our queue -func (mb *MockBackend) WriteMsgStatus(ctx context.Context, status courier.MsgStatus) error { +// WriteStatusUpdate writes the status update to our queue +func (mb *MockBackend) WriteStatusUpdate(ctx context.Context, status courier.StatusUpdate) error { mb.mutex.Lock() defer mb.mutex.Unlock() @@ -254,11 +262,18 @@ func (mb *MockBackend) NewChannelEvent(channel courier.Channel, eventType courie // WriteChannelEvent writes the channel event passed in func (mb *MockBackend) WriteChannelEvent(ctx context.Context, event courier.ChannelEvent, clog *courier.ChannelLog) error { + evt := event.(*mockChannelEvent) + mb.mutex.Lock() defer mb.mutex.Unlock() mb.writtenChannelEvents = append(mb.writtenChannelEvents, event) - mb.lastContactName = event.(*mockChannelEvent).contactName + mb.lastContactName = evt.contactName + + if evt.urnAuthTokens != nil { + mb.recordURNAuthTokens(evt.urn, evt.urnAuthTokens) + } + return nil } @@ -281,17 +296,17 @@ func (mb *MockBackend) GetChannelByAddress(ctx context.Context, cType courier.Ch } // GetContact creates a new contact with the passed in channel and URN -func (mb *MockBackend) GetContact(ctx context.Context, channel courier.Channel, urn urns.URN, auth, name string, clog *courier.ChannelLog) (courier.Contact, error) { +func (mb *MockBackend) GetContact(ctx context.Context, channel courier.Channel, urn urns.URN, authTokens map[string]string, name string, clog *courier.ChannelLog) (courier.Contact, error) { contact, found := mb.contacts[urn] if !found { - contact = &mockContact{channel, urn, auth, courier.ContactUUID(uuids.New())} + contact = &mockContact{channel, urn, authTokens, courier.ContactUUID(uuids.New())} mb.contacts[urn] = contact } return contact, nil } // AddURNtoContact adds a URN to the passed in contact -func (mb *MockBackend) AddURNtoContact(context context.Context, channel courier.Channel, contact courier.Contact, urn urns.URN) (urns.URN, error) { +func (mb *MockBackend) AddURNtoContact(context context.Context, channel courier.Channel, contact courier.Contact, urn urns.URN, authTokens map[string]string) (urns.URN, error) { mb.contacts[urn] = contact return urn, nil } @@ -339,11 +354,19 @@ func (mb *MockBackend) ResolveMedia(ctx context.Context, mediaUrl string) (couri return media, nil } -// Health gives a string representing our health, empty for our mock func (mb *MockBackend) Health() string { return "" } +// Health gives a string representing our health, empty for our mock +func (mb *MockBackend) HttpClient(bool) *http.Client { + return http.DefaultClient +} + +func (mb *MockBackend) HttpAccess() *httpx.AccessConfig { + return nil +} + // Status returns a string describing the status of the service, queue size etc.. func (mb *MockBackend) Status() string { return "ALL GOOD" @@ -363,11 +386,12 @@ func (mb *MockBackend) RedisPool() *redis.Pool { // Methods not part of the backed interface but used in tests //////////////////////////////////////////////////////////////////////////////// -func (mb *MockBackend) WrittenMsgs() []courier.Msg { return mb.writtenMsgs } -func (mb *MockBackend) WrittenMsgStatuses() []courier.MsgStatus { return mb.writtenMsgStatuses } -func (mb *MockBackend) WrittenChannelEvents() []courier.ChannelEvent { return mb.writtenChannelEvents } -func (mb *MockBackend) WrittenChannelLogs() []*courier.ChannelLog { return mb.writtenChannelLogs } -func (mb *MockBackend) SavedAttachments() []*SavedAttachment { return mb.savedAttachments } +func (mb *MockBackend) WrittenMsgs() []courier.MsgIn { return mb.writtenMsgs } +func (mb *MockBackend) WrittenMsgStatuses() []courier.StatusUpdate { return mb.writtenMsgStatuses } +func (mb *MockBackend) WrittenChannelEvents() []courier.ChannelEvent { return mb.writtenChannelEvents } +func (mb *MockBackend) WrittenChannelLogs() []*courier.ChannelLog { return mb.writtenChannelLogs } +func (mb *MockBackend) SavedAttachments() []*SavedAttachment { return mb.savedAttachments } +func (mb *MockBackend) URNAuthTokens() map[urns.URN]map[string]string { return mb.urnAuthTokens } // LastContactName returns the contact name set on the last msg or channel event written func (mb *MockBackend) LastContactName() string { @@ -400,9 +424,20 @@ func (mb *MockBackend) Reset() { mb.writtenMsgStatuses = nil mb.writtenChannelEvents = nil mb.writtenChannelLogs = nil + mb.urnAuthTokens = nil } // SetStorageError sets the error to return for operation that try to use storage func (mb *MockBackend) SetStorageError(err error) { mb.storageError = err } + +func (mb *MockBackend) recordURNAuthTokens(urn urns.URN, authTokens map[string]string) { + if mb.urnAuthTokens == nil { + mb.urnAuthTokens = make(map[urns.URN]map[string]string) + } + if mb.urnAuthTokens[urn] == nil { + mb.urnAuthTokens[urn] = map[string]string{} + } + utils.MapUpdate(mb.urnAuthTokens[urn], authTokens) +} diff --git a/test/channel.go b/test/channel.go index a47375d1e..6f5157315 100644 --- a/test/channel.go +++ b/test/channel.go @@ -17,8 +17,8 @@ type MockChannel struct { address courier.ChannelAddress country string role string - config map[string]interface{} - orgConfig map[string]interface{} + config map[string]any + orgConfig map[string]any } // UUID returns the uuid for this channel @@ -51,7 +51,7 @@ func (c *MockChannel) ChannelAddress() courier.ChannelAddress { return c.address func (c *MockChannel) Country() string { return c.country } // SetConfig sets the passed in config parameter -func (c *MockChannel) SetConfig(key string, value interface{}) { +func (c *MockChannel) SetConfig(key string, value any) { c.config[key] = value } @@ -65,7 +65,7 @@ func (c *MockChannel) CallbackDomain(fallbackDomain string) string { } // ConfigForKey returns the config value for the passed in key -func (c *MockChannel) ConfigForKey(key string, defaultValue interface{}) interface{} { +func (c *MockChannel) ConfigForKey(key string, defaultValue any) any { value, found := c.config[key] if !found { return defaultValue @@ -120,7 +120,7 @@ func (c *MockChannel) IntConfigForKey(key string, defaultValue int) int { } // OrgConfigForKey returns the org config value for the passed in key -func (c *MockChannel) OrgConfigForKey(key string, defaultValue interface{}) interface{} { +func (c *MockChannel) OrgConfigForKey(key string, defaultValue any) any { value, found := c.orgConfig[key] if !found { return defaultValue @@ -153,7 +153,7 @@ func (c *MockChannel) HasRole(role courier.ChannelRole) bool { } // NewMockChannel creates a new mock channel for the passed in type, address, country and config -func NewMockChannel(uuid string, channelType string, address string, country string, config map[string]interface{}) *MockChannel { +func NewMockChannel(uuid string, channelType string, address string, country string, config map[string]any) *MockChannel { return &MockChannel{ uuid: courier.ChannelUUID(uuid), channelType: courier.ChannelType(channelType), @@ -162,6 +162,6 @@ func NewMockChannel(uuid string, channelType string, address string, country str country: country, config: config, role: "SR", - orgConfig: map[string]interface{}{}, + orgConfig: map[string]any{}, } } diff --git a/test/channel_event.go b/test/channel_event.go index 4742d9ac6..177f5300c 100644 --- a/test/channel_event.go +++ b/test/channel_event.go @@ -14,8 +14,9 @@ type mockChannelEvent struct { createdOn time.Time occurredOn time.Time - contactName string - extra map[string]interface{} + contactName string + urnAuthTokens map[string]string + extra map[string]string } func (e *mockChannelEvent) EventID() int64 { return 0 } @@ -23,18 +24,25 @@ func (e *mockChannelEvent) ChannelUUID() courier.ChannelUUID { return e.chann func (e *mockChannelEvent) EventType() courier.ChannelEventType { return e.eventType } func (e *mockChannelEvent) CreatedOn() time.Time { return e.createdOn } func (e *mockChannelEvent) OccurredOn() time.Time { return e.occurredOn } -func (e *mockChannelEvent) Extra() map[string]interface{} { return e.extra } +func (e *mockChannelEvent) Extra() map[string]string { return e.extra } func (e *mockChannelEvent) ContactName() string { return e.contactName } func (e *mockChannelEvent) URN() urns.URN { return e.urn } -func (e *mockChannelEvent) WithExtra(extra map[string]interface{}) courier.ChannelEvent { +func (e *mockChannelEvent) WithExtra(extra map[string]string) courier.ChannelEvent { e.extra = extra return e } + func (e *mockChannelEvent) WithContactName(name string) courier.ChannelEvent { e.contactName = name return e } + +func (e *mockChannelEvent) WithURNAuthTokens(tokens map[string]string) courier.ChannelEvent { + e.urnAuthTokens = tokens + return e +} + func (e *mockChannelEvent) WithOccurredOn(time time.Time) courier.ChannelEvent { e.occurredOn = time return e diff --git a/test/contact.go b/test/contact.go index 4952780b7..aa80fcccc 100644 --- a/test/contact.go +++ b/test/contact.go @@ -6,10 +6,10 @@ import ( ) type mockContact struct { - channel courier.Channel - urn urns.URN - auth string - uuid courier.ContactUUID + channel courier.Channel + urn urns.URN + authTokens map[string]string + uuid courier.ContactUUID } func (c *mockContact) UUID() courier.ContactUUID { return c.uuid } diff --git a/test/handler.go b/test/handler.go index e3084bdf9..33394171f 100644 --- a/test/handler.go +++ b/test/handler.go @@ -31,7 +31,7 @@ func (h *mockHandler) UseChannelRouteUUID() bool { return true } func (h *mockHandler) RedactValues(courier.Channel) []string { return []string{"sesame"} } func (h *mockHandler) GetChannel(ctx context.Context, r *http.Request) (courier.Channel, error) { - dmChannel := NewMockChannel("e4bb1578-29da-4fa5-a214-9da19dd24230", "MCK", "2020", "US", map[string]interface{}{}) + dmChannel := NewMockChannel("e4bb1578-29da-4fa5-a214-9da19dd24230", "MCK", "2020", "US", map[string]any{}) return dmChannel, nil } @@ -44,7 +44,7 @@ func (h *mockHandler) Initialize(s courier.Server) error { } // Send sends the given message, logging any HTTP calls or errors -func (h *mockHandler) Send(ctx context.Context, msg courier.Msg, clog *courier.ChannelLog) (courier.MsgStatus, error) { +func (h *mockHandler) Send(ctx context.Context, msg courier.MsgOut, clog *courier.ChannelLog) (courier.StatusUpdate, error) { // log a request that contains a header value that should be redacted req, _ := httpx.NewRequest("GET", "http://mock.com/send", nil, map[string]string{"Authorization": "Token sesame"}) trace, _ := httpx.DoTrace(http.DefaultClient, req, nil, nil, 1024) @@ -53,14 +53,14 @@ func (h *mockHandler) Send(ctx context.Context, msg courier.Msg, clog *courier.C // log an error than contains a value that should be redacted clog.Error(courier.NewChannelError("seeds", "", "contains sesame seeds")) - return h.backend.NewMsgStatusForID(msg.Channel(), msg.ID(), courier.MsgSent, clog), nil + return h.backend.NewStatusUpdate(msg.Channel(), msg.ID(), courier.MsgStatusSent, clog), nil } -func (h *mockHandler) WriteStatusSuccessResponse(ctx context.Context, w http.ResponseWriter, statuses []courier.MsgStatus) error { +func (h *mockHandler) WriteStatusSuccessResponse(ctx context.Context, w http.ResponseWriter, statuses []courier.StatusUpdate) error { return courier.WriteStatusSuccess(w, statuses) } -func (h *mockHandler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.Msg) error { +func (h *mockHandler) WriteMsgSuccessResponse(ctx context.Context, w http.ResponseWriter, msgs []courier.MsgIn) error { return courier.WriteMsgSuccess(w, msgs) } diff --git a/test/msg.go b/test/msg.go index e5e7112b7..c4fdad252 100644 --- a/test/msg.go +++ b/test/msg.go @@ -5,18 +5,20 @@ import ( "time" "github.com/nyaruka/courier" + "github.com/nyaruka/gocommon/i18n" "github.com/nyaruka/gocommon/urns" ) -type mockMsg struct { +type MockMsg struct { id courier.MsgID uuid courier.MsgUUID channel courier.Channel urn urns.URN urnAuth string + urnAuthTokens map[string]string text string attachments []string - locale courier.Locale + locale i18n.Locale externalID string contactName string highPriority bool @@ -29,73 +31,70 @@ type mockMsg struct { alreadyWritten bool isResend bool - flow *courier.FlowReference + flow *courier.FlowReference + optIn *courier.OptInReference receivedOn *time.Time sentOn *time.Time - wiredOn *time.Time } -func NewMockMsg(id courier.MsgID, uuid courier.MsgUUID, channel courier.Channel, urn urns.URN, text string) courier.Msg { - return &mockMsg{ - id: id, - uuid: uuid, - channel: channel, - urn: urn, - text: text, +func NewMockMsg(id courier.MsgID, uuid courier.MsgUUID, channel courier.Channel, urn urns.URN, text string, attachments []string) *MockMsg { + return &MockMsg{ + id: id, + uuid: uuid, + channel: channel, + urn: urn, + text: text, + attachments: attachments, } } -func (m *mockMsg) SessionStatus() string { return "" } -func (m *mockMsg) Flow() *courier.FlowReference { return m.flow } +func (m *MockMsg) EventID() int64 { return int64(m.id) } +func (m *MockMsg) ID() courier.MsgID { return m.id } +func (m *MockMsg) UUID() courier.MsgUUID { return m.uuid } +func (m *MockMsg) ExternalID() string { return m.externalID } +func (m *MockMsg) Text() string { return m.text } +func (m *MockMsg) Attachments() []string { return m.attachments } +func (m *MockMsg) URN() urns.URN { return m.urn } +func (m *MockMsg) Channel() courier.Channel { return m.channel } -func (m *mockMsg) FlowName() string { - if m.flow == nil { - return "" - } - return m.flow.Name -} - -func (m *mockMsg) FlowUUID() string { - if m.flow == nil { - return "" - } - return m.flow.UUID -} +// outgoing specific +func (m *MockMsg) QuickReplies() []string { return m.quickReplies } +func (m *MockMsg) Locale() i18n.Locale { return m.locale } +func (m *MockMsg) URNAuth() string { return m.urnAuth } +func (m *MockMsg) Origin() courier.MsgOrigin { return m.origin } +func (m *MockMsg) ContactLastSeenOn() *time.Time { return m.contactLastSeenOn } +func (m *MockMsg) Topic() string { return m.topic } +func (m *MockMsg) Metadata() json.RawMessage { return m.metadata } +func (m *MockMsg) ResponseToExternalID() string { return m.responseToExternalID } +func (m *MockMsg) SentOn() *time.Time { return m.sentOn } +func (m *MockMsg) IsResend() bool { return m.isResend } +func (m *MockMsg) Flow() *courier.FlowReference { return m.flow } +func (m *MockMsg) OptIn() *courier.OptInReference { return m.optIn } +func (m *MockMsg) SessionStatus() string { return "" } +func (m *MockMsg) HighPriority() bool { return m.highPriority } -func (m *mockMsg) Channel() courier.Channel { return m.channel } -func (m *mockMsg) ID() courier.MsgID { return m.id } -func (m *mockMsg) EventID() int64 { return int64(m.id) } -func (m *mockMsg) UUID() courier.MsgUUID { return m.uuid } -func (m *mockMsg) Text() string { return m.text } -func (m *mockMsg) Attachments() []string { return m.attachments } -func (m *mockMsg) Locale() courier.Locale { return m.locale } -func (m *mockMsg) ExternalID() string { return m.externalID } -func (m *mockMsg) URN() urns.URN { return m.urn } -func (m *mockMsg) URNAuth() string { return m.urnAuth } -func (m *mockMsg) ContactName() string { return m.contactName } -func (m *mockMsg) HighPriority() bool { return m.highPriority } -func (m *mockMsg) QuickReplies() []string { return m.quickReplies } -func (m *mockMsg) Origin() courier.MsgOrigin { return m.origin } -func (m *mockMsg) ContactLastSeenOn() *time.Time { return m.contactLastSeenOn } -func (m *mockMsg) Topic() string { return m.topic } -func (m *mockMsg) ResponseToExternalID() string { return m.responseToExternalID } -func (m *mockMsg) Metadata() json.RawMessage { return m.metadata } -func (m *mockMsg) IsResend() bool { return m.isResend } -func (m *mockMsg) ReceivedOn() *time.Time { return m.receivedOn } -func (m *mockMsg) SentOn() *time.Time { return m.sentOn } -func (m *mockMsg) WiredOn() *time.Time { return m.wiredOn } - -func (m *mockMsg) WithContactName(name string) courier.Msg { m.contactName = name; return m } -func (m *mockMsg) WithURNAuth(auth string) courier.Msg { m.urnAuth = auth; return m } -func (m *mockMsg) WithReceivedOn(date time.Time) courier.Msg { m.receivedOn = &date; return m } -func (m *mockMsg) WithID(id courier.MsgID) courier.Msg { m.id = id; return m } -func (m *mockMsg) WithUUID(uuid courier.MsgUUID) courier.Msg { m.uuid = uuid; return m } -func (m *mockMsg) WithAttachment(url string) courier.Msg { +// incoming specific +func (m *MockMsg) ReceivedOn() *time.Time { return m.receivedOn } +func (m *MockMsg) WithAttachment(url string) courier.MsgIn { m.attachments = append(m.attachments, url) return m } -func (m *mockMsg) WithLocale(lc courier.Locale) courier.Msg { m.locale = lc; return m } -func (m *mockMsg) WithMetadata(metadata json.RawMessage) courier.Msg { m.metadata = metadata; return m } +func (m *MockMsg) WithContactName(name string) courier.MsgIn { m.contactName = name; return m } +func (m *MockMsg) WithURNAuthTokens(tokens map[string]string) courier.MsgIn { + m.urnAuthTokens = tokens + return m +} +func (m *MockMsg) WithReceivedOn(date time.Time) courier.MsgIn { m.receivedOn = &date; return m } -func (m *mockMsg) WithFlow(flow *courier.FlowReference) courier.Msg { m.flow = flow; return m } +// used to create outgoing messages for testing +func (m *MockMsg) WithID(id courier.MsgID) courier.MsgOut { m.id = id; return m } +func (m *MockMsg) WithUUID(uuid courier.MsgUUID) courier.MsgOut { m.uuid = uuid; return m } +func (m *MockMsg) WithMetadata(metadata json.RawMessage) courier.MsgOut { + m.metadata = metadata + return m +} +func (m *MockMsg) WithFlow(flow *courier.FlowReference) courier.MsgOut { m.flow = flow; return m } +func (m *MockMsg) WithOptIn(optIn *courier.OptInReference) courier.MsgOut { m.optIn = optIn; return m } +func (m *MockMsg) WithLocale(lc i18n.Locale) courier.MsgOut { m.locale = lc; return m } +func (m *MockMsg) WithURNAuth(token string) courier.MsgOut { m.urnAuth = token; return m } diff --git a/test/server.go b/test/server.go new file mode 100644 index 000000000..9f31b92d8 --- /dev/null +++ b/test/server.go @@ -0,0 +1,59 @@ +package test + +import ( + "sync" + + "github.com/go-chi/chi" + "github.com/nyaruka/courier" +) + +type MockServer struct { + backend courier.Backend + config *courier.Config + + stopChan chan bool + stopped bool +} + +func NewMockServer(config *courier.Config, backend courier.Backend) courier.Server { + return &MockServer{ + backend: backend, + config: config, + stopChan: make(chan bool), + } +} + +func (ms *MockServer) Config() *courier.Config { + return ms.config +} + +func (ms *MockServer) AddHandlerRoute(handler courier.ChannelHandler, method string, action string, logType courier.ChannelLogType, handlerFunc courier.ChannelHandleFunc) { + +} +func (ms *MockServer) GetHandler(courier.Channel) courier.ChannelHandler { + return nil +} + +func (ms *MockServer) Backend() courier.Backend { + return ms.backend +} + +func (ms *MockServer) WaitGroup() *sync.WaitGroup { + return nil +} +func (ms *MockServer) StopChan() chan bool { + return ms.stopChan +} +func (ms *MockServer) Stopped() bool { + return ms.stopped +} + +func (ms *MockServer) Router() chi.Router { + return nil +} + +func (ms *MockServer) Start() error { return nil } +func (ms *MockServer) Stop() error { + ms.stopped = true + return nil +} diff --git a/test/status.go b/test/status.go index 01fca36f2..12e6a9037 100644 --- a/test/status.go +++ b/test/status.go @@ -7,37 +7,31 @@ import ( "github.com/nyaruka/gocommon/urns" ) -type mockMsgStatus struct { +type MockStatusUpdate struct { channel courier.Channel - id courier.MsgID + msgID courier.MsgID oldURN urns.URN newURN urns.URN externalID string - status courier.MsgStatusValue + status courier.MsgStatus createdOn time.Time } -func (m *mockMsgStatus) ChannelUUID() courier.ChannelUUID { return m.channel.UUID() } -func (m *mockMsgStatus) ID() courier.MsgID { return m.id } -func (m *mockMsgStatus) EventID() int64 { return int64(m.id) } +func (m *MockStatusUpdate) EventID() int64 { return int64(m.msgID) } +func (m *MockStatusUpdate) ChannelUUID() courier.ChannelUUID { return m.channel.UUID() } +func (m *MockStatusUpdate) MsgID() courier.MsgID { return m.msgID } -func (m *mockMsgStatus) SetUpdatedURN(old, new urns.URN) error { +func (m *MockStatusUpdate) SetURNUpdate(old, new urns.URN) error { m.oldURN = old m.newURN = new return nil } -func (m *mockMsgStatus) UpdatedURN() (urns.URN, urns.URN) { +func (m *MockStatusUpdate) URNUpdate() (urns.URN, urns.URN) { return m.oldURN, m.newURN } -func (m *mockMsgStatus) HasUpdatedURN() bool { - if m.oldURN != urns.NilURN && m.newURN != urns.NilURN { - return true - } - return false -} -func (m *mockMsgStatus) ExternalID() string { return m.externalID } -func (m *mockMsgStatus) SetExternalID(id string) { m.externalID = id } +func (m *MockStatusUpdate) ExternalID() string { return m.externalID } +func (m *MockStatusUpdate) SetExternalID(id string) { m.externalID = id } -func (m *mockMsgStatus) Status() courier.MsgStatusValue { return m.status } -func (m *mockMsgStatus) SetStatus(status courier.MsgStatusValue) { m.status = status } +func (m *MockStatusUpdate) Status() courier.MsgStatus { return m.status } +func (m *MockStatusUpdate) SetStatus(status courier.MsgStatus) { m.status = status } diff --git a/utils/http.go b/utils/http.go deleted file mode 100644 index db49c0ad1..000000000 --- a/utils/http.go +++ /dev/null @@ -1,51 +0,0 @@ -package utils - -import ( - "crypto/tls" - "net/http" - "sync" - "time" -) - -// GetHTTPClient returns the shared HTTP client used by all Courier threads -func GetHTTPClient() *http.Client { - once.Do(func() { - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.MaxIdleConns = 64 - transport.MaxIdleConnsPerHost = 8 - transport.IdleConnTimeout = 15 * time.Second - client = &http.Client{ - Transport: transport, - Timeout: 30 * time.Second, - } - }) - - return client -} - -// GetInsecureHTTPClient returns the shared HTTP client used by all Courier threads -func GetInsecureHTTPClient() *http.Client { - insecureOnce.Do(func() { - insecureTransport := http.DefaultTransport.(*http.Transport).Clone() - insecureTransport.MaxIdleConns = 64 - insecureTransport.MaxIdleConnsPerHost = 8 - insecureTransport.IdleConnTimeout = 15 * time.Second - insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - insecureClient = &http.Client{ - Transport: insecureTransport, - Timeout: 30 * time.Second, - } - }) - - return insecureClient -} - -var ( - client *http.Client - once sync.Once - - insecureClient *http.Client - insecureOnce sync.Once - - HTTPUserAgent = "Courier/vDev" -) diff --git a/utils/http_test.go b/utils/http_test.go deleted file mode 100644 index 160ef0480..000000000 --- a/utils/http_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package utils - -import "testing" - -func TestClient(t *testing.T) { - client := GetHTTPClient() - if client == nil { - t.Error("Client should not be nil") - } - - insecureClient := GetInsecureHTTPClient() - if insecureClient == nil { - t.Error("Insecure client should not be nil") - } - - if client == insecureClient || client.Transport == insecureClient.Transport { - t.Error("Client and insecure client should not be the same") - } - - client2 := GetHTTPClient() - if client != client2 { - t.Error("GetHTTPClient should always return same client") - } -} diff --git a/utils/misc.go b/utils/misc.go index 7b1c7f879..a3d650951 100644 --- a/utils/misc.go +++ b/utils/misc.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "net/url" "path" + "reflect" "unicode/utf8" validator "gopkg.in/go-playground/validator.v9" @@ -133,3 +134,25 @@ func ChunkSlice[T any](slice []T, size int) [][]T { } return chunks } + +// MapContains returns whether m1 contains all the key value pairs in m2 +func MapContains[K comparable, V comparable, M ~map[K]V](m1 M, m2 M) bool { + for k, v2 := range m2 { + v1, ok := m1[k] + if !ok || v1 != v2 { + return false + } + } + return true +} + +// MapUpdate updates map m1 to contain the key value pairs in m2 - deleting any pairs in m1 which have zero values in m2. +func MapUpdate[K comparable, V comparable, M ~map[K]V](m1 M, m2 M) { + for k, v := range m2 { + if reflect.ValueOf(v).IsZero() { + delete(m1, k) + } else { + m1[k] = v + } + } +} \ No newline at end of file diff --git a/utils/misc_test.go b/utils/misc_test.go index 9dc055266..987d8b25d 100644 --- a/utils/misc_test.go +++ b/utils/misc_test.go @@ -103,3 +103,45 @@ func TestStringsToRows(t *testing.T) { assert.Equal(t, tc.expected, rows, "rows mismatch for replies %v", tc.replies) } } + +func TestMapContains(t *testing.T) { + assert.True(t, utils.MapContains(map[string]string{}, map[string]string{})) + assert.True(t, utils.MapContains(map[string]string{"a": "1", "b": "2", "c": "3"}, map[string]string{"a": "1"})) + assert.True(t, utils.MapContains(map[string]string{"a": "1", "b": "2", "c": "3"}, map[string]string{"b": "2", "c": "3"})) + assert.False(t, utils.MapContains(map[string]string{"a": "1", "b": "2"}, map[string]string{"c": "3"})) + assert.False(t, utils.MapContains(map[string]string{"a": "1", "b": "2"}, map[string]string{"a": "4"})) +} + +func TestMapUpdate(t *testing.T) { + tcs := []struct { + m1 map[string]any + m2 map[string]any + updated map[string]any + }{ + { + map[string]any{}, + map[string]any{}, + map[string]any{}, + }, + { + map[string]any{"a": "1", "b": "2"}, + map[string]any{"b": 5, "c": "3"}, + map[string]any{"a": "1", "b": 5, "c": "3"}, + }, + { + map[string]any{"a": "1", "b": "2", "c": "3"}, + map[string]any{"b": 0, "c": ""}, // delete by zero value + map[string]any{"a": "1"}, + }, + { + map[string]any{"a": "1"}, + map[string]any{"c": ""}, // delete but doesn't exist in m1 so noop + map[string]any{"a": "1"}, + }, + } + + for _, tc := range tcs { + utils.MapUpdate(tc.m1, tc.m2) + assert.Equal(t, tc.updated, tc.m1) + } +} \ No newline at end of file