diff --git a/CHANGELOG.md b/CHANGELOG.md index 76172fe1c..a009b1748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +v8.3.19 (2023-09-14) +------------------------- + * Fix stop contact task name + +v8.3.18 (2023-09-14) +------------------------- + * Add support for optin/optout triggers and channel events + +v8.3.17 (2023-09-12) +------------------------- + * Fix not supporting channel events with extra with non-string values + * Update test database based on https://github.com/nyaruka/rapidpro/pull/4819 + +v8.3.16 (2023-09-12) +------------------------- + * Stop reading ContactURN.auth and remove from model + +v8.3.15 (2023-09-11) +------------------------- + * Start reading and writing ContactURN.auth_tokens + +v8.3.14 (2023-09-11) +------------------------- + * Remove support for delegate channels + +v8.3.13 (2023-09-11) +------------------------- + * Just noop if trying to sync an Android channel that doesn't have an FM ID + +v8.3.12 (2023-09-11) +------------------------- + * Remove encoding URN priority in URN strings as it's not used + * Remove having auth as a URN param + * Rework message sending so that URNs are loaded before queueing + * Update to latest null library and use Map[string] for channel events extra + v8.3.11 (2023-09-05) ------------------------- * Update to latest goflow diff --git a/Dockerfile b/Dockerfile index 8b6b4efce..0b7732d45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,20 @@ FROM golang:1.20 +# copy our dev certs into the container +# WORKDIR /usr/local/share/ca-certificates +# COPY ./rootCA.pem /usr/local/share/ca-certificates/rootCA.crt +# RUN /usr/sbin/update-ca-certificates + WORKDIR /usr/src/app # pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change COPY go.mod go.sum ./ RUN go mod download && go mod verify +# fetch our docs for our goflow version +RUN grep goflow go.mod | cut -d" " -f2 | cut -c2- > /tmp/goflow_version +RUN curl -L "https://github.com/nyaruka/goflow/releases/download/v`cat /tmp/goflow_version`/docs.tar.gz" | tar zxv + COPY . . RUN go build -v -o /usr/local/bin/app github.com/nyaruka/mailroom/cmd/mailroom diff --git a/core/models/channel_event.go b/core/models/channel_event.go index 8cb463fe4..f7f5d6d95 100644 --- a/core/models/channel_event.go +++ b/core/models/channel_event.go @@ -13,21 +13,25 @@ type ChannelEventID int64 // channel event types const ( - NewConversationEventType = ChannelEventType("new_conversation") - WelcomeMessageEventType = ChannelEventType("welcome_message") - ReferralEventType = ChannelEventType("referral") - MOMissEventType = ChannelEventType("mo_miss") - MOCallEventType = ChannelEventType("mo_call") - StopContactEventType = ChannelEventType("stop_contact") + EventTypeNewConversation ChannelEventType = "new_conversation" + EventTypeWelcomeMessage ChannelEventType = "welcome_message" + EventTypeReferral ChannelEventType = "referral" + EventTypeMissedCall ChannelEventType = "mo_miss" + EventTypeIncomingCall ChannelEventType = "mo_call" + EventTypeStopContact ChannelEventType = "stop_contact" + EventTypeOptIn ChannelEventType = "optin" + EventTypeOptOut ChannelEventType = "optout" ) // ContactSeenEvents are those which count as the contact having been seen var ContactSeenEvents = map[ChannelEventType]bool{ - NewConversationEventType: true, - ReferralEventType: true, - MOMissEventType: true, - MOCallEventType: true, - StopContactEventType: true, + EventTypeNewConversation: true, + EventTypeReferral: true, + EventTypeMissedCall: true, + EventTypeIncomingCall: true, + EventTypeStopContact: true, + EventTypeOptIn: true, + EventTypeOptOut: true, } // ChannelEvent represents an event that occurred associated with a channel, such as a referral, missed call, etc.. @@ -39,7 +43,7 @@ type ChannelEvent struct { ChannelID ChannelID `json:"channel_id" db:"channel_id"` ContactID ContactID `json:"contact_id" db:"contact_id"` URNID URNID `json:"urn_id" db:"contact_urn_id"` - Extra null.Map[string] `json:"extra" db:"extra"` + Extra null.Map[any] `json:"extra" db:"extra"` OccurredOn time.Time `json:"occurred_on" db:"occurred_on"` // only in JSON representation @@ -50,14 +54,21 @@ type ChannelEvent struct { } } -func (e *ChannelEvent) ID() ChannelEventID { return e.e.ID } -func (e *ChannelEvent) ContactID() ContactID { return e.e.ContactID } -func (e *ChannelEvent) URNID() URNID { return e.e.URNID } -func (e *ChannelEvent) OrgID() OrgID { return e.e.OrgID } -func (e *ChannelEvent) ChannelID() ChannelID { return e.e.ChannelID } -func (e *ChannelEvent) IsNewContact() bool { return e.e.NewContact } -func (e *ChannelEvent) OccurredOn() time.Time { return e.e.OccurredOn } -func (e *ChannelEvent) Extra() map[string]string { return e.e.Extra } +func (e *ChannelEvent) ID() ChannelEventID { return e.e.ID } +func (e *ChannelEvent) ContactID() ContactID { return e.e.ContactID } +func (e *ChannelEvent) URNID() URNID { return e.e.URNID } +func (e *ChannelEvent) OrgID() OrgID { return e.e.OrgID } +func (e *ChannelEvent) ChannelID() ChannelID { return e.e.ChannelID } +func (e *ChannelEvent) IsNewContact() bool { return e.e.NewContact } +func (e *ChannelEvent) OccurredOn() time.Time { return e.e.OccurredOn } +func (e *ChannelEvent) Extra() map[string]any { return e.e.Extra } +func (e *ChannelEvent) ExtraString(key string) string { + asStr, ok := e.e.Extra[key].(string) + if ok { + return asStr + } + return "" +} // MarshalJSON is our custom marshaller so that our inner struct get output func (e *ChannelEvent) MarshalJSON() ([]byte, error) { @@ -81,7 +92,7 @@ func (e *ChannelEvent) Insert(ctx context.Context, db DBorTx) error { } // NewChannelEvent creates a new channel event for the passed in parameters, returning it -func NewChannelEvent(eventType ChannelEventType, orgID OrgID, channelID ChannelID, contactID ContactID, urnID URNID, extra map[string]string, isNewContact bool) *ChannelEvent { +func NewChannelEvent(eventType ChannelEventType, orgID OrgID, channelID ChannelID, contactID ContactID, urnID URNID, extra map[string]any, isNewContact bool) *ChannelEvent { event := &ChannelEvent{} e := &event.e @@ -93,9 +104,9 @@ func NewChannelEvent(eventType ChannelEventType, orgID OrgID, channelID ChannelI e.NewContact = isNewContact if extra == nil { - e.Extra = null.Map[string]{} + e.Extra = null.Map[any]{} } else { - e.Extra = null.Map[string](extra) + e.Extra = null.Map[any](extra) } now := time.Now() diff --git a/core/models/channel_event_test.go b/core/models/channel_event_test.go index a3b019160..586f34d82 100644 --- a/core/models/channel_event_test.go +++ b/core/models/channel_event_test.go @@ -20,19 +20,21 @@ func TestChannelEvents(t *testing.T) { start := time.Now() // no extra - e := models.NewChannelEvent(models.MOMissEventType, testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Cathy.ID, testdata.Cathy.URNID, nil, false) + e := models.NewChannelEvent(models.EventTypeMissedCall, testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Cathy.ID, testdata.Cathy.URNID, nil, false) err := e.Insert(ctx, rt.DB) assert.NoError(t, err) assert.NotZero(t, e.ID()) - assert.Equal(t, map[string]string{}, e.Extra()) + assert.Equal(t, map[string]any{}, e.Extra()) assert.True(t, e.OccurredOn().After(start)) // with extra - e2 := models.NewChannelEvent(models.MOMissEventType, testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Cathy.ID, testdata.Cathy.URNID, map[string]string{"referral_id": "foobar"}, false) + e2 := models.NewChannelEvent(models.EventTypeMissedCall, testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Cathy.ID, testdata.Cathy.URNID, map[string]any{"referral_id": "foobar"}, false) err = e2.Insert(ctx, rt.DB) assert.NoError(t, err) assert.NotZero(t, e2.ID()) - assert.Equal(t, map[string]string{"referral_id": "foobar"}, e2.Extra()) + assert.Equal(t, map[string]any{"referral_id": "foobar"}, e2.Extra()) + assert.Equal(t, "foobar", e2.ExtraString("referral_id")) + assert.Equal(t, "", e2.ExtraString("invalid")) asJSON, err := json.Marshal(e2) assert.NoError(t, err) diff --git a/core/models/channels.go b/core/models/channels.go index 118f79a71..dfa366bb8 100644 --- a/core/models/channels.go +++ b/core/models/channels.go @@ -42,21 +42,20 @@ const ( type Channel struct { // inner struct for privacy and so we don't collide with method names c struct { - ID ChannelID `json:"id"` - UUID assets.ChannelUUID `json:"uuid"` - OrgID OrgID `json:"org_id"` - Parent *assets.ChannelReference `json:"parent"` - Name string `json:"name"` - Address string `json:"address"` - ChannelType ChannelType `json:"channel_type"` - TPS int `json:"tps"` - Country null.String `json:"country"` - Schemes []string `json:"schemes"` - Roles []assets.ChannelRole `json:"roles"` - MatchPrefixes []string `json:"match_prefixes"` - AllowInternational bool `json:"allow_international"` - MachineDetection bool `json:"machine_detection"` - Config map[string]any `json:"config"` + ID ChannelID `json:"id"` + UUID assets.ChannelUUID `json:"uuid"` + OrgID OrgID `json:"org_id"` + Name string `json:"name"` + Address string `json:"address"` + ChannelType ChannelType `json:"channel_type"` + TPS int `json:"tps"` + Country null.String `json:"country"` + Schemes []string `json:"schemes"` + Roles []assets.ChannelRole `json:"roles"` + MatchPrefixes []string `json:"match_prefixes"` + AllowInternational bool `json:"allow_international"` + MachineDetection bool `json:"machine_detection"` + Config map[string]any `json:"config"` } } @@ -99,9 +98,6 @@ func (c *Channel) AllowInternational() bool { return c.c.AllowInternational } // MachineDetection returns whether this channel should do answering machine detection (only applies to IVR) func (c *Channel) MachineDetection() bool { return c.c.MachineDetection } -// Parent returns a reference to the parent channel of this channel (if any) -func (c *Channel) Parent() *assets.ChannelReference { return c.c.Parent } - // Config returns the config for this channel func (c *Channel) Config() map[string]any { return c.c.Config } @@ -197,7 +193,6 @@ SELECT ROW_TO_JSON(r) FROM (SELECT c.id as id, c.uuid as uuid, c.org_id as org_id, - (SELECT ROW_TO_JSON(p) FROM (SELECT uuid, name FROM channels_channel cc where cc.id = c.parent_id) p) as parent, c.name as name, c.channel_type as channel_type, COALESCE(c.tps, 10) as tps, diff --git a/core/models/channels_test.go b/core/models/channels_test.go index 6400330ea..3152d4f2b 100644 --- a/core/models/channels_test.go +++ b/core/models/channels_test.go @@ -19,9 +19,6 @@ func TestChannels(t *testing.T) { // add some tel specific config to channel 2 rt.DB.MustExec(`UPDATE channels_channel SET config = '{"matching_prefixes": ["250", "251"], "allow_international": true}' WHERE id = $1`, testdata.VonageChannel.ID) - // make twitter channel have a parent of twilio channel - rt.DB.MustExec(`UPDATE channels_channel SET parent_id = $1 WHERE id = $2`, testdata.TwilioChannel.ID, testdata.TwitterChannel.ID) - oa, err := models.GetOrgAssetsWithRefresh(ctx, rt, 1, models.RefreshChannels) require.NoError(t, err) @@ -37,7 +34,6 @@ func TestChannels(t *testing.T) { Roles []assets.ChannelRole Prefixes []string AllowInternational bool - Parent *assets.ChannelReference }{ { testdata.TwilioChannel.ID, @@ -48,7 +44,6 @@ func TestChannels(t *testing.T) { []assets.ChannelRole{"send", "receive", "call", "answer"}, nil, false, - nil, }, { testdata.VonageChannel.ID, @@ -59,7 +54,6 @@ func TestChannels(t *testing.T) { []assets.ChannelRole{"send", "receive"}, []string{"250", "251"}, true, - nil, }, { testdata.TwitterChannel.ID, @@ -70,7 +64,6 @@ func TestChannels(t *testing.T) { []assets.ChannelRole{"send", "receive"}, nil, false, - assets.NewChannelReference(testdata.TwilioChannel.UUID, "Twilio"), }, } @@ -85,6 +78,5 @@ func TestChannels(t *testing.T) { assert.Equal(t, tc.Schemes, channel.Schemes()) assert.Equal(t, tc.Prefixes, channel.MatchPrefixes()) assert.Equal(t, tc.AllowInternational, channel.AllowInternational()) - assert.Equal(t, tc.Parent, channel.Parent()) } } diff --git a/core/models/contacts.go b/core/models/contacts.go index a84a47afb..4c034f761 100644 --- a/core/models/contacts.go +++ b/core/models/contacts.go @@ -417,16 +417,16 @@ func queryContactIDs(ctx context.Context, db Queryer, query string, args ...any) } type ContactURN struct { - ID URNID `json:"id" db:"id"` - OrgID OrgID ` db:"org_id"` - ContactID ContactID ` db:"contact_id"` - Priority int ` db:"priority"` - Identity urns.URN `json:"identity" db:"identity"` - Scheme string `json:"scheme" db:"scheme"` - Path string `json:"path" db:"path"` - Display null.String `json:"display" db:"display"` - Auth null.String `json:"auth" db:"auth"` - ChannelID ChannelID `json:"channel_id" db:"channel_id"` + ID URNID `json:"id" db:"id"` + OrgID OrgID ` db:"org_id"` + ContactID ContactID ` db:"contact_id"` + Priority int ` db:"priority"` + Identity urns.URN `json:"identity" db:"identity"` + Scheme string `json:"scheme" db:"scheme"` + Path string `json:"path" db:"path"` + Display null.String `json:"display" db:"display"` + AuthTokens null.Map[string] `json:"auth_tokens" db:"auth_tokens"` + ChannelID ChannelID `json:"channel_id" db:"channel_id"` } // AsURN returns a full URN representation including the query parameters needed by goflow and mailroom @@ -871,7 +871,7 @@ func insertContactAndURNs(ctx context.Context, db DBorTx, orgID OrgID, userID Us func URNForURN(ctx context.Context, db Queryer, oa *OrgAssets, u urns.URN) (urns.URN, error) { urn := &ContactURN{} rows, err := db.QueryContext(ctx, - `SELECT row_to_json(r) FROM (SELECT id, scheme, path, display, auth, channel_id, priority FROM contacts_contacturn WHERE identity = $1 AND org_id = $2) r;`, + `SELECT row_to_json(r) FROM (SELECT id, scheme, path, display, auth_tokens, channel_id, priority FROM contacts_contacturn WHERE identity = $1 AND org_id = $2) r;`, u.Identity(), oa.OrgID(), ) if err != nil { @@ -931,7 +931,7 @@ func GetOrCreateURN(ctx context.Context, db DBorTx, oa *OrgAssets, contactID Con func URNForID(ctx context.Context, db Queryer, oa *OrgAssets, urnID URNID) (urns.URN, error) { urn := &ContactURN{} rows, err := db.QueryContext(ctx, - `SELECT row_to_json(r) FROM (SELECT id, scheme, path, display, auth, channel_id, priority FROM contacts_contacturn WHERE id = $1) r;`, + `SELECT row_to_json(r) FROM (SELECT id, scheme, path, display, auth_tokens, channel_id, priority FROM contacts_contacturn WHERE id = $1) r;`, urnID, ) if err != nil { @@ -1090,7 +1090,7 @@ UPDATE contacts_contact WHERE id = $1` const sqlSelectURNsByID = ` -SELECT id, org_id, contact_id, identity, priority, scheme, path, display, auth, channel_id +SELECT id, org_id, contact_id, identity, priority, scheme, path, display, auth_tokens, channel_id FROM contacts_contacturn WHERE id = ANY($1)` @@ -1316,8 +1316,8 @@ WHERE const sqlInsertContactURN = ` INSERT INTO - contacts_contacturn(contact_id, identity, path, display, auth, scheme, priority, org_id) - VALUES(:contact_id, :identity, :path, :display, :auth, :scheme, :priority, :org_id) + contacts_contacturn(contact_id, identity, path, display, auth_tokens, scheme, priority, org_id) + VALUES(:contact_id, :identity, :path, :display, :auth_tokens, :scheme, :priority, :org_id) ON CONFLICT(identity, org_id) DO UPDATE diff --git a/core/models/triggers.go b/core/models/triggers.go index 660d3df96..3d377e569 100644 --- a/core/models/triggers.go +++ b/core/models/triggers.go @@ -36,6 +36,8 @@ const ( IncomingCallTriggerType = TriggerType("V") ScheduleTriggerType = TriggerType("S") TicketClosedTriggerType = TriggerType("T") + OptInTriggerType = TriggerType("I") + OptOutTriggerType = TriggerType("O") ) // match type constants @@ -167,6 +169,20 @@ func FindMatchingNewConversationTrigger(oa *OrgAssets, channel *Channel) *Trigge return findBestTriggerMatch(candidates, channel, nil) } +// FindMatchingOptInTrigger finds the best match trigger for optin channel events +func FindMatchingOptInTrigger(oa *OrgAssets, channel *Channel) *Trigger { + candidates := findTriggerCandidates(oa, OptInTriggerType, nil) + + return findBestTriggerMatch(candidates, channel, nil) +} + +// FindMatchingOptOutTrigger finds the best match trigger for optout channel events +func FindMatchingOptOutTrigger(oa *OrgAssets, channel *Channel) *Trigger { + candidates := findTriggerCandidates(oa, OptOutTriggerType, nil) + + return findBestTriggerMatch(candidates, channel, nil) +} + // FindMatchingReferralTrigger finds the best match trigger for referral click channel events func FindMatchingReferralTrigger(oa *OrgAssets, channel *Channel, referrerID string) *Trigger { // first try to find matching referrer ID diff --git a/core/models/triggers_test.go b/core/models/triggers_test.go index 38ad30227..321c2e4e0 100644 --- a/core/models/triggers_test.go +++ b/core/models/triggers_test.go @@ -207,7 +207,7 @@ func TestFindMatchingIncomingCallTrigger(t *testing.T) { func TestFindMatchingMissedCallTrigger(t *testing.T) { ctx, rt := testsuite.Runtime() - defer testsuite.Reset(testsuite.ResetAll) + defer testsuite.Reset(testsuite.ResetData) testdata.InsertCatchallTrigger(rt, testdata.Org1, testdata.SingleMessage, nil, nil) @@ -230,7 +230,7 @@ func TestFindMatchingMissedCallTrigger(t *testing.T) { func TestFindMatchingNewConversationTrigger(t *testing.T) { ctx, rt := testsuite.Runtime() - defer testsuite.Reset(testsuite.ResetAll) + defer testsuite.Reset(testsuite.ResetData) twilioTriggerID := testdata.InsertNewConversationTrigger(rt, testdata.Org1, testdata.Favorites, testdata.TwilioChannel) noChTriggerID := testdata.InsertNewConversationTrigger(rt, testdata.Org1, testdata.Favorites, nil) @@ -257,7 +257,7 @@ func TestFindMatchingNewConversationTrigger(t *testing.T) { func TestFindMatchingReferralTrigger(t *testing.T) { ctx, rt := testsuite.Runtime() - defer testsuite.Reset(testsuite.ResetAll) + defer testsuite.Reset(testsuite.ResetData) fooID := testdata.InsertReferralTrigger(rt, testdata.Org1, testdata.Favorites, "foo", testdata.TwitterChannel) barID := testdata.InsertReferralTrigger(rt, testdata.Org1, testdata.Favorites, "bar", nil) @@ -289,6 +289,60 @@ func TestFindMatchingReferralTrigger(t *testing.T) { } } +func TestFindMatchingOptInTrigger(t *testing.T) { + ctx, rt := testsuite.Runtime() + + defer testsuite.Reset(testsuite.ResetData) + + twilioTriggerID := testdata.InsertOptInTrigger(rt, testdata.Org1, testdata.Favorites, testdata.TwilioChannel) + noChTriggerID := testdata.InsertOptInTrigger(rt, testdata.Org1, testdata.Favorites, nil) + + oa, err := models.GetOrgAssetsWithRefresh(ctx, rt, testdata.Org1.ID, models.RefreshTriggers) + require.NoError(t, err) + + tcs := []struct { + channelID models.ChannelID + expectedTriggerID models.TriggerID + }{ + {testdata.TwilioChannel.ID, twilioTriggerID}, + {testdata.VonageChannel.ID, noChTriggerID}, + } + + for i, tc := range tcs { + channel := oa.ChannelByID(tc.channelID) + trigger := models.FindMatchingOptInTrigger(oa, channel) + + assertTrigger(t, tc.expectedTriggerID, trigger, "trigger mismatch in test case #%d", i) + } +} + +func TestFindMatchingOptOutTrigger(t *testing.T) { + ctx, rt := testsuite.Runtime() + + defer testsuite.Reset(testsuite.ResetData) + + twilioTriggerID := testdata.InsertOptOutTrigger(rt, testdata.Org1, testdata.Favorites, testdata.TwilioChannel) + noChTriggerID := testdata.InsertOptOutTrigger(rt, testdata.Org1, testdata.Favorites, nil) + + oa, err := models.GetOrgAssetsWithRefresh(ctx, rt, testdata.Org1.ID, models.RefreshTriggers) + require.NoError(t, err) + + tcs := []struct { + channelID models.ChannelID + expectedTriggerID models.TriggerID + }{ + {testdata.TwilioChannel.ID, twilioTriggerID}, + {testdata.VonageChannel.ID, noChTriggerID}, + } + + for i, tc := range tcs { + channel := oa.ChannelByID(tc.channelID) + trigger := models.FindMatchingOptOutTrigger(oa, channel) + + assertTrigger(t, tc.expectedTriggerID, trigger, "trigger mismatch in test case #%d", i) + } +} + func TestArchiveContactTriggers(t *testing.T) { ctx, rt := testsuite.Runtime() diff --git a/core/msgio/android.go b/core/msgio/android.go index bc329b658..75cdde97a 100644 --- a/core/msgio/android.go +++ b/core/msgio/android.go @@ -21,7 +21,7 @@ func SyncAndroidChannel(fc *fcm.Client, channel *models.Channel) error { // no FCM ID for this channel, noop, we can't trigger a sync fcmID := channel.ConfigValue(models.ChannelConfigFCMID, "") if fcmID == "" { - return errors.New("channel has no FCM ID") + return nil } sync := &fcm.Message{ diff --git a/core/msgio/android_test.go b/core/msgio/android_test.go index 9ce7fba24..63eaf3e6a 100644 --- a/core/msgio/android_test.go +++ b/core/msgio/android_test.go @@ -84,7 +84,7 @@ func TestSyncAndroidChannel(t *testing.T) { err = msgio.SyncAndroidChannel(nil, channel1) assert.EqualError(t, err, "instance has no FCM configuration") err = msgio.SyncAndroidChannel(fc, channel1) - assert.EqualError(t, err, "channel has no FCM ID") + assert.NoError(t, err) err = msgio.SyncAndroidChannel(fc, channel2) assert.EqualError(t, err, "error syncing channel: 401 error: 401 Unauthorized") err = msgio.SyncAndroidChannel(fc, channel3) diff --git a/core/msgio/courier.go b/core/msgio/courier.go index 62e841b19..e180f1ef0 100644 --- a/core/msgio/courier.go +++ b/core/msgio/courier.go @@ -89,7 +89,7 @@ func NewCourierMsg(oa *models.OrgAssets, m *models.Msg, u *models.ContactURN, ch ContactURNID: *m.ContactURNID(), ChannelUUID: ch.UUID(), URN: u.Identity, - URNAuth: string(u.Auth), + URNAuth: string(u.AuthTokens["default"]), Metadata: m.Metadata(), IsResend: m.IsResend, } diff --git a/core/tasks/handler/contact_tasks.go b/core/tasks/handler/contact_tasks.go index d83503909..35358c244 100644 --- a/core/tasks/handler/contact_tasks.go +++ b/core/tasks/handler/contact_tasks.go @@ -28,16 +28,11 @@ import ( ) const ( - MOMissEventType = string(models.MOMissEventType) - NewConversationEventType = "new_conversation" - WelcomeMessageEventType = "welcome_message" - ReferralEventType = "referral" - StopEventType = "stop_event" - MsgEventType = "msg_event" - ExpirationEventType = "expiration_event" - TimeoutEventType = "timeout_event" - TicketClosedEventType = "ticket_closed" - MsgDeletedType = "msg_deleted" + MsgEventType = "msg_event" + ExpirationEventType = "expiration_event" + TimeoutEventType = "timeout_event" + TicketClosedEventType = "ticket_closed" + MsgDeletedType = "msg_deleted" ) // handleTimedEvent is called for timeout events @@ -184,22 +179,20 @@ func HandleChannelEvent(ctx context.Context, rt *runtime.Runtime, eventType mode var trigger *models.Trigger switch eventType { - - case models.NewConversationEventType: + case models.EventTypeNewConversation: trigger = models.FindMatchingNewConversationTrigger(oa, channel) - - case models.ReferralEventType: - trigger = models.FindMatchingReferralTrigger(oa, channel, event.Extra()["referrer_id"]) - - case models.MOMissEventType: + case models.EventTypeReferral: + trigger = models.FindMatchingReferralTrigger(oa, channel, event.ExtraString("referrer_id")) + case models.EventTypeMissedCall: trigger = models.FindMatchingMissedCallTrigger(oa) - - case models.MOCallEventType: + case models.EventTypeIncomingCall: trigger = models.FindMatchingIncomingCallTrigger(oa, contact) - - case models.WelcomeMessageEventType: + case models.EventTypeOptIn: + trigger = models.FindMatchingOptInTrigger(oa, channel) + case models.EventTypeOptOut: + trigger = models.FindMatchingOptOutTrigger(oa, channel) + case models.EventTypeWelcomeMessage: trigger = nil - default: return nil, errors.Errorf("unknown channel event type: %s", eventType) } @@ -251,23 +244,18 @@ func HandleChannelEvent(ctx context.Context, rt *runtime.Runtime, eventType mode // build our flow trigger var flowTrigger flows.Trigger - switch eventType { - case models.NewConversationEventType, models.ReferralEventType, models.MOMissEventType: - flowTrigger = triggers.NewBuilder(oa.Env(), flow.Reference(), contact). - Channel(channel.ChannelReference(), triggers.ChannelEventType(eventType)). - WithParams(params). - Build() - - case models.MOCallEventType: + if eventType == models.EventTypeIncomingCall { urn := contacts[0].URNForID(event.URNID()) flowTrigger = triggers.NewBuilder(oa.Env(), flow.Reference(), contact). Channel(channel.ChannelReference(), triggers.ChannelEventTypeIncomingCall). WithCall(urn). Build() - - default: - return nil, errors.Errorf("unknown channel event type: %s", eventType) + } else { + flowTrigger = triggers.NewBuilder(oa.Env(), flow.Reference(), contact). + Channel(channel.ChannelReference(), triggers.ChannelEventType(eventType)). + WithParams(params). + Build() } // if we have a channel connection we set the connection on the session before our event hooks fire diff --git a/core/tasks/handler/handle_contact_event.go b/core/tasks/handler/handle_contact_event.go index ad6099ef2..61b17d184 100644 --- a/core/tasks/handler/handle_contact_event.go +++ b/core/tasks/handler/handle_contact_event.go @@ -2,13 +2,13 @@ package handler import ( "context" - "encoding/json" "fmt" "time" "github.com/gomodule/redigo/redis" "github.com/nyaruka/gocommon/analytics" "github.com/nyaruka/gocommon/dbutil" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/core/queue" "github.com/nyaruka/mailroom/core/tasks" @@ -83,60 +83,39 @@ func (t *HandleContactEventTask) Perform(ctx context.Context, rt *runtime.Runtim // decode our event, this is a normal task at its top level contactEvent := &queue.Task{} - err = json.Unmarshal([]byte(event), contactEvent) - if err != nil { - return errors.Wrapf(err, "error unmarshalling contact event: %s", event) - } + jsonx.MustUnmarshal([]byte(event), contactEvent) // hand off to the appropriate handler switch contactEvent.Type { - case StopEventType: + case string(models.EventTypeStopContact), "stop_event": evt := &StopEvent{} - err = json.Unmarshal(contactEvent.Task, evt) - if err != nil { - return errors.Wrapf(err, "error unmarshalling stop event: %s", event) - } + jsonx.MustUnmarshal(contactEvent.Task, evt) err = handleStopEvent(ctx, rt, evt) - case NewConversationEventType, ReferralEventType, MOMissEventType, WelcomeMessageEventType: + case string(models.EventTypeNewConversation), string(models.EventTypeReferral), string(models.EventTypeMissedCall), string(models.EventTypeWelcomeMessage), string(models.EventTypeOptIn), string(models.EventTypeOptOut): evt := &models.ChannelEvent{} - err = json.Unmarshal(contactEvent.Task, evt) - if err != nil { - return errors.Wrapf(err, "error unmarshalling channel event: %s", event) - } + jsonx.MustUnmarshal(contactEvent.Task, evt) _, err = HandleChannelEvent(ctx, rt, models.ChannelEventType(contactEvent.Type), evt, nil) case MsgEventType: msg := &MsgEvent{} - err = json.Unmarshal(contactEvent.Task, msg) - if err != nil { - return errors.Wrapf(err, "error unmarshalling msg event: %s", event) - } + jsonx.MustUnmarshal(contactEvent.Task, msg) err = handleMsgEvent(ctx, rt, msg) case TicketClosedEventType: evt := &models.TicketEvent{} - err = json.Unmarshal(contactEvent.Task, evt) - if err != nil { - return errors.Wrapf(err, "error unmarshalling ticket event: %s", event) - } + jsonx.MustUnmarshal(contactEvent.Task, evt) err = handleTicketEvent(ctx, rt, evt) case TimeoutEventType, ExpirationEventType: evt := &TimedEvent{} - err = json.Unmarshal(contactEvent.Task, evt) - if err != nil { - return errors.Wrapf(err, "error unmarshalling timeout event: %s", event) - } + jsonx.MustUnmarshal(contactEvent.Task, evt) err = handleTimedEvent(ctx, rt, contactEvent.Type, evt) case MsgDeletedType: evt := &MsgDeletedEvent{} - err = json.Unmarshal(contactEvent.Task, evt) - if err != nil { - return errors.Wrapf(err, "error unmarshalling deleted event: %s", event) - } + jsonx.MustUnmarshal(contactEvent.Task, evt) err = handleMsgDeletedEvent(ctx, rt, evt) default: diff --git a/core/tasks/handler/handle_contact_event_test.go b/core/tasks/handler/handle_contact_event_test.go index 709dc1186..6372b60ac 100644 --- a/core/tasks/handler/handle_contact_event_test.go +++ b/core/tasks/handler/handle_contact_event_test.go @@ -417,6 +417,8 @@ func TestChannelEvents(t *testing.T) { // add some channel event triggers testdata.InsertNewConversationTrigger(rt, testdata.Org1, testdata.Favorites, testdata.TwitterChannel) testdata.InsertReferralTrigger(rt, testdata.Org1, testdata.PickANumber, "", testdata.VonageChannel) + testdata.InsertOptInTrigger(rt, testdata.Org1, testdata.Favorites, testdata.VonageChannel) + testdata.InsertOptOutTrigger(rt, testdata.Org1, testdata.PickANumber, testdata.VonageChannel) // add a URN for cathy so we can test twitter URNs testdata.InsertContactURN(rt, testdata.Org1, testdata.Bob, urns.URN("twitterid:123456"), 10) @@ -427,15 +429,17 @@ func TestChannelEvents(t *testing.T) { URNID models.URNID OrgID models.OrgID ChannelID models.ChannelID - Extra map[string]string + Extra map[string]any Response string UpdateLastSeen bool }{ - {handler.NewConversationEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.TwitterChannel.ID, nil, "What is your favorite color?", true}, - {handler.NewConversationEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "", true}, - {handler.WelcomeMessageEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "", false}, - {handler.ReferralEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.TwitterChannel.ID, nil, "", true}, - {handler.ReferralEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "Pick a number between 1-10.", true}, + {models.EventTypeNewConversation, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.TwitterChannel.ID, nil, "What is your favorite color?", true}, + {models.EventTypeNewConversation, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "", true}, + {models.EventTypeWelcomeMessage, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "", false}, + {models.EventTypeReferral, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.TwitterChannel.ID, nil, "", true}, + {models.EventTypeReferral, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "Pick a number between 1-10.", true}, + {models.EventTypeOptIn, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, map[string]any{"optin_name": "Updates"}, "What is your favorite color?", true}, + {models.EventTypeOptOut, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, map[string]any{"optin_name": "Updates"}, "Pick a number between 1-10.", true}, } models.FlushCache() @@ -523,7 +527,7 @@ func TestStopEvent(t *testing.T) { eventJSON, err := json.Marshal(event) require.NoError(t, err) task := &queue.Task{ - Type: handler.StopEventType, + Type: string(models.EventTypeStopContact), OrgID: int(testdata.Org1.ID), Task: eventJSON, } diff --git a/go.mod b/go.mod index d9a192472..3949a4313 100644 --- a/go.mod +++ b/go.mod @@ -16,8 +16,8 @@ require ( github.com/jmoiron/sqlx v1.3.5 github.com/lib/pq v1.10.9 github.com/nyaruka/ezconf v0.2.1 - github.com/nyaruka/gocommon v1.41.1 - github.com/nyaruka/goflow v0.193.1 + github.com/nyaruka/gocommon v1.41.2 + github.com/nyaruka/goflow v0.194.0 github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d github.com/nyaruka/null/v3 v3.0.0 github.com/nyaruka/redisx v0.5.0 diff --git a/go.sum b/go.sum index bdce05397..c4dcf82de 100644 --- a/go.sum +++ b/go.sum @@ -82,10 +82,10 @@ 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/gocommon v1.41.1 h1:SpIXqLCBF3Un/AjzIiqC/DO4jU7Zt7SyDh/t9SyjIrQ= -github.com/nyaruka/gocommon v1.41.1/go.mod h1:cJ2XmEX+FDOzBvE19IW+hG8EFVsSrNgCp7NrxAlP4Xg= -github.com/nyaruka/goflow v0.193.1 h1:CDkf5DP4pT432LeKkJ6lnwNGWPfskWJyLKn4guysXf0= -github.com/nyaruka/goflow v0.193.1/go.mod h1:RAH+Mpv+oaKdmcC3hceeiQ9fKV9enONY0y5b8LJZcdU= +github.com/nyaruka/gocommon v1.41.2 h1:eGHOJMu9VZhnHri7XzQQax/sW5SSGS2qwS1ORt6idEo= +github.com/nyaruka/gocommon v1.41.2/go.mod h1:cJ2XmEX+FDOzBvE19IW+hG8EFVsSrNgCp7NrxAlP4Xg= +github.com/nyaruka/goflow v0.194.0 h1:7LV/f/IiGe5YXAASy10JhH4Jho6i1IYyp2xfyDaLjhE= +github.com/nyaruka/goflow v0.194.0/go.mod h1:ZC+XSZMA+R1HV3C7mfcrEYUb/SyF5BnjnonRBEPA8gM= 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/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc= diff --git a/mailroom_test.dump b/mailroom_test.dump index 72896805b..b5628074e 100644 Binary files a/mailroom_test.dump and b/mailroom_test.dump differ diff --git a/testsuite/testdata/triggers.go b/testsuite/testdata/triggers.go index ff1414a32..1f323c04b 100644 --- a/testsuite/testdata/triggers.go +++ b/testsuite/testdata/triggers.go @@ -21,6 +21,14 @@ func InsertNewConversationTrigger(rt *runtime.Runtime, org *Org, flow *Flow, cha return insertTrigger(rt, org, models.NewConversationTriggerType, flow, "", "", nil, nil, nil, "", channel) } +func InsertOptInTrigger(rt *runtime.Runtime, org *Org, flow *Flow, channel *Channel) models.TriggerID { + return insertTrigger(rt, org, models.OptInTriggerType, flow, "", "", nil, nil, nil, "", channel) +} + +func InsertOptOutTrigger(rt *runtime.Runtime, org *Org, flow *Flow, channel *Channel) models.TriggerID { + return insertTrigger(rt, org, models.OptOutTriggerType, flow, "", "", nil, nil, nil, "", channel) +} + func InsertReferralTrigger(rt *runtime.Runtime, org *Org, flow *Flow, referrerID string, channel *Channel) models.TriggerID { return insertTrigger(rt, org, models.ReferralTriggerType, flow, "", "", nil, nil, nil, referrerID, channel) } diff --git a/testsuite/testsuite.go b/testsuite/testsuite.go index db2aa6b63..31f63c4af 100644 --- a/testsuite/testsuite.go +++ b/testsuite/testsuite.go @@ -250,6 +250,7 @@ DELETE FROM tickets_ticketevent; DELETE FROM tickets_ticket; DELETE FROM triggers_trigger_contacts WHERE trigger_id >= 30000; DELETE FROM triggers_trigger_groups WHERE trigger_id >= 30000; +DELETE FROM triggers_trigger_exclude_groups WHERE trigger_id >= 30000; DELETE FROM triggers_trigger WHERE id >= 30000; DELETE FROM channels_channelcount; DELETE FROM msgs_msg; diff --git a/web/ivr/ivr.go b/web/ivr/ivr.go index b32d9ae41..b12ed5395 100644 --- a/web/ivr/ivr.go +++ b/web/ivr/ivr.go @@ -116,7 +116,7 @@ func handleIncoming(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAsse } // we first create an incoming call channel event and see if that matches - event := models.NewChannelEvent(models.MOCallEventType, oa.OrgID(), ch.ID(), contact.ID(), urnID, nil, false) + event := models.NewChannelEvent(models.EventTypeIncomingCall, oa.OrgID(), ch.ID(), contact.ID(), urnID, nil, false) externalID, err := svc.CallIDForRequest(r) if err != nil { @@ -130,7 +130,7 @@ func handleIncoming(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAsse } // try to handle this event - session, err := handler.HandleChannelEvent(ctx, rt, models.MOCallEventType, event, call) + session, err := handler.HandleChannelEvent(ctx, rt, models.EventTypeIncomingCall, event, call) if err != nil { logrus.WithError(err).WithField("http_request", r).Error("error handling incoming call")