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