diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27e13c94d..a57ba8e3f 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.20.x" jobs: test: name: Test diff --git a/assets/channel.go b/assets/channel.go index f9f08e5a9..2977418f7 100644 --- a/assets/channel.go +++ b/assets/channel.go @@ -22,6 +22,13 @@ const ( ChannelRoleUSSD ChannelRole = "ussd" ) +// ChannelFeature is a feature that a channel supports +type ChannelFeature string + +const ( + ChannelFeatureOptIns ChannelFeature = "optins" +) + // Channel is something that can send/receive messages. // // { @@ -40,6 +47,7 @@ type Channel interface { Address() string Schemes() []string Roles() []ChannelRole + Features() []ChannelFeature Country() i18n.Country MatchPrefixes() []string AllowInternational() bool diff --git a/assets/static/channel.go b/assets/static/channel.go index 2b9e586e5..ec8af243f 100644 --- a/assets/static/channel.go +++ b/assets/static/channel.go @@ -8,24 +8,26 @@ import ( // Channel is a JSON serializable implementation of a channel asset type Channel struct { - UUID_ assets.ChannelUUID `json:"uuid" validate:"required,uuid"` - Name_ string `json:"name"` - Address_ string `json:"address"` - Schemes_ []string `json:"schemes" validate:"min=1"` - Roles_ []assets.ChannelRole `json:"roles" validate:"min=1,dive,eq=send|eq=receive|eq=call|eq=answer|eq=ussd"` - Country_ i18n.Country `json:"country,omitempty"` - MatchPrefixes_ []string `json:"match_prefixes,omitempty"` - AllowInternational_ bool `json:"allow_international,omitempty"` + UUID_ assets.ChannelUUID `json:"uuid" validate:"required,uuid"` + Name_ string `json:"name"` + Address_ string `json:"address"` + Schemes_ []string `json:"schemes" validate:"min=1"` + Roles_ []assets.ChannelRole `json:"roles" validate:"min=1,dive,eq=send|eq=receive|eq=call|eq=answer|eq=ussd"` + Features_ []assets.ChannelFeature `json:"features,omitempty"` + Country_ i18n.Country `json:"country,omitempty"` + MatchPrefixes_ []string `json:"match_prefixes,omitempty"` + AllowInternational_ bool `json:"allow_international,omitempty"` } // NewChannel creates a new channel -func NewChannel(uuid assets.ChannelUUID, name string, address string, schemes []string, roles []assets.ChannelRole) assets.Channel { +func NewChannel(uuid assets.ChannelUUID, name string, address string, schemes []string, roles []assets.ChannelRole, features []assets.ChannelFeature) assets.Channel { return &Channel{ UUID_: uuid, Name_: name, Address_: address, Schemes_: schemes, Roles_: roles, + Features_: features, AllowInternational_: true, } } @@ -38,6 +40,7 @@ func NewTelChannel(uuid assets.ChannelUUID, name string, address string, roles [ Address_: address, Schemes_: []string{urns.TelScheme}, Roles_: roles, + Features_: []assets.ChannelFeature{}, Country_: country, MatchPrefixes_: matchPrefixes, AllowInternational_: allowInternational, @@ -59,6 +62,9 @@ func (c *Channel) Schemes() []string { return c.Schemes_ } // Roles returns the roles of this channel func (c *Channel) Roles() []assets.ChannelRole { return c.Roles_ } +// Features returnsthe featurs this channel supports +func (c *Channel) Features() []assets.ChannelFeature { return c.Features_ } + // Country returns this channel's associated country code (if any) func (c *Channel) Country() i18n.Country { return c.Country_ } diff --git a/assets/static/channel_test.go b/assets/static/channel_test.go index cb2c9d45b..b3c3cf6b2 100644 --- a/assets/static/channel_test.go +++ b/assets/static/channel_test.go @@ -18,12 +18,14 @@ func TestChannel(t *testing.T) { "+234151", []string{"tel"}, []assets.ChannelRole{assets.ChannelRoleSend}, + []assets.ChannelFeature{assets.ChannelFeatureOptIns}, ) assert.Equal(t, assets.ChannelUUID("ffffffff-9b24-92e1-ffff-ffffb207cdb4"), channel.UUID()) assert.Equal(t, "Android", channel.Name()) assert.Equal(t, "+234151", channel.Address()) assert.Equal(t, []string{"tel"}, channel.Schemes()) assert.Equal(t, []assets.ChannelRole{assets.ChannelRoleSend}, channel.Roles()) + assert.Equal(t, []assets.ChannelFeature{assets.ChannelFeatureOptIns}, channel.Features()) assert.Equal(t, i18n.NilCountry, channel.Country()) assert.Nil(t, channel.MatchPrefixes()) assert.True(t, channel.AllowInternational()) diff --git a/flows/actions/base_test.go b/flows/actions/base_test.go index 9d3ef7426..659c6a684 100644 --- a/flows/actions/base_test.go +++ b/flows/actions/base_test.go @@ -37,12 +37,15 @@ import ( "github.com/stretchr/testify/require" ) -var contactJSON = `{ +var defaultContactJSON = []byte(`{ "uuid": "5d76d86b-3bb9-4d5a-b822-c9d86f5d8e4f", "name": "Ryan Lewis", "language": "eng", "timezone": "America/Guayaquil", - "urns": [], + "urns": [ + "tel:+12065551212?channel=57f1078f-88aa-46f4-a59a-948a5739c03d&id=123", + "twitterid:54784326227#nyaruka" + ], "groups": [ {"uuid": "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d", "name": "Testers"}, {"uuid": "0ec97956-c451-48a0-a180-1ce766623e31", "name": "Males"} @@ -53,7 +56,7 @@ var contactJSON = `{ } }, "created_on": "2018-06-20T11:40:30.123456789-00:00" -}` +}`) func TestActionTypes(t *testing.T) { assetsJSON, err := os.ReadFile("testdata/_assets.json") @@ -81,7 +84,7 @@ func testActionType(t *testing.T, assetsJSON json.RawMessage, typeName string) { HTTPMocks *httpx.MockRequestor `json:"http_mocks,omitempty"` SMTPError string `json:"smtp_error,omitempty"` NoContact bool `json:"no_contact,omitempty"` - NoURNs bool `json:"no_urns,omitempty"` + Contact json.RawMessage `json:"contact,omitempty"` HasTicket bool `json:"has_ticket,omitempty"` NoInput bool `json:"no_input,omitempty"` RedactURNs bool `json:"redact_urns,omitempty"` @@ -164,15 +167,14 @@ func testActionType(t *testing.T, assetsJSON json.RawMessage, typeName string) { // optionally load our contact var contact *flows.Contact if !tc.NoContact { - contact, err = flows.ReadContact(sa, json.RawMessage(contactJSON), assets.PanicOnMissing) + contactJSON := defaultContactJSON + if tc.Contact != nil { + contactJSON = tc.Contact + } + + contact, err = flows.ReadContact(sa, contactJSON, assets.PanicOnMissing) require.NoError(t, err) - // optionally give our contact some URNs and a ticket - if !tc.NoURNs { - channel := sa.Channels().Get("57f1078f-88aa-46f4-a59a-948a5739c03d") - contact.AddURN(urns.URN("tel:+12065551212?channel=57f1078f-88aa-46f4-a59a-948a5739c03d&id=123"), channel) - contact.AddURN(urns.URN("twitterid:54784326227#nyaruka"), nil) - } if tc.HasTicket { ticketer := sa.Ticketers().Get("d605bb96-258d-4097-ad0a-080937db2212") topic := sa.Topics().Get("0d9a2c56-6fc2-4f27-93c5-a6322e26b740") @@ -568,6 +570,20 @@ func TestConstructors(t *testing.T) { "body": "So I was thinking..." }`, }, + { + actions.NewSendOptIn( + actionUUID, + assets.NewOptInReference("248be71d-78e9-4d71-a6c4-9981d369e5cb", "Joke Of The Day"), + ), + `{ + "type": "send_optin", + "uuid": "ad154980-7bf7-4ab8-8728-545fd6378912", + "optin": { + "uuid": "248be71d-78e9-4d71-a6c4-9981d369e5cb", + "name": "Joke Of The Day" + } + }`, + }, { actions.NewSendMsg( actionUUID, diff --git a/flows/actions/send_optin.go b/flows/actions/send_optin.go new file mode 100644 index 000000000..d44e31740 --- /dev/null +++ b/flows/actions/send_optin.go @@ -0,0 +1,55 @@ +package actions + +import ( + "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/goflow/flows/events" +) + +func init() { + registerType(TypeSendOptIn, func() flows.Action { return &SendOptInAction{} }) +} + +// TypeSendOptIn is the type for the send optin action +const TypeSendOptIn string = "send_optin" + +// SendOptInAction can be used to send an optin to the contact if the channel supports that. +// +// An [event:optin_sent] event will be created if the optin was sent. +// +// { +// "uuid": "8eebd020-1af5-431c-b943-aa670fc74da9", +// "type": "send_optin", +// "optin": { +// "uuid": "248be71d-78e9-4d71-a6c4-9981d369e5cb", +// "name": "Joke Of The Day" +// } +// } +// +// @action send_optin +type SendOptInAction struct { + baseAction + onlineAction + + OptIn *assets.OptInReference `json:"optin" validate:"required,dive"` +} + +// NewSendOptIn creates a new send optin action +func NewSendOptIn(uuid flows.ActionUUID, optIn *assets.OptInReference) *SendOptInAction { + return &SendOptInAction{ + baseAction: newBaseAction(TypeSendOptIn, uuid), + OptIn: optIn, + } +} + +// Execute creates the optin events +func (a *SendOptInAction) Execute(run flows.Run, step flows.Step, logModifier flows.ModifierCallback, logEvent flows.EventCallback) error { + optIn := run.Session().Assets().OptIns().Get(a.OptIn.UUID) + destinations := run.Contact().ResolveDestinations(false) + + if len(destinations) > 0 && destinations[0].Channel.HasFeature(assets.ChannelFeatureOptIns) { + logEvent(events.NewOptInSent(optIn)) + } + + return nil +} diff --git a/flows/actions/testdata/_assets.json b/flows/actions/testdata/_assets.json index e2db7c4ef..c7c2e8507 100644 --- a/flows/actions/testdata/_assets.json +++ b/flows/actions/testdata/_assets.json @@ -103,6 +103,21 @@ "receive" ] }, + { + "uuid": "4bb288a0-7fca-4da1-abe8-59a593aff648", + "name": "Facebook Channel", + "address": "235326346322111", + "schemes": [ + "facebook" + ], + "roles": [ + "send", + "receive" + ], + "features": [ + "optins" + ] + }, { "uuid": "8e21f093-99aa-413b-b55b-758b54308fcb", "name": "Twitter Channel", @@ -205,6 +220,12 @@ "name": "Spam" } ], + "optins": [ + { + "uuid": "248be71d-78e9-4d71-a6c4-9981d369e5cb", + "name": "Joke Of The Day" + } + ], "resthooks": [ { "slug": "new-registration", diff --git a/flows/actions/testdata/call_resthook.json b/flows/actions/testdata/call_resthook.json index 2063f2d82..a98f584d2 100644 --- a/flows/actions/testdata/call_resthook.json +++ b/flows/actions/testdata/call_resthook.json @@ -526,7 +526,16 @@ } ] }, - "no_urns": true, + "contact": { + "uuid": "5d76d86b-3bb9-4d5a-b822-c9d86f5d8e4f", + "name": "Ryan Lewis", + "language": "eng", + "timezone": "America/Guayaquil", + "urns": [], + "groups": [], + "fields": {}, + "created_on": "2018-06-20T11:40:30.123456789-00:00" + }, "no_input": true, "action": { "type": "call_resthook", diff --git a/flows/actions/testdata/send_msg.json b/flows/actions/testdata/send_msg.json index 9aca4c347..6a71f96a5 100644 --- a/flows/actions/testdata/send_msg.json +++ b/flows/actions/testdata/send_msg.json @@ -354,7 +354,16 @@ }, { "description": "Msg created event even if contact has no sendable URNs", - "no_urns": true, + "contact": { + "uuid": "5d76d86b-3bb9-4d5a-b822-c9d86f5d8e4f", + "name": "Ryan Lewis", + "language": "eng", + "timezone": "America/Guayaquil", + "urns": [], + "groups": [], + "fields": {}, + "created_on": "2018-06-20T11:40:30.123456789-00:00" + }, "action": { "type": "send_msg", "uuid": "ad154980-7bf7-4ab8-8728-545fd6378912", diff --git a/flows/actions/testdata/send_optin.json b/flows/actions/testdata/send_optin.json new file mode 100644 index 000000000..49020a8c7 --- /dev/null +++ b/flows/actions/testdata/send_optin.json @@ -0,0 +1,48 @@ +[ + { + "description": "NOOP when channel doesn't support optins feature", + "action": { + "type": "send_optin", + "uuid": "ad154980-7bf7-4ab8-8728-545fd6378912", + "optin": { + "uuid": "248be71d-78e9-4d71-a6c4-9981d369e5cb", + "name": "Joke Of The Day" + } + }, + "events": [] + }, + { + "description": "Event created when channel does support optins feature", + "contact": { + "uuid": "5d76d86b-3bb9-4d5a-b822-c9d86f5d8e4f", + "name": "Ryan Lewis", + "language": "eng", + "timezone": "America/Guayaquil", + "urns": [ + "facebook:1234567890" + ], + "groups": [], + "fields": {}, + "created_on": "2018-06-20T11:40:30.123456789-00:00" + }, + "action": { + "type": "send_optin", + "uuid": "ad154980-7bf7-4ab8-8728-545fd6378912", + "optin": { + "uuid": "248be71d-78e9-4d71-a6c4-9981d369e5cb", + "name": "Joke Of The Day" + } + }, + "events": [ + { + "type": "optin_sent", + "created_on": "2018-10-18T14:20:30.000123456Z", + "step_uuid": "59d74b86-3e2f-4a93-aece-b05d2fdcde0c", + "optin": { + "name": "Joke Of The Day", + "uuid": "248be71d-78e9-4d71-a6c4-9981d369e5cb" + } + } + ] + } +] \ No newline at end of file diff --git a/flows/actions/testdata/transfer_airtime.json b/flows/actions/testdata/transfer_airtime.json index 123b2f1b9..368e442d3 100644 --- a/flows/actions/testdata/transfer_airtime.json +++ b/flows/actions/testdata/transfer_airtime.json @@ -48,7 +48,16 @@ }, { "description": "Error and failed transfer if contact has no tel urn", - "no_urns": true, + "contact": { + "uuid": "5d76d86b-3bb9-4d5a-b822-c9d86f5d8e4f", + "name": "Ryan Lewis", + "language": "eng", + "timezone": "America/Guayaquil", + "urns": [], + "groups": [], + "fields": {}, + "created_on": "2018-06-20T11:40:30.123456789-00:00" + }, "action": { "type": "transfer_airtime", "uuid": "ad154980-7bf7-4ab8-8728-545fd6378912", diff --git a/flows/channel.go b/flows/channel.go index c5c07b547..e83fd623a 100644 --- a/flows/channel.go +++ b/flows/channel.go @@ -10,6 +10,7 @@ import ( "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/excellent/types" "github.com/nyaruka/goflow/utils" + "golang.org/x/exp/slices" ) // Channel represents a means for sending and receiving input during a flow run @@ -35,22 +36,17 @@ func (c *Channel) Reference() *assets.ChannelReference { // SupportsScheme returns whether this channel supports the given URN scheme func (c *Channel) SupportsScheme(scheme string) bool { - for _, s := range c.Schemes() { - if s == scheme { - return true - } - } - return false + return slices.Contains(c.Schemes(), scheme) } // HasRole returns whether this channel has the given role func (c *Channel) HasRole(role assets.ChannelRole) bool { - for _, r := range c.Roles() { - if r == role { - return true - } - } - return false + return slices.Contains(c.Roles(), role) +} + +// HasFeature returns whether this channel has the given feature +func (c *Channel) HasFeature(feat assets.ChannelFeature) bool { + return slices.Contains(c.Features(), feat) } // Context returns the properties available in expressions diff --git a/flows/channel_test.go b/flows/channel_test.go index ae93e2065..9a0a9af83 100644 --- a/flows/channel_test.go +++ b/flows/channel_test.go @@ -39,6 +39,7 @@ func TestChannel(t *testing.T) { assert.Equal(t, assets.NewChannelReference(ch.UUID(), "Android"), ch.Reference()) assert.True(t, ch.HasRole(assets.ChannelRoleSend)) assert.False(t, ch.HasRole(assets.ChannelRoleCall)) + assert.False(t, ch.HasFeature(assets.ChannelFeatureOptIns)) // nil object returns nil reference assert.Nil(t, (*flows.Channel)(nil).Reference()) diff --git a/flows/events/base_test.go b/flows/events/base_test.go index c90bf37ee..5e6e82f9f 100644 --- a/flows/events/base_test.go +++ b/flows/events/base_test.go @@ -45,6 +45,7 @@ func TestEventMarshaling(t *testing.T) { timeout := 500 expiresOn := time.Date(2022, 2, 3, 13, 45, 30, 0, time.UTC) gender := session.Assets().Fields().Get("gender") + jotd := session.Assets().OptIns().Get("248be71d-78e9-4d71-a6c4-9981d369e5cb") mailgun := session.Assets().Ticketers().Get("19dc6346-9623-4fe4-be80-538d493ecdf5") weather := session.Assets().Topics().Get("472a7a73-96cb-4736-b567-056d987cc5b4") user := session.Assets().Users().Get("bob@nyaruka.com") @@ -543,6 +544,17 @@ func TestEventMarshaling(t *testing.T) { "expires_on": "2022-02-03T13:45:30Z" }`, }, + { + events.NewOptInSent(jotd), + `{ + "type": "optin_sent", + "created_on": "2018-10-18T14:20:30.000123456Z", + "optin": { + "uuid": "248be71d-78e9-4d71-a6c4-9981d369e5cb", + "name": "Joke Of The Day" + } + }`, + }, { events.NewSessionTriggered( assets.NewFlowReference(assets.FlowUUID("e4d441f0-24e3-4627-85fb-1e99e733baf0"), "Collect Age"), diff --git a/flows/events/optin_sent.go b/flows/events/optin_sent.go new file mode 100644 index 000000000..bad9ff89a --- /dev/null +++ b/flows/events/optin_sent.go @@ -0,0 +1,39 @@ +package events + +import ( + "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/goflow/flows" +) + +func init() { + registerType(TypeOptInSent, func() flows.Event { return &OptInSentEvent{} }) +} + +// TypeOptInSent is our type for the optin event +const TypeOptInSent string = "optin_sent" + +// OptInSentEvent events are created when an action has sent an optin. +// +// { +// "type": "optin_sent", +// "created_on": "2006-01-02T15:04:05Z", +// "optin": { +// "uuid": "248be71d-78e9-4d71-a6c4-9981d369e5cb", +// "name": "Joke Of The Day" +// } +// } +// +// @event optin_sent +type OptInSentEvent struct { + BaseEvent + + OptIn *assets.OptInReference `json:"optin" validate:"required,dive"` +} + +// NewOptInSent returns a new optin sent event +func NewOptInSent(optIn *flows.OptIn) *OptInSentEvent { + return &OptInSentEvent{ + BaseEvent: NewBaseEvent(TypeOptInSent), + OptIn: optIn.Reference(), + } +} diff --git a/test/assets.go b/test/assets.go index 654a8391e..16e5ef37d 100644 --- a/test/assets.go +++ b/test/assets.go @@ -39,8 +39,8 @@ func LoadFlowFromAssets(env envs.Environment, path string, uuid assets.FlowUUID) return sa.Flows().Get(uuid) } -func NewChannel(name string, address string, schemes []string, roles []assets.ChannelRole, parent *assets.ChannelReference) *flows.Channel { - return flows.NewChannel(static.NewChannel(assets.ChannelUUID(uuids.New()), name, address, schemes, roles)) +func NewChannel(name string, address string, schemes []string, roles []assets.ChannelRole, features []assets.ChannelFeature) *flows.Channel { + return flows.NewChannel(static.NewChannel(assets.ChannelUUID(uuids.New()), name, address, schemes, roles, features)) } func NewTelChannel(name string, address string, roles []assets.ChannelRole, parent *assets.ChannelReference, country i18n.Country, matchPrefixes []string, allowInternational bool) *flows.Channel { diff --git a/test/session.go b/test/session.go index 07581d046..3a8f557af 100644 --- a/test/session.go +++ b/test/session.go @@ -44,7 +44,8 @@ var sessionAssets = `{ "name": "Facebook Channel", "address": "235326346322111", "schemes": ["facebook"], - "roles": ["send", "receive"] + "roles": ["send", "receive"], + "features": ["optins"] } ], "classifiers": [