From b7df7438b3207517a26a4c089908452db3c33972 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 21 Jan 2022 19:00:13 -0300 Subject: [PATCH 01/24] add twilioflex ticketer client base --- services/tickets/twilioflex/client.go | 255 ++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 services/tickets/twilioflex/client.go diff --git a/services/tickets/twilioflex/client.go b/services/tickets/twilioflex/client.go new file mode 100644 index 000000000..bcc966127 --- /dev/null +++ b/services/tickets/twilioflex/client.go @@ -0,0 +1,255 @@ +package twilioflex + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "time" + + "github.com/fatih/structs" + "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/gocommon/jsonx" +) + +type baseClient struct { + httpClient *http.Client + httpRetries *httpx.RetryConfig + authToken string + accountSID string + serviceSID string + workspaceSID string +} + +func newBaseClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, authToken, accountSID, workspaceSID string) baseClient { + return baseClient{ + httpClient: httpClient, + httpRetries: httpRetries, + authToken: authToken, + accountSID: accountSID, + workspaceSID: workspaceSID, + } +} + +type errorResponse struct { + Error string `json:"error"` + Description string `json:"description"` +} + +func (c *baseClient) request(method, url string, payload interface{}, response interface{}) (*httpx.Trace, error) { + headers := map[string]string{} + + formValues := structToMap(payload) + body := strings.NewReader(formValues.Encode()) + + req, err := httpx.NewRequest(method, url, body, headers) + if err != nil { + return nil, err + } + req.SetBasicAuth(c.accountSID, c.authToken) + + trace, err := httpx.DoTrace(c.httpClient, req, c.httpRetries, nil, -1) + if err != nil { + return trace, err + } + + if trace.Response.StatusCode >= 400 { + response := &errorResponse{} + jsonx.Unmarshal(trace.ResponseBody, response) + return trace, errors.New(response.Description) + } + + if response != nil { + return trace, jsonx.Unmarshal(trace.ResponseBody, response) + } + return nil, nil +} + +func (c *baseClient) post(url string, payload, response interface{}) (*httpx.Trace, error) { + return c.request("POST", url, payload, response) +} + +func (c *baseClient) put(url string, payload, response interface{}) (*httpx.Trace, error) { + return c.request("PUT", url, payload, response) +} + +func (c *baseClient) delete(url string, payload, response interface{}) (*httpx.Trace, error) { + return c.request("DELETE", url, payload, response) +} + +func (c *baseClient) get(url string, payload, response interface{}) (*httpx.Trace, error) { + return c.request("GET", url, payload, response) +} + +type RESTClient struct { + baseClient +} + +func NewRestClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, authToken, accountSID, workspaceSID string) *RESTClient { + return &RESTClient{ + baseClient: newBaseClient(httpClient, httpRetries, authToken, accountSID, workspaceSID), + } +} + +func (c *RESTClient) CreateUser(user *ChatUser) (*ChatUser, *httpx.Trace, error) { + url := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Users", c.serviceSID) + payload := structs.Map(user) + + response := &ChatUser{} + trace, err := c.post(url, payload, response) + if err != nil { + return nil, trace, err + } + return response, trace, nil +} + +func (c *RESTClient) CreateChannel(channel *ChatChannel) (*ChatChannel, *httpx.Trace, error) { + // TODO: CreateChannel + return nil, nil, nil +} + +func (c *RESTClient) CreateChannelMemberFromUser(user *ChatUser) (*ChatMember, *httpx.Trace, error) { + // TODO: CreateMember + return nil, nil, nil +} + +func (c *RESTClient) SetChannelWebhook() { + // TODO: SetChannelWebhook +} + +func (c *RESTClient) CreateMessage() { + // TODO: CreateMessage +} + +func (c *RESTClient) CreateTask() { + // TODO: CreateTask +} + +type ChatUser struct { + AccountSID *string `json:"account_sid,omitempty"` + Attributes *string `json:"attributes,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + FriendlyName *string `json:"friendly_name,omitempty"` + Identity *string `json:"identity,omitempty"` + Links *map[string]interface{} `json:"links,omitempty"` + RoleSID *string `json:"role_sid,omitempty"` + ServiceSID *string `json:"service_sid,omitempty"` + SID *string `json:"sid,omitempty"` + Url *string `json:"url,omitempty"` +} + +type ChatChannel struct { + AccountSID *string `json:"account_sid,omitempty"` + Attributes *string `json:"attributes,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + FriendlyName *string `json:"friendly_name,omitempty"` + Links *map[string]interface{} `json:"links,omitempty"` + MemberCount *int `json:"member_count,omitempty"` + MessagesCount *int `json:"messages_count,omitempty"` + ServiceSID *string `json:"service_sid,omitempty"` + SID *string `json:"sid,omitempty"` + Type *string `json:"type,omitempty"` + UniqueName *string `json:"unique_name,omitempty"` +} + +type ChatMember struct { + AccountSID *string `json:"account_sid,omitempty"` + Attributes *string `json:"attributes,omitempty"` + ChannelSID *string `json:"channel_sid,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + Identity *string `json:"identity,omitempty"` + LastConsumedMessageIndex *int `json:"last_consumed_message_index,omitempty"` + LastConsumptionTimestamp *time.Time `json:"last_consumption_timestamp,omitempty"` + RoleSID *string `json:"role_sid,omitempty"` + ServiceSID *string `json:"service_sid,omitempty"` + SID *string `json:"sid,omitempty"` + Url *string `json:"url,omitempty"` +} + +type ChatMessage struct { + AccountSID *string `json:"account_sid,omitempty"` + Attributes *string `json:"attributes,omitempty"` + Body *string `json:"body,omitempty"` + ChannelSID *string `json:"channel_sid,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + From *string `json:"from,omitempty"` + Index *int `json:"index,omitempty"` + LastUpdatedBy *string `json:"last_updated_by,omitempty"` + Media *map[string]interface{} `json:"media,omitempty"` + ServiceSID *string `json:"service_sid,omitempty"` + SID *string `json:"sid,omitempty"` + To *string `json:"to,omitempty"` + Type *string `json:"type,omitempty"` + Url *string `json:"url,omitempty"` + WasEdited *bool `json:"was_edited,omitempty"` +} + +type ChatChannelWebhook struct { + AccountSID *string `json:"account_sid,omitempty"` + ChannelSID *string `json:"channel_sid,omitempty"` + Configuration *map[string]interface{} `json:"configuration,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + ServiceSID *string `json:"service_sid,omitempty"` + SID *string `json:"sid,omitempty"` + Type *string `json:"type,omitempty"` + Url *string `json:"url,omitempty"` +} + +type TaskrouterTask struct { + AccountSID *string `json:"account_sid,omitempty"` + Addons *string `json:"addons,omitempty"` + Age *int `json:"age,omitempty"` + AssignmentStatus *string `json:"assignment_status,omitempty"` + Attributes *string `json:"attributes,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + Links *map[string]interface{} `json:"links,omitempty"` + Priority *int `json:"priority,omitempty"` + Reason *string `json:"reason,omitempty"` + SID *string `json:"sid,omitempty"` + TaskChannelSID *string `json:"task_channel_sid,omitempty"` + TaskChannelUniqueName *string `json:"task_channel_unique_name,omitempty"` + TaskQueueEnteredDate *time.Time `json:"task_queue_entered_date,omitempty"` + TaskQueueFriendlyName *string `json:"task_queue_friendly_name,omitempty"` + TaskQueueSID *string `json:"task_queue_sid,omitempty"` + Timeout *int `json:"timeout,omitempty"` + Url *string `json:"url,omitempty"` + WorkflowFriendlyName *string `json:"workflow_friendly_name,omitempty"` + WorkflowSID *string `json:"workflow_sid,omitempty"` + WorkspaceSID *string `json:"workspace_sid,omitempty"` +} + +func structToMap(i interface{}) (values url.Values) { + values = url.Values{} + iVal := reflect.ValueOf(i).Elem() + typ := iVal.Type() + for i := 0; i < iVal.NumField(); i++ { + f := iVal.Field(i) + var v string + switch f.Interface().(type) { + case int, int8, int16, int32, int64: + v = strconv.FormatInt(f.Int(), 10) + case uint, uint8, uint16, uint32, uint64: + v = strconv.FormatUint(f.Uint(), 10) + case float32: + v = strconv.FormatFloat(f.Float(), 'f', 4, 32) + case float64: + v = strconv.FormatFloat(f.Float(), 'f', 4, 64) + case []byte: + v = string(f.Bytes()) + case string: + v = f.String() + } + values.Set(typ.Field(i).Name, v) + } + return +} From 9694ddb258a47818bb90c5d705818605d87c891f Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 21 Jan 2022 19:00:25 -0300 Subject: [PATCH 02/24] add twilioflex ticketer service base --- services/tickets/twilioflex/service.go | 53 ++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 services/tickets/twilioflex/service.go diff --git a/services/tickets/twilioflex/service.go b/services/tickets/twilioflex/service.go new file mode 100644 index 000000000..22b0af98a --- /dev/null +++ b/services/tickets/twilioflex/service.go @@ -0,0 +1,53 @@ +package twilioflex + +import ( + "net/http" + + "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/runtime" +) + +const ( + typeTwilioFlex = "twilioflex" + configurationAuthToken = "auth_token" + configurationAccountSID = "account_sid" + configurationChatServiceSID = "chat_service_sid" + configurationWorkspaceSID = "workspace_sid" +) + +func init() { + models.RegisterTicketService(typeTwilioFlex, NewService) +} + +type service struct { + ticketer *flows.Ticketer +} + +func NewService(rtCfg *runtime.Config, httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) { + return &service{ + ticketer: ticketer, + }, nil +} + +func (s *service) Open(session flows.Session, topic *flows.Topic, body string, assignee *flows.User, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { + // TODO: Open Ticket + return nil, nil +} + +func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error { + // TODO: Forward + return nil +} + +func (s *service) Close(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { + // TODO: Close + return nil +} + +func (s *service) Reopen(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { + // TODO: Reopen + return nil +} From 63dc54202c21dfa7be9ce041f1032ab9d7be9c87 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Wed, 26 Jan 2022 18:53:12 -0300 Subject: [PATCH 03/24] Add ticketer twilio flex Open Implementation --- cmd/mailroom/main.go | 1 + go.mod | 1 + go.sum | 2 + services/tickets/twilioflex/client.go | 375 +++++++++++++++---------- services/tickets/twilioflex/service.go | 92 +++++- 5 files changed, 309 insertions(+), 162 deletions(-) diff --git a/cmd/mailroom/main.go b/cmd/mailroom/main.go index 76817321d..83e0b26d2 100644 --- a/cmd/mailroom/main.go +++ b/cmd/mailroom/main.go @@ -29,6 +29,7 @@ import ( _ "github.com/nyaruka/mailroom/services/tickets/intern" _ "github.com/nyaruka/mailroom/services/tickets/mailgun" _ "github.com/nyaruka/mailroom/services/tickets/rocketchat" + _ "github.com/nyaruka/mailroom/services/tickets/twilioflex" _ "github.com/nyaruka/mailroom/services/tickets/zendesk" _ "github.com/nyaruka/mailroom/web/contact" _ "github.com/nyaruka/mailroom/web/docs" diff --git a/go.mod b/go.mod index 046d213f0..f0b61eef5 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/go-playground/locales v0.13.0 // indirect github.com/go-playground/universal-translator v0.17.0 // indirect github.com/gofrs/uuid v3.3.0+incompatible // indirect + github.com/google/go-querystring v1.1.0 github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect diff --git a/go.sum b/go.sum index a15cf5a30..d3d6560b4 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= diff --git a/services/tickets/twilioflex/client.go b/services/tickets/twilioflex/client.go index bcc966127..e579c4d7a 100644 --- a/services/tickets/twilioflex/client.go +++ b/services/tickets/twilioflex/client.go @@ -5,51 +5,57 @@ import ( "fmt" "net/http" "net/url" - "reflect" "strconv" "strings" "time" - "github.com/fatih/structs" + "github.com/google/go-querystring/query" "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/jsonx" ) type baseClient struct { - httpClient *http.Client - httpRetries *httpx.RetryConfig - authToken string - accountSID string - serviceSID string - workspaceSID string + httpClient *http.Client + httpRetries *httpx.RetryConfig + authToken string + accountSid string + serviceSid string + workspaceSid string + workflowSid string + taskChannelSid string + flexFlowSid string } -func newBaseClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, authToken, accountSID, workspaceSID string) baseClient { +func newBaseClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, authToken, accountSid, serviceSid, workspaceSid, workflowSid, taskChannelSid, flexFlowSid string) baseClient { return baseClient{ - httpClient: httpClient, - httpRetries: httpRetries, - authToken: authToken, - accountSID: accountSID, - workspaceSID: workspaceSID, + httpClient: httpClient, + httpRetries: httpRetries, + authToken: authToken, + accountSid: accountSid, + serviceSid: serviceSid, + workspaceSid: workspaceSid, + workflowSid: workflowSid, + taskChannelSid: taskChannelSid, + flexFlowSid: flexFlowSid, } } type errorResponse struct { - Error string `json:"error"` - Description string `json:"description"` + Code int32 `json:"code,omitempty"` + Message string `json:"message,omitempty"` + MoreInfo string `json:"more_info,omitempty"` + Status int32 `json:"status,omitempty"` } -func (c *baseClient) request(method, url string, payload interface{}, response interface{}) (*httpx.Trace, error) { - headers := map[string]string{} - - formValues := structToMap(payload) - body := strings.NewReader(formValues.Encode()) - - req, err := httpx.NewRequest(method, url, body, headers) +func (c *baseClient) request(method, url string, payload url.Values, response interface{}) (*httpx.Trace, error) { + data := strings.NewReader(payload.Encode()) + req, err := httpx.NewRequest(method, url, data, map[string]string{}) if err != nil { return nil, err } - req.SetBasicAuth(c.accountSID, c.authToken) + req.SetBasicAuth(c.accountSid, c.authToken) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Content-Length", strconv.Itoa(len(payload.Encode()))) trace, err := httpx.DoTrace(c.httpClient, req, c.httpRetries, nil, -1) if err != nil { @@ -59,28 +65,28 @@ func (c *baseClient) request(method, url string, payload interface{}, response i if trace.Response.StatusCode >= 400 { response := &errorResponse{} jsonx.Unmarshal(trace.ResponseBody, response) - return trace, errors.New(response.Description) + return trace, errors.New(response.Message) } if response != nil { return trace, jsonx.Unmarshal(trace.ResponseBody, response) } - return nil, nil + return trace, nil } -func (c *baseClient) post(url string, payload, response interface{}) (*httpx.Trace, error) { +func (c *baseClient) post(url string, payload url.Values, response interface{}) (*httpx.Trace, error) { return c.request("POST", url, payload, response) } -func (c *baseClient) put(url string, payload, response interface{}) (*httpx.Trace, error) { +func (c *baseClient) put(url string, payload url.Values, response interface{}) (*httpx.Trace, error) { return c.request("PUT", url, payload, response) } -func (c *baseClient) delete(url string, payload, response interface{}) (*httpx.Trace, error) { +func (c *baseClient) delete(url string, payload url.Values, response interface{}) (*httpx.Trace, error) { return c.request("DELETE", url, payload, response) } -func (c *baseClient) get(url string, payload, response interface{}) (*httpx.Trace, error) { +func (c *baseClient) get(url string, payload url.Values, response interface{}) (*httpx.Trace, error) { return c.request("GET", url, payload, response) } @@ -88,168 +94,231 @@ type RESTClient struct { baseClient } -func NewRestClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, authToken, accountSID, workspaceSID string) *RESTClient { +func NewRestClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, authToken, accountSid, serviceSid, workspaceSid, workflowSid, taskChannelSid, flexFlowSid string) *RESTClient { return &RESTClient{ - baseClient: newBaseClient(httpClient, httpRetries, authToken, accountSID, workspaceSID), + baseClient: newBaseClient(httpClient, httpRetries, authToken, accountSid, serviceSid, workspaceSid, workflowSid, taskChannelSid, flexFlowSid), } } -func (c *RESTClient) CreateUser(user *ChatUser) (*ChatUser, *httpx.Trace, error) { - url := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Users", c.serviceSID) - payload := structs.Map(user) - +func (c *RESTClient) CreateUser(user *CreateChatUserParams) (*ChatUser, *httpx.Trace, error) { + requestUrl := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Users", c.serviceSid) response := &ChatUser{} - trace, err := c.post(url, payload, response) + data, err := query.Values(user) + if err != nil { + return nil, nil, err + } + trace, err := c.post(requestUrl, data, response) if err != nil { return nil, trace, err } return response, trace, nil } -func (c *RESTClient) CreateChannel(channel *ChatChannel) (*ChatChannel, *httpx.Trace, error) { - // TODO: CreateChannel - return nil, nil, nil -} +func (c *RESTClient) GetUser(userSid string) (*ChatUser, *httpx.Trace, error) { + requestUrl := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Users/%s", c.serviceSid, userSid) + response := &ChatUser{} + trace, err := c.post(requestUrl, url.Values{}, response) + if err != nil { + return nil, trace, err + } + return response, trace, nil -func (c *RESTClient) CreateChannelMemberFromUser(user *ChatUser) (*ChatMember, *httpx.Trace, error) { - // TODO: CreateMember - return nil, nil, nil } -func (c *RESTClient) SetChannelWebhook() { - // TODO: SetChannelWebhook +func (c *RESTClient) CreateFlexChannel(channel *CreateFlexChannelParams) (*FlexChannel, *httpx.Trace, error) { + url := "https://flex-api.twilio.com/v1/Channels" + response := &FlexChannel{} + data, err := query.Values(channel) + if err != nil { + return nil, nil, err + } + data = removeEmpties(data) + trace, err := c.post(url, data, response) + if err != nil { + return nil, trace, err + } + return response, trace, err } -func (c *RESTClient) CreateMessage() { - // TODO: CreateMessage +func (c *RESTClient) CreateFlexChannelWebhook(channelWebhook *CreateChatChannelWebhookParams, channelSid string) (*ChatChannelWebhook, *httpx.Trace, error) { + requestUrl := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Channels/%s/Webhooks", c.serviceSid, channelSid) + response := &ChatChannelWebhook{} + data := url.Values{ + "Configuration.Url": []string{channelWebhook.ConfigurationUrl}, + "Configuration.Filters": channelWebhook.ConfigurationFilters, + "Configuration.Method": []string{channelWebhook.ConfigurationMethod}, + "Configuration.RetryCount": []string{fmt.Sprint(channelWebhook.ConfigurationRetryCount)}, + "Type": []string{channelWebhook.Type}, + } + trace, err := c.post(requestUrl, data, response) + if err != nil { + return nil, trace, err + } + return response, trace, err } -func (c *RESTClient) CreateTask() { - // TODO: CreateTask +func (c *RESTClient) CreateMessage(message *ChatMessage) (*ChatMessage, *httpx.Trace, error) { + url := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Channels/%s/Messages", c.serviceSid, message.ChannelSid) + response := &ChatMessage{} + data, err := query.Values(message) + if err != nil { + return nil, nil, err + } + data = removeEmpties(data) + trace, err := c.post(url, data, response) + if err != nil { + return nil, trace, err + } + return response, trace, nil } type ChatUser struct { - AccountSID *string `json:"account_sid,omitempty"` - Attributes *string `json:"attributes,omitempty"` - DateCreated *time.Time `json:"date_created,omitempty"` - DateUpdated *time.Time `json:"date_updated,omitempty"` - FriendlyName *string `json:"friendly_name,omitempty"` - Identity *string `json:"identity,omitempty"` - Links *map[string]interface{} `json:"links,omitempty"` - RoleSID *string `json:"role_sid,omitempty"` - ServiceSID *string `json:"service_sid,omitempty"` - SID *string `json:"sid,omitempty"` - Url *string `json:"url,omitempty"` + AccountSid string `json:"account_sid,omitempty"` + Attributes string `json:"attributes,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + FriendlyName string `json:"friendly_name,omitempty"` + Identity string `json:"identity,omitempty"` + Links map[string]interface{} `json:"links,omitempty"` + RoleSid string `json:"role_sid,omitempty"` + ServiceSid string `json:"service_sid,omitempty"` + Sid string `json:"sid,omitempty"` + Url string `json:"url,omitempty"` +} + +type CreateChatUserParams struct { + XTwilioWebhookEnabled string `json:"X-Twilio-Webhook-Enabled,omitempty"` + Attributes string `json:"Attributes,omitempty"` + FriendlyName string `json:"FriendlyName,omitempty"` + Identity string `json:"Identity,omitempty"` + RoleSid string `json:"RoleSid,omitempty"` } type ChatChannel struct { - AccountSID *string `json:"account_sid,omitempty"` - Attributes *string `json:"attributes,omitempty"` - CreatedBy *string `json:"created_by,omitempty"` - DateCreated *time.Time `json:"date_created,omitempty"` - DateUpdated *time.Time `json:"date_updated,omitempty"` - FriendlyName *string `json:"friendly_name,omitempty"` - Links *map[string]interface{} `json:"links,omitempty"` - MemberCount *int `json:"member_count,omitempty"` - MessagesCount *int `json:"messages_count,omitempty"` - ServiceSID *string `json:"service_sid,omitempty"` - SID *string `json:"sid,omitempty"` - Type *string `json:"type,omitempty"` - UniqueName *string `json:"unique_name,omitempty"` + AccountSid string `json:"account_sid,omitempty"` + Attributes string `json:"attributes,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + FriendlyName string `json:"friendly_name,omitempty"` + Links map[string]interface{} `json:"links,omitempty"` + MemberCount int `json:"member_count,omitempty"` + MessagesCount int `json:"messages_count,omitempty"` + ServiceSid string `json:"service_sid,omitempty"` + Sid string `json:"sid,omitempty"` + Type string `json:"type,omitempty"` + UniqueName string `json:"unique_name,omitempty"` +} + +type FlexChannel struct { + AccountSid string `json:"account_sid,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + FlexFlowSid string `json:"flex_flow_sid,omitempty"` + Sid string `json:"sid,omitempty"` + TaskSid string `json:"task_sid,omitempty"` + Url string `json:"url,omitempty"` + UserSid string `json:"user_sid,omitempty"` +} + +type CreateFlexChannelParams struct { + ChatFriendlyName string `json:"ChatFriendlyName,omitempty"` + ChatUniqueName string `json:"ChatUniqueName,omitempty"` + ChatUserFriendlyName string `json:"ChatUserFriendlyName,omitempty"` + FlexFlowSid string `json:"FlexFlowSid,omitempty"` + Identity string `json:"Identity,omitempty"` + LongLived bool `json:"LongLived,omitempty"` + PreEngagementData string `json:"PreEngagementData,omitempty"` + Target string `json:"Target,omitempty"` + TaskAttributes string `json:"TaskAttributes,omitempty"` + TaskSid string `json:"TaskSid,omitempty"` } type ChatMember struct { - AccountSID *string `json:"account_sid,omitempty"` - Attributes *string `json:"attributes,omitempty"` - ChannelSID *string `json:"channel_sid,omitempty"` + AccountSid string `json:"account_sid,omitempty"` + Attributes string `json:"attributes,omitempty"` + ChannelSid string `json:"channel_sid,omitempty"` DateCreated *time.Time `json:"date_created,omitempty"` DateUpdated *time.Time `json:"date_updated,omitempty"` - Identity *string `json:"identity,omitempty"` - LastConsumedMessageIndex *int `json:"last_consumed_message_index,omitempty"` + Identity string `json:"identity,omitempty"` + LastConsumedMessageIndex int `json:"last_consumed_message_index,omitempty"` LastConsumptionTimestamp *time.Time `json:"last_consumption_timestamp,omitempty"` - RoleSID *string `json:"role_sid,omitempty"` - ServiceSID *string `json:"service_sid,omitempty"` - SID *string `json:"sid,omitempty"` - Url *string `json:"url,omitempty"` + RoleSid string `json:"role_sid,omitempty"` + ServiceSid string `json:"service_sid,omitempty"` + Sid string `json:"sid,omitempty"` + Url string `json:"url,omitempty"` } type ChatMessage struct { - AccountSID *string `json:"account_sid,omitempty"` - Attributes *string `json:"attributes,omitempty"` - Body *string `json:"body,omitempty"` - ChannelSID *string `json:"channel_sid,omitempty"` - DateCreated *time.Time `json:"date_created,omitempty"` - DateUpdated *time.Time `json:"date_updated,omitempty"` - From *string `json:"from,omitempty"` - Index *int `json:"index,omitempty"` - LastUpdatedBy *string `json:"last_updated_by,omitempty"` - Media *map[string]interface{} `json:"media,omitempty"` - ServiceSID *string `json:"service_sid,omitempty"` - SID *string `json:"sid,omitempty"` - To *string `json:"to,omitempty"` - Type *string `json:"type,omitempty"` - Url *string `json:"url,omitempty"` - WasEdited *bool `json:"was_edited,omitempty"` + AccountSid string `json:"account_sid,omitempty"` + Attributes string `json:"attributes,omitempty"` + Body string `json:"body,omitempty"` + ChannelSid string `json:"channel_sid,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + From string `json:"from,omitempty"` + Index int `json:"index,omitempty"` + LastUpdatedBy string `json:"last_updated_by,omitempty"` + Media map[string]interface{} `json:"media,omitempty"` + ServiceSid string `json:"service_sid,omitempty"` + Sid string `json:"sid,omitempty"` + To string `json:"to,omitempty"` + Type string `json:"type,omitempty"` + Url string `json:"url,omitempty"` + WasEdited bool `json:"was_edited,omitempty"` } type ChatChannelWebhook struct { - AccountSID *string `json:"account_sid,omitempty"` - ChannelSID *string `json:"channel_sid,omitempty"` - Configuration *map[string]interface{} `json:"configuration,omitempty"` - DateCreated *time.Time `json:"date_created,omitempty"` - DateUpdated *time.Time `json:"date_updated,omitempty"` - ServiceSID *string `json:"service_sid,omitempty"` - SID *string `json:"sid,omitempty"` - Type *string `json:"type,omitempty"` - Url *string `json:"url,omitempty"` + AccountSid string `json:"account_sid,omitempty"` + ChannelSid string `json:"channel_sid,omitempty"` + Configuration map[string]interface{} `json:"configuration,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + ServiceSid string `json:"service_sid,omitempty"` + Sid string `json:"sid,omitempty"` + Type string `json:"type,omitempty"` + Url string `json:"url,omitempty"` +} + +type CreateChatChannelWebhookParams struct { + ConfigurationFilters []string `json:"Configuration.Filters,omitempty"` + ConfigurationFlowSid string `json:"Configuration.FlowSid,omitempty"` + ConfigurationMethod string `json:"Configuration.Method,omitempty"` + ConfigurationRetryCount int `json:"Configuration.RetryCount,omitempty"` + ConfigurationTriggers []string `json:"Configuration.Triggers,omitempty"` + ConfigurationUrl string `json:"Configuration.Url,omitempty"` + Type string `json:"Type,omitempty"` } type TaskrouterTask struct { - AccountSID *string `json:"account_sid,omitempty"` - Addons *string `json:"addons,omitempty"` - Age *int `json:"age,omitempty"` - AssignmentStatus *string `json:"assignment_status,omitempty"` - Attributes *string `json:"attributes,omitempty"` - DateCreated *time.Time `json:"date_created,omitempty"` - DateUpdated *time.Time `json:"date_updated,omitempty"` - Links *map[string]interface{} `json:"links,omitempty"` - Priority *int `json:"priority,omitempty"` - Reason *string `json:"reason,omitempty"` - SID *string `json:"sid,omitempty"` - TaskChannelSID *string `json:"task_channel_sid,omitempty"` - TaskChannelUniqueName *string `json:"task_channel_unique_name,omitempty"` - TaskQueueEnteredDate *time.Time `json:"task_queue_entered_date,omitempty"` - TaskQueueFriendlyName *string `json:"task_queue_friendly_name,omitempty"` - TaskQueueSID *string `json:"task_queue_sid,omitempty"` - Timeout *int `json:"timeout,omitempty"` - Url *string `json:"url,omitempty"` - WorkflowFriendlyName *string `json:"workflow_friendly_name,omitempty"` - WorkflowSID *string `json:"workflow_sid,omitempty"` - WorkspaceSID *string `json:"workspace_sid,omitempty"` -} - -func structToMap(i interface{}) (values url.Values) { - values = url.Values{} - iVal := reflect.ValueOf(i).Elem() - typ := iVal.Type() - for i := 0; i < iVal.NumField(); i++ { - f := iVal.Field(i) - var v string - switch f.Interface().(type) { - case int, int8, int16, int32, int64: - v = strconv.FormatInt(f.Int(), 10) - case uint, uint8, uint16, uint32, uint64: - v = strconv.FormatUint(f.Uint(), 10) - case float32: - v = strconv.FormatFloat(f.Float(), 'f', 4, 32) - case float64: - v = strconv.FormatFloat(f.Float(), 'f', 4, 64) - case []byte: - v = string(f.Bytes()) - case string: - v = f.String() + AccountSid string `json:"account_sid,omitempty"` + Addons string `json:"addons,omitempty"` + Age int `json:"age,omitempty"` + AssignmentStatus string `json:"assignment_status,omitempty"` + Attributes string `json:"attributes,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + Links map[string]interface{} `json:"links,omitempty"` + Priority int `json:"priority,omitempty"` + Reason string `json:"reason,omitempty"` + Sid string `json:"sid,omitempty"` + TaskChannel string `json:"task_channel,omitempty"` + TaskChannelUniqueName string `json:"task_channel_unique_name,omitempty"` + TaskQueueEnteredDate *time.Time `json:"task_queue_entered_date,omitempty"` + TaskQueueFriendlyName string `json:"task_queue_friendly_name,omitempty"` + TaskQueueSid string `json:"task_queue_sid,omitempty"` + Timeout int `json:"timeout,omitempty"` + Url string `json:"url,omitempty"` + WorkflowFriendlyName string `json:"workflow_friendly_name,omitempty"` + WorkflowSid string `json:"workflow_sid,omitempty"` + WorkspaceSid string `json:"workspace_sid,omitempty"` +} + +func removeEmpties(uv url.Values) url.Values { + for k, v := range uv { + if len(v) == 0 || len(v[0]) == 0 { + delete(uv, k) } - values.Set(typ.Field(i).Name, v) } - return + return uv } diff --git a/services/tickets/twilioflex/service.go b/services/tickets/twilioflex/service.go index 22b0af98a..0d6977fa2 100644 --- a/services/tickets/twilioflex/service.go +++ b/services/tickets/twilioflex/service.go @@ -3,6 +3,8 @@ package twilioflex import ( "net/http" + "github.com/pkg/errors" + "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" @@ -13,9 +15,12 @@ import ( const ( typeTwilioFlex = "twilioflex" configurationAuthToken = "auth_token" - configurationAccountSID = "account_sid" - configurationChatServiceSID = "chat_service_sid" - configurationWorkspaceSID = "workspace_sid" + configurationAccountSid = "account_sid" + configurationChatServiceSid = "chat_service_sid" + configurationWorkspaceSid = "workspace_sid" + configurationWorkflowSid = "workflow_sid" + configurationTaskChannelSid = "task_channel_sid" + configurationFlexFlowSid = "flex_flow_sid" ) func init() { @@ -23,18 +28,87 @@ func init() { } type service struct { - ticketer *flows.Ticketer + rtConfig *runtime.Config + restClient *RESTClient + ticketer *flows.Ticketer + redactor utils.Redactor } +// newService creates a new twilio flex ticket service func NewService(rtCfg *runtime.Config, httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) { - return &service{ - ticketer: ticketer, - }, nil + authToken := config[configurationAuthToken] + accountSid := config[configurationAccountSid] + chatServiceSid := config[configurationChatServiceSid] + workspaceSid := config[configurationWorkspaceSid] + workflowSid := config[configurationWorkflowSid] + taskChannelSid := config[configurationTaskChannelSid] + flexFlowSid := config[configurationFlexFlowSid] + if authToken != "" && accountSid != "" && chatServiceSid != "" && workspaceSid != "" && workflowSid != "" && taskChannelSid != "" { + return &service{ + rtConfig: rtCfg, + ticketer: ticketer, + restClient: NewRestClient(httpClient, httpRetries, authToken, accountSid, chatServiceSid, workspaceSid, workflowSid, taskChannelSid, flexFlowSid), + redactor: utils.NewRedactor(flows.RedactionMask, authToken, accountSid, chatServiceSid, workspaceSid), + }, nil + } + return nil, errors.New("missing auth_token or account_sid or chat_service_sid or workspace_sid in twilio flex config") } func (s *service) Open(session flows.Session, topic *flows.Topic, body string, assignee *flows.User, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { - // TODO: Open Ticket - return nil, nil + ticket := flows.OpenTicket(s.ticketer, topic, body, assignee) + contact := session.Contact() + chatUser := &CreateChatUserParams{ + Identity: string(contact.UUID()), + FriendlyName: contact.Name(), + } + contactUser, trace, err := s.restClient.GetUser(chatUser.Identity) + if trace != nil { + logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) + } + if err != nil && trace.Response.StatusCode != 404 { + return nil, errors.Wrapf(err, "failed to get twilio chat user") + } + if contactUser == nil { + _, trace, err := s.restClient.CreateUser(chatUser) + if trace != nil { + logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) + } + if err != nil { + return nil, errors.Wrap(err, "failed to create twilio chat user") + } + } + + flexChannelParams := &CreateFlexChannelParams{ + FlexFlowSid: s.restClient.flexFlowSid, + Identity: string(contact.UUID()), + ChatUserFriendlyName: contact.Name(), + ChatFriendlyName: contact.Name(), + } + newFlexChannel, trace, err := s.restClient.CreateFlexChannel(flexChannelParams) + if trace != nil { + logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) + } + if err != nil { + return nil, errors.Wrap(err, "failed to create twilio flex chat channel") + } + + channelWebhook := &CreateChatChannelWebhookParams{ + ConfigurationUrl: "https://webhook.site/34e495f6-25e9-4b1e-9629-346054be0d13", + ConfigurationFilters: []string{"onMessageSent"}, + ConfigurationMethod: "POST", + ConfigurationRetryCount: 1, + Type: "webhook", + } + _, trace, err = s.restClient.CreateFlexChannelWebhook(channelWebhook, newFlexChannel.Sid) + if trace != nil { + logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) + } + if err != nil { + return nil, errors.Wrap(err, "failed to create channel webhook") + } + + ticket.SetExternalID(newFlexChannel.TaskSid) + return ticket, nil } func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error { From 603d1ffeb0d56dfc304f2d9fbdc935ec5f1ca184 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Thu, 27 Jan 2022 18:57:52 -0300 Subject: [PATCH 04/24] add twiliflex ticketer Forward implementation --- services/tickets/twilioflex/service.go | 32 +++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/services/tickets/twilioflex/service.go b/services/tickets/twilioflex/service.go index 0d6977fa2..1f558e3ac 100644 --- a/services/tickets/twilioflex/service.go +++ b/services/tickets/twilioflex/service.go @@ -1,6 +1,7 @@ package twilioflex import ( + "fmt" "net/http" "github.com/pkg/errors" @@ -58,7 +59,7 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a ticket := flows.OpenTicket(s.ticketer, topic, body, assignee) contact := session.Contact() chatUser := &CreateChatUserParams{ - Identity: string(contact.UUID()), + Identity: fmt.Sprint(contact.ID()), FriendlyName: contact.Name(), } contactUser, trace, err := s.restClient.GetUser(chatUser.Identity) @@ -80,7 +81,7 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a flexChannelParams := &CreateFlexChannelParams{ FlexFlowSid: s.restClient.flexFlowSid, - Identity: string(contact.UUID()), + Identity: fmt.Sprint(contact.ID()), ChatUserFriendlyName: contact.Name(), ChatFriendlyName: contact.Name(), } @@ -92,9 +93,15 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a return nil, errors.Wrap(err, "failed to create twilio flex chat channel") } + callbackURL := fmt.Sprintf( + "https://723c-186-235-156-219.ngrok.io/mr/tickets/types/twilioflex/event_callback/%s/%s", + s.ticketer.UUID(), + ticket.UUID(), + ) + channelWebhook := &CreateChatChannelWebhookParams{ - ConfigurationUrl: "https://webhook.site/34e495f6-25e9-4b1e-9629-346054be0d13", - ConfigurationFilters: []string{"onMessageSent"}, + ConfigurationUrl: callbackURL, + ConfigurationFilters: []string{"onMessageSent", "onChannelUpdated"}, ConfigurationMethod: "POST", ConfigurationRetryCount: 1, Type: "webhook", @@ -107,12 +114,25 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a return nil, errors.Wrap(err, "failed to create channel webhook") } - ticket.SetExternalID(newFlexChannel.TaskSid) + ticket.SetExternalID(newFlexChannel.Sid) return ticket, nil } func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error { - // TODO: Forward + identity := fmt.Sprint(ticket.ContactID()) + msg := &ChatMessage{ + From: identity, + Body: text, + ChannelSid: string(ticket.ExternalID()), + } + // TODO: attachments + _, trace, err := s.restClient.CreateMessage(msg) + if trace != nil { + logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) + } + if err != nil { + return errors.Wrap(err, "error calling Twilio") + } return nil } From 0546e7e589bb3b39039394ccee87cda556f04aef Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Thu, 27 Jan 2022 18:58:39 -0300 Subject: [PATCH 05/24] add twilioflex ticketer web service for webhook callback events --- services/tickets/twilioflex/web.go | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 services/tickets/twilioflex/web.go diff --git a/services/tickets/twilioflex/web.go b/services/tickets/twilioflex/web.go new file mode 100644 index 000000000..7d4ed179f --- /dev/null +++ b/services/tickets/twilioflex/web.go @@ -0,0 +1,87 @@ +package twilioflex + +import ( + "context" + "log" + "net/http" + "time" + + "github.com/go-chi/chi" + "github.com/nyaruka/gocommon/uuids" + "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/mailroom/runtime" + "github.com/nyaruka/mailroom/services/tickets" + "github.com/nyaruka/mailroom/web" + "github.com/pkg/errors" +) + +func init() { + base := "/mr/tickets/types/twilioflex" + web.RegisterRoute(http.MethodPost, base+"/event_callback/{ticketer:[a-f0-9\\-]+}/{ticket:[a-f0-9\\-]+}", handleEventCallback) +} + +type eventCallbackRequest struct { + EventType string `json:"event_type,omitempty"` + InstanceSid string `json:"instance_sid,omitempty"` + Attributes string `json:"attributes,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + Index int `json:"index,omitempty"` + From string `json:"from,omitempty"` + MessageSid string `json:"message_sid,omitempty"` + AccountSid string `json:"account_sid,omitempty"` + Source string `json:"source,omitempty"` + ChannelSid string `json:"channel_sid,omitempty"` + ClientIdentity string `json:"client_identity,omitempty"` + RetryCount int `json:"retry_count,omitempty"` + WebhookType string `json:"webhook_type,omitempty"` + Body string `json:"body,omitempty"` + WebhookSid string `json:"webhook_sid,omitempty"` +} + +func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) error { + ticketerUUID := assets.TicketerUUID(chi.URLParam(r, "ticketer")) + request := &eventCallbackRequest{} + if err := web.DecodeAndValidateForm(request, r); err != nil { + return errors.Wrapf(err, "error decoding form") + } + + ticketer, _, err := tickets.FromTicketerUUID(ctx, rt, ticketerUUID, typeTwilioFlex) + if err != nil { + return errors.Errorf("no such ticketer %s", ticketerUUID) + } + + accountSid := request.AccountSid + if accountSid != ticketer.Config(configurationAccountSid) { + return errors.New("Unauthorized") + } + + ticketUUID := uuids.UUID(chi.URLParam(r, "ticket")) + + ticket, _, _, err := tickets.FromTicketUUID(ctx, rt, flows.TicketUUID(ticketUUID), typeTwilioFlex) + if err != nil { + return errors.Errorf("no such ticket %s", ticketUUID) + } + + // oa, err := models.GetOrgAssets(ctx, rt, ticket.OrgID()) + // if err != nil { + // return err + // } + + switch request.EventType { + case "onMessageSent": + // TODO: Attachments + _, err = tickets.SendReply(ctx, rt, ticket, request.Body, []*tickets.File{}) + case "onChannelUpdated": + log.Println(request) + + // err = tickets.Close(ctx, rt, oa, ticket, false, nil) + default: + log.Println(request) + err = nil + } + if err != nil { + return err + } + return nil +} From b567a78cfdf3521a0ffa05840e7d287306e31d59 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Tue, 1 Feb 2022 19:18:10 -0300 Subject: [PATCH 06/24] add twilio flex ticketer client CompleteTask to close ticket --- services/tickets/twilioflex/client.go | 124 +++++++++++++++----------- 1 file changed, 72 insertions(+), 52 deletions(-) diff --git a/services/tickets/twilioflex/client.go b/services/tickets/twilioflex/client.go index e579c4d7a..df2b8ceea 100644 --- a/services/tickets/twilioflex/client.go +++ b/services/tickets/twilioflex/client.go @@ -15,28 +15,24 @@ import ( ) type baseClient struct { - httpClient *http.Client - httpRetries *httpx.RetryConfig - authToken string - accountSid string - serviceSid string - workspaceSid string - workflowSid string - taskChannelSid string - flexFlowSid string + httpClient *http.Client + httpRetries *httpx.RetryConfig + authToken string + accountSid string + serviceSid string + workspaceSid string + flexFlowSid string } -func newBaseClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, authToken, accountSid, serviceSid, workspaceSid, workflowSid, taskChannelSid, flexFlowSid string) baseClient { +func newBaseClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid string) baseClient { return baseClient{ - httpClient: httpClient, - httpRetries: httpRetries, - authToken: authToken, - accountSid: accountSid, - serviceSid: serviceSid, - workspaceSid: workspaceSid, - workflowSid: workflowSid, - taskChannelSid: taskChannelSid, - flexFlowSid: flexFlowSid, + httpClient: httpClient, + httpRetries: httpRetries, + authToken: authToken, + accountSid: accountSid, + serviceSid: serviceSid, + workspaceSid: workspaceSid, + flexFlowSid: flexFlowSid, } } @@ -78,29 +74,23 @@ func (c *baseClient) post(url string, payload url.Values, response interface{}) return c.request("POST", url, payload, response) } -func (c *baseClient) put(url string, payload url.Values, response interface{}) (*httpx.Trace, error) { - return c.request("PUT", url, payload, response) -} - -func (c *baseClient) delete(url string, payload url.Values, response interface{}) (*httpx.Trace, error) { - return c.request("DELETE", url, payload, response) -} - func (c *baseClient) get(url string, payload url.Values, response interface{}) (*httpx.Trace, error) { return c.request("GET", url, payload, response) } -type RESTClient struct { +type Client struct { baseClient } -func NewRestClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, authToken, accountSid, serviceSid, workspaceSid, workflowSid, taskChannelSid, flexFlowSid string) *RESTClient { - return &RESTClient{ - baseClient: newBaseClient(httpClient, httpRetries, authToken, accountSid, serviceSid, workspaceSid, workflowSid, taskChannelSid, flexFlowSid), +// NewClient returns a new twilio api client. +func NewClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid string) *Client { + return &Client{ + baseClient: newBaseClient(httpClient, httpRetries, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid), } } -func (c *RESTClient) CreateUser(user *CreateChatUserParams) (*ChatUser, *httpx.Trace, error) { +// CreateUser creates a new twilio chat User. +func (c *Client) CreateUser(user *CreateChatUserParams) (*ChatUser, *httpx.Trace, error) { requestUrl := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Users", c.serviceSid) response := &ChatUser{} data, err := query.Values(user) @@ -114,7 +104,8 @@ func (c *RESTClient) CreateUser(user *CreateChatUserParams) (*ChatUser, *httpx.T return response, trace, nil } -func (c *RESTClient) GetUser(userSid string) (*ChatUser, *httpx.Trace, error) { +// FetchUser fetch a twilio chat User by sid. +func (c *Client) FetchUser(userSid string) (*ChatUser, *httpx.Trace, error) { requestUrl := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Users/%s", c.serviceSid, userSid) response := &ChatUser{} trace, err := c.post(requestUrl, url.Values{}, response) @@ -122,10 +113,10 @@ func (c *RESTClient) GetUser(userSid string) (*ChatUser, *httpx.Trace, error) { return nil, trace, err } return response, trace, nil - } -func (c *RESTClient) CreateFlexChannel(channel *CreateFlexChannelParams) (*FlexChannel, *httpx.Trace, error) { +// CreateFlexChannel creates a new twilio flex Channel. +func (c *Client) CreateFlexChannel(channel *CreateFlexChannelParams) (*FlexChannel, *httpx.Trace, error) { url := "https://flex-api.twilio.com/v1/Channels" response := &FlexChannel{} data, err := query.Values(channel) @@ -140,7 +131,20 @@ func (c *RESTClient) CreateFlexChannel(channel *CreateFlexChannelParams) (*FlexC return response, trace, err } -func (c *RESTClient) CreateFlexChannelWebhook(channelWebhook *CreateChatChannelWebhookParams, channelSid string) (*ChatChannelWebhook, *httpx.Trace, error) { +// FetchFlexChannel fetch a twilio flex Channel by sid. +func (c *Client) FetchFlexChannel(channelSid string) (*FlexChannel, *httpx.Trace, error) { + fetchUrl := fmt.Sprintf("https://flex-api.twilio.com/v1/Channels/%s", channelSid) + response := &FlexChannel{} + data := url.Values{} + trace, err := c.get(fetchUrl, data, response) + if err != nil { + return nil, trace, err + } + return response, trace, err +} + +// CreateFlexChannelWebhook create a webhook target that is specific to a Channel. +func (c *Client) CreateFlexChannelWebhook(channelWebhook *CreateChatChannelWebhookParams, channelSid string) (*ChatChannelWebhook, *httpx.Trace, error) { requestUrl := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Channels/%s/Webhooks", c.serviceSid, channelSid) response := &ChatChannelWebhook{} data := url.Values{ @@ -157,7 +161,8 @@ func (c *RESTClient) CreateFlexChannelWebhook(channelWebhook *CreateChatChannelW return response, trace, err } -func (c *RESTClient) CreateMessage(message *ChatMessage) (*ChatMessage, *httpx.Trace, error) { +// CreateMessage create a message in chat channel. +func (c *Client) CreateMessage(message *ChatMessage) (*ChatMessage, *httpx.Trace, error) { url := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Channels/%s/Messages", c.serviceSid, message.ChannelSid) response := &ChatMessage{} data, err := query.Values(message) @@ -172,6 +177,27 @@ func (c *RESTClient) CreateMessage(message *ChatMessage) (*ChatMessage, *httpx.T return response, trace, nil } +// CompleteTask updates a twilio taskrouter Task as completed. +func (c *Client) CompleteTask(taskSid string) (*TaskrouterTask, *httpx.Trace, error) { + url := fmt.Sprintf("https://taskrouter.twilio.com/v1/Workspaces/%s/Tasks/%s", c.workspaceSid, taskSid) + response := &TaskrouterTask{} + task := &TaskrouterTask{ + AssignmentStatus: "completed", + Reason: "resolved", + } + data, err := query.Values(task) + if err != nil { + return nil, nil, err + } + data = removeEmpties(data) + trace, err := c.post(url, data, response) + if err != nil { + return nil, trace, err + } + return response, trace, nil +} + +// https://www.twilio.com/docs/chat/rest/user-resource#user-properties type ChatUser struct { AccountSid string `json:"account_sid,omitempty"` Attributes string `json:"attributes,omitempty"` @@ -186,6 +212,7 @@ type ChatUser struct { Url string `json:"url,omitempty"` } +// https://www.twilio.com/docs/chat/rest/user-resource#create-a-user-resource type CreateChatUserParams struct { XTwilioWebhookEnabled string `json:"X-Twilio-Webhook-Enabled,omitempty"` Attributes string `json:"Attributes,omitempty"` @@ -194,6 +221,7 @@ type CreateChatUserParams struct { RoleSid string `json:"RoleSid,omitempty"` } +// https://www.twilio.com/docs/chat/rest/channel-resource#channel-properties type ChatChannel struct { AccountSid string `json:"account_sid,omitempty"` Attributes string `json:"attributes,omitempty"` @@ -210,6 +238,7 @@ type ChatChannel struct { UniqueName string `json:"unique_name,omitempty"` } +// https://www.twilio.com/docs/flex/developer/messaging/api/chat-channel#channel-properties type FlexChannel struct { AccountSid string `json:"account_sid,omitempty"` DateCreated *time.Time `json:"date_created,omitempty"` @@ -221,6 +250,7 @@ type FlexChannel struct { UserSid string `json:"user_sid,omitempty"` } +// https://www.twilio.com/docs/flex/developer/messaging/api/chat-channel#create-a-channel-resource type CreateFlexChannelParams struct { ChatFriendlyName string `json:"ChatFriendlyName,omitempty"` ChatUniqueName string `json:"ChatUniqueName,omitempty"` @@ -234,21 +264,7 @@ type CreateFlexChannelParams struct { TaskSid string `json:"TaskSid,omitempty"` } -type ChatMember struct { - AccountSid string `json:"account_sid,omitempty"` - Attributes string `json:"attributes,omitempty"` - ChannelSid string `json:"channel_sid,omitempty"` - DateCreated *time.Time `json:"date_created,omitempty"` - DateUpdated *time.Time `json:"date_updated,omitempty"` - Identity string `json:"identity,omitempty"` - LastConsumedMessageIndex int `json:"last_consumed_message_index,omitempty"` - LastConsumptionTimestamp *time.Time `json:"last_consumption_timestamp,omitempty"` - RoleSid string `json:"role_sid,omitempty"` - ServiceSid string `json:"service_sid,omitempty"` - Sid string `json:"sid,omitempty"` - Url string `json:"url,omitempty"` -} - +// https://www.twilio.com/docs/chat/rest/message-resource#message-properties type ChatMessage struct { AccountSid string `json:"account_sid,omitempty"` Attributes string `json:"attributes,omitempty"` @@ -268,6 +284,7 @@ type ChatMessage struct { WasEdited bool `json:"was_edited,omitempty"` } +// https://www.twilio.com/docs/chat/rest/channel-webhook-resource#channelwebhook-properties type ChatChannelWebhook struct { AccountSid string `json:"account_sid,omitempty"` ChannelSid string `json:"channel_sid,omitempty"` @@ -280,6 +297,7 @@ type ChatChannelWebhook struct { Url string `json:"url,omitempty"` } +// https://www.twilio.com/docs/chat/rest/channel-webhook-resource#create-a-channelwebhook-resource type CreateChatChannelWebhookParams struct { ConfigurationFilters []string `json:"Configuration.Filters,omitempty"` ConfigurationFlowSid string `json:"Configuration.FlowSid,omitempty"` @@ -290,6 +308,7 @@ type CreateChatChannelWebhookParams struct { Type string `json:"Type,omitempty"` } +// https://www.twilio.com/docs/taskrouter/api/task#task-properties type TaskrouterTask struct { AccountSid string `json:"account_sid,omitempty"` Addons string `json:"addons,omitempty"` @@ -314,6 +333,7 @@ type TaskrouterTask struct { WorkspaceSid string `json:"workspace_sid,omitempty"` } +// removeEmpties remove empty values from url.Values func removeEmpties(uv url.Values) url.Values { for k, v := range uv { if len(v) == 0 || len(v[0]) == 0 { From add08bd1dc6ec0a61f1c542c4855e8b0404857a6 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Tue, 1 Feb 2022 19:19:11 -0300 Subject: [PATCH 07/24] add twilio flex service Close method implementation --- services/tickets/twilioflex/service.go | 36 +++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/services/tickets/twilioflex/service.go b/services/tickets/twilioflex/service.go index 1f558e3ac..3ecbeaa7f 100644 --- a/services/tickets/twilioflex/service.go +++ b/services/tickets/twilioflex/service.go @@ -19,8 +19,6 @@ const ( configurationAccountSid = "account_sid" configurationChatServiceSid = "chat_service_sid" configurationWorkspaceSid = "workspace_sid" - configurationWorkflowSid = "workflow_sid" - configurationTaskChannelSid = "task_channel_sid" configurationFlexFlowSid = "flex_flow_sid" ) @@ -30,7 +28,7 @@ func init() { type service struct { rtConfig *runtime.Config - restClient *RESTClient + restClient *Client ticketer *flows.Ticketer redactor utils.Redactor } @@ -41,14 +39,12 @@ func NewService(rtCfg *runtime.Config, httpClient *http.Client, httpRetries *htt accountSid := config[configurationAccountSid] chatServiceSid := config[configurationChatServiceSid] workspaceSid := config[configurationWorkspaceSid] - workflowSid := config[configurationWorkflowSid] - taskChannelSid := config[configurationTaskChannelSid] flexFlowSid := config[configurationFlexFlowSid] - if authToken != "" && accountSid != "" && chatServiceSid != "" && workspaceSid != "" && workflowSid != "" && taskChannelSid != "" { + if authToken != "" && accountSid != "" && chatServiceSid != "" && workspaceSid != "" { return &service{ rtConfig: rtCfg, ticketer: ticketer, - restClient: NewRestClient(httpClient, httpRetries, authToken, accountSid, chatServiceSid, workspaceSid, workflowSid, taskChannelSid, flexFlowSid), + restClient: NewClient(httpClient, httpRetries, authToken, accountSid, chatServiceSid, workspaceSid, flexFlowSid), redactor: utils.NewRedactor(flows.RedactionMask, authToken, accountSid, chatServiceSid, workspaceSid), }, nil } @@ -62,7 +58,7 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a Identity: fmt.Sprint(contact.ID()), FriendlyName: contact.Name(), } - contactUser, trace, err := s.restClient.GetUser(chatUser.Identity) + contactUser, trace, err := s.restClient.FetchUser(chatUser.Identity) if trace != nil { logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) } @@ -94,7 +90,8 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a } callbackURL := fmt.Sprintf( - "https://723c-186-235-156-219.ngrok.io/mr/tickets/types/twilioflex/event_callback/%s/%s", + "https://%s/mr/tickets/types/twilioflex/event_callback/%s/%s", + s.rtConfig.Domain, s.ticketer.UUID(), ticket.UUID(), ) @@ -137,11 +134,26 @@ func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text str } func (s *service) Close(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { - // TODO: Close + for _, t := range tickets { + flexChannel, trace, err := s.restClient.FetchFlexChannel(string(t.ExternalID())) + if trace != nil { + logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) + } + if err != nil { + return errors.Wrap(err, "error calling Twilio API") + } + + _, trace, err = s.restClient.CompleteTask(flexChannel.TaskSid) + if trace != nil { + logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) + } + if err != nil { + return errors.Wrap(err, "error calling Twilio API") + } + } return nil } func (s *service) Reopen(tickets []*models.Ticket, logHTTP flows.HTTPLogCallback) error { - // TODO: Reopen - return nil + return errors.New("Twilio Flex ticket type doesn't support reopening") } From e47f8708c853d3384654a0a44c0c831b028dca68 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Tue, 1 Feb 2022 19:21:32 -0300 Subject: [PATCH 08/24] add case for twilio channel update event callback to close ticket --- services/tickets/twilioflex/web.go | 37 +++++++++++++++++++----------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/services/tickets/twilioflex/web.go b/services/tickets/twilioflex/web.go index 7d4ed179f..861037073 100644 --- a/services/tickets/twilioflex/web.go +++ b/services/tickets/twilioflex/web.go @@ -2,7 +2,7 @@ package twilioflex import ( "context" - "log" + "encoding/json" "net/http" "time" @@ -10,6 +10,7 @@ import ( "github.com/nyaruka/gocommon/uuids" "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/mailroom/core/models" "github.com/nyaruka/mailroom/runtime" "github.com/nyaruka/mailroom/services/tickets" "github.com/nyaruka/mailroom/web" @@ -63,25 +64,33 @@ func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Reque return errors.Errorf("no such ticket %s", ticketUUID) } - // oa, err := models.GetOrgAssets(ctx, rt, ticket.OrgID()) - // if err != nil { - // return err - // } + oa, err := models.GetOrgAssets(ctx, rt, ticket.OrgID()) + if err != nil { + return err + } switch request.EventType { case "onMessageSent": // TODO: Attachments _, err = tickets.SendReply(ctx, rt, ticket, request.Body, []*tickets.File{}) + if err != nil { + return err + } case "onChannelUpdated": - log.Println(request) - - // err = tickets.Close(ctx, rt, oa, ticket, false, nil) - default: - log.Println(request) - err = nil - } - if err != nil { - return err + jsonMap := make(map[string]interface{}) + err = json.Unmarshal([]byte(request.Attributes), &jsonMap) + if err != nil { + return err + } + if jsonMap["status"] == "INACTIVE" { + err = tickets.Close(ctx, rt, oa, ticket, false, nil) + if err != nil { + return err + } + } } return nil } + +type EventAttributes struct { +} From 3b493796ba7f644d132e014a1f150c22021a8757 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 11 Mar 2022 19:23:53 -0300 Subject: [PATCH 09/24] update db dump to add twilio flex dummy ticketer --- mailroom_test.dump | Bin 1891206 -> 1898931 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/mailroom_test.dump b/mailroom_test.dump index 84995661677e6301e1c7fbd036512ff4f8d2e836..abddfcb4bbe7d2b4a0d1cc2895acbf2800a19d80 100644 GIT binary patch delta 123161 zcmb?^2Y6IP*MDx=G)guJ>1{(xLJ6DgOARV0y^2T+5kip?N>D+h*ek^qjs+<;1e9Ww zYe9+~Lk}V0z#h*ac*W{u8iZe>)c*x#_5+!ag8!58v&7+XL|ND`9;eS0NQ_Gk3>1@u^1wK;XHu^lJ9h76YiBE{ zm{@jezv5|CqXNf7ccAC{iv zV0V1%*u8(JzP2FAgz~ZDr&Lx>D66WNT4n3kslRP%MfsEwW6CC#SBks1=!z+U_R(pK`E1@oK#q!HRd9Gp+-ep4B_1#Tjgv<)b@#jR zmQFo7s=~0=y|Hhnj{Q5?)IZxeU7PqQUI&OC@%P#qD5_pmVC+4Gv*Q4!{)>>o<#I>G z;|s?w8(Cgeew`q!1!@t56GNJGe7-4C8ttPlQcP47|CQ^bo+=1_M#y3>u}8(?&sCL^ zD#urjo_?Jqj8%jr8Y$#R#m6u?f}eCLR3U^Y-X$JlU1D|u3hCO_xkA{e8+-NW(Wz(u zC~k57&>w8QdIq(w(c8N8vlaBNoLY4=e(A6Oaq5UM6%)z}+Qf%5SJlA(hNg)MJpOUU zKxVupPX0FuzpDS3YC^v`?Owm$Y1|%GGHTSc88fC&t(r2SylTXlDAvB62KMjNvtO59 zJ=OlxF_36!I4q3Jc3YSJx9IY#-4%*sV*yuO5Wp5z>VK#MH~oh*soWu^&o(I@b)7zk zf}y5hrcaY{T(2W5sw$>T7(21THg)WTvE$39jIA15G1XQ+aineJ*r`?J6Gv3o?x?7` zyP{%Zk`3AzJ8|^XkkSLag!~fThOP+pGGkN!F-05H>v4Dj!=*SQ<@;i^ z_GHk}9NH6bNDbropyc)Y-GMXm6OpAK%?$L}mma9L%rh1bY?4#DvvTUc2ti6bKHjA# zp=XUAJGD$T(9so@BPvH$OsTEGyP&~~nnVzWHMu^36a@NMG6qIa{twN}yWXhs@lz{! z*DBF;%B$~)(ClNGv|*skDx26ag2VMaVOZdMOJ0`NaaYBZsTILL zhPSucDDJ>VaziI?6u%wvTA|~hCUrxv#b`P+9YSv1jEx4P{}CpW&lK1_NcMkFowHV@ zJ)RlZaEa?II|zEcwr*$Ix+zEyt|wjMshF6?mXEhpO`oJ0%BX1*M^ufioETvkfsxTE zG^n$XTt`)t*X@k5(u#OFx%$oM3Ne=T$1xS-iYd9@UOl5XU=osZ_6MQfYan7+d{v*>NeRW8c0VZy#JpHBDnusW>gB zj?j-X(_->SD2Pd+p7?`UR1_G$D~s~7#MnUHk?CRPmc7bMM4#PJJz?ZhNgd8^86;S< zSDVv1tA~!cL*{*XjX9NzFVq5kCZq($rzKZ=CfMt+BH3@47jajwzh@2aW%+NKQ+cAA zvL@PdUuN|uGjCHn(^_+KFL%|c&z_jn>N&IOM02^OZWnOPi z?yp|*)N!?D8%(K6?A0~TjZszhmN``kU*!fCtguzDTxwINifVImM|I*$cdArxn^QTf zKYjIg-bHiYF{g4xWd(+=&8}{+>IxS#d!sq6yL#~2j%ue@%&9!pzpZb|wVnO0IhD71 zfHIC_(R;mvld`YG7nK$`@?mCm!TYg>Ne^=}?dl(#RD2Fs;LwMS14}ktQhV;wXQXH< zYZDzWK9Nb2|CYQIw^ncmwr|P}6#kYJ*s&=!rg-c~=C=g;98RZ&w+TLS^fTajs!wme zHGGl^+`dVlg?$cx^;28lGJN0mUVCkZ_BkCi`5!4GaBOc94XP5e0=qtXmrE7cy4fl| znMr$k$?0_0KT?Ch*e|kZ)if~^B*}qRpEMLTdAKuCJYI?pZ2d4kP_tS0QG8BUR4UaR zkIAUs_sKQ`d*~xD_M;G)H!6pd1rC26L;ifBII#4KoItP7*Qx61vCLu>pJF~etsf@H z)Z?6#3t?ty`~!18E)2~3#2T2mHCSV(pXOFdsnuh*-yFgB>c!!*6tC zo0t|DdOn%9EJFQX+?f^_^yMm``owwaVkyvj=i>%uRR6qW4J`d6$j#-g?)+7G1fw5! z1i!nTfit`Qq78q>6w#G6mIQG=U!XLvxyy)plA*e`2h{?v%}N{AT9T_9?kNxNO@TeT zg5aJKI`XfSg9bcF`IDq#vW=4C#RaP5^xMBudZ5j>Qw)p(2WzZ>g0DgV&OpX@AB7FM zfj9StK6_~E1t~4i=KCW|({yc&Y!w%>HPQkDD7|n2(x0jcO3xNVwDmJFBXIc-Yv7(A zz6odW{GUNeZwZ=zYqcel*8L~N(9Z9KB$_i+$O^3AS0`}h$8CW<-}uC*gUuhfx_^nr znDV``g>-7JYz>^dnjEV&tGC*6plL*7thyYmkJm%9FG|^g1qV;4<(mqERSas=JVIy~@VTO_fo(^VY2`}MN*8;G=>dQQX8-bP zm=dUIoe-$c8`yF*hbA3F?Rh`6ortF`m!x{s>rL^Ffc@yrs- zTvku862%x${eky?JEwkpBnI_d#OvAavXm02_s2*>J&S02kJtq2mJq6jza%P?dhZl- z(1a(#C0|!B#KZ5ZwjVDPR3?-NCW|?fdRL^Tf$X2M=wb&UGhjQBADH-4Dy@x;O$i&# zcpJ9+6FOd*XaMz8zxSsU(Taa%2Rn`5AC*MHRVk%9`&4>FQ{-j`ar|}%UArRX1(uvS z8<>2#kcuCWqMy!>7b%kxqvedi{TsPY>$AZ+AtdQR1iQoaaciR*Qe{ zF)+hoCg*=#%#hSTXsGo|e@S5ZzfA(e&R?RE0kK77dsa>mpUos8K}@F;SEW1}_X38W z!~a$o1zMjbSOde(=)Kop;;7zmp)x`*zn#?e;>UJnCT+P_N~P1+q}ae?7yo9}4y^pk zDn7@St!oV6f%Rvtfvoc(Y;W}!mnB2}*f_2Hr5l#tv8YVQ4z#*5D3a2Ms*Py4_+8biEi9(BZ(SRx(#gR_+vpjZ#>*3Os!wM2D%A5`VHZAp19B zbl{J@#f#PpakPA&7(+GDLJaM&2&*87Pkcce>b6D;x;5}RFS}%=iYcL{blRaBa89A+ z-q={>+ZdrBoIU+C&Oq&q$`4V~q=s>_GNz7T5vb$8ViB!PN9!%&{rp~0Zc0~Li@c*} z(d=X)fvm?Zb*Oh;Av)0Pa3Yl^!l)V3u#R88G_Hu+cZ*J-4;F+vtkWGx%t;|zeZivq zQBUxPH#PlJWf0L9mCZyQGHw4yB|4 zdUse8Q}^`-3LkZ_$TnJ)D*UR-vGq-`SjIYP2JKH1o(|*iFXQmt+K`ZLI|hN0zOgCF znRG#pXv~^W$vL0{fMy=RLVVSmn11si06 z53-yO8V+u#v^z%_946jR1rRUrR;YSzH)Ra$rg$iHx%kn`=&y@pbqF-zEGF_sjYH#;hrcJpi!Z4oC#`QP z=tHMUvbG5IANhW$Vwl*|_!bpyEfCJBeTeNji7#swyDt{5WoD6}dna_{5(dU6AA?C8 z-5QeA>!dzUz-)WBgiRzh6AnixbIE1{t(W?EgcJp|vY^UU{4x5}D_XZ*UI#+rV?Ekj zEX>r~k1MkGdQ`F9M?yRu|I7gBXUwydZLNeGBlx!eI8;}kJ1UpeHJ>Iqgnz@k{EIt6 za6X^1-YK{=(OP~1(O%^(ygXiRMi*xbS+uLJm`Te$Lh9nz7NTkO&0-?iT45?U zQ@=hPb_P-vz5BB0 zv{xuMAQsV}m(T{E?lvOMe@ZG+e)U1E;kEmHXQ+05w^GttC>1dN7EsHUa-z7BSFR>Q zmT7YvVXMZ3f1kfPI+2zg4(ZVE#Q+T*rqkB;Xz93i7;VFOrGKqaJM8#}0a~Z7%SJyo z)fLbWcCdY&vwgB2M&?5ugoll6uf78(^VAVE^F!Zhk~1`ivZquy716?tzBN$zXm)+M z5jDR__>VJ^eh?DHRh-eB`f@{>-zjLB{QL4WOCpWkv$oYxXz@VS=!O^NdkgUcFplU{MX@ygI-A9Ae>gDSi40{URFl&RL>5G7k0e}mV%OQP(jngPNadY9z{!~4Prrrs;6?hM28Q+bkHs8|O=cPhvuzX9Y;Re84u zl}CHx;*03R*)K(Iu+YG~h$dZyCv^F|Hi$4-rOjv|A)GC3 zI2RJkF7q>v?G{n(Jnlt+`Q zglJ{W6h4<-5`1Dc$2yoM7f@c6@Msu}(gp?qk0EFPK97<-O}H!EFACgT79{XHX#G;D z3FXa2Lw&dgexm&`q0Ztqwy5wzX<)tKmdKfpP{&7Uc>4XaOSd(@{> zW=JiV9~#o047mwin<-ohZ-Z+oqTYK;?6fOGwv+7@c-;@qg7>;GTl9(VaPF9iidF6@ zbA)?!eu-&tmC3Y|T^H%%ezva$_|JU>(;YhebPR0k@0#QUt?}vqAfk{zFXD&xu zMTLtpq_Lbp!ykqyXb^qzq*BeQ2W`xX3vn!Qz{A-vI67bDIQ$2pA>3xU0rh-LSQXBD zvTWe(R!Sdd4iD!Y*A^Z0UCtX0k4&8&7n-Wg!Mz^V8OZ)=Y%=v)j3Jf(HtETbx5F_m zz^JIMWMJV{jy#EpB3y_Oc_9jiUFk4SDA9!2Y7wpCd!e@IHBZP?h0xog4wcS_Qh0;l zx9|oLI>JiD;|XE*V&-d$%{ftb-3XBCyqd; z8NLWUDR0Zm=LFRdY5g%7nqZbVF;|SHIVrHvQxB^~SZA?CN~*;MEdc|%H>zA7iEcPa639qY@ea z22_UxIQy^_{fwHfJ{D3S_MqoJC*;zaEHQ~N-yBdy%DWY#=O!*v>pd8M7Ca|Bpj+&Q zK~fRb+-FIkIrkfYT*~a{h0KVa@bR3GNN_R=XzugqTzRqZ17Wnul)pr{#5nrI4>PIH zi^A7B6JDzhM=_fWo{mN0h(`>Z{L1{L*Kzvc!4M~x1DraJk8Vu6@5gw+Ih8C|IbqZC zF{pbp)ud%*$KZP8ov z9lb&&jWFHJ_!ut81)HI~Us?E?5Ff$e?%d!cS>kp?HKLBIFd^-_AC3U8OWy0k-vmIP zxRvw4SW?KWsFX@i#3rhQssZ6Y!#WF>qF8_w&L$x#+%76!E#=V7l9-^px>{HkAB%Ce+VbP$6e9&RVr+HWMg}cPdV|F zaJT;V7n@;mbqz~f%6mhocfF)_Uc`8lG{Yb%mV|4h6eWJ0kRBmq^*tdnkApfim9uGS z5_>0Ay{x4@q1DE-LYnfpumsp_l~R=**qMls_Pv<~X}y$PfF;1J_Lwz4NCgKj zZLdVhN>9BlyvUlmkiMD&ckg4>cCiMUA zAEKo=>ldpO+!9{=RLj-T7hj* zQSWWSCn~SSIpFmf-+f7^_??(3$A2U|6vlAQU@)}JGvIlZp&tt)!d*c6=*dw1{m!Tc zOjj$u5E}6MyFVAcWekgGelCpZbKVzgmWeWb&`eID@m~nz!+1V93_ROB6KZwD2PDzL z=5mTMc>8q*^6S$f;ru?D-y9wc?{X)12sLUwhuKiiFGBUykD1i3hwlKpdd~(?u&kg~ z)oACOudw_MpJTpT9F)po_e7=Bw$+HD>b(a|$Q6vKrpe;f(J@q7BWzINdq0ofww>1* zb53wQ(=i_X%r+9NKYqc$+NXHG7CwsLI{f(%MTv`s|0*b8^P^Yh)dt>j!9ei=j1 z=|jSZFm~50VCPK_&chA|mSsN)DKznC%mdACg8B?2?f3W_|B!P;cwOZ+d@0)ZE6xk! zYkjJJK%-eS1J1W$pw-GY4`c7ng#Y$!r{E;-aF#?hpbbYc9OV8I*}AK53WRiN(q1j?Qt zqI5fHt3z%~$2W>efl24F_i!2>+!ucf57Dv{aAmigf|ac~&89R~t#eO9sv2#eOgJM1 z2TK06bTOQs7DGe*7H8jSw81H7DxR~#j}g6T;Gj@%a{FlOKk!QL`x~AWu!?A28G7rU z5T|(B{g1FHtlEzcgPDw88mf%PuGE|pu-XlCQSD1Y4efEl<2bZg%A}pl2c&OjM9IBYd9hE})ed5yTjsJu{!uid4$w0@>b~xRb zgIJwl7dML*T*1PzcQd%7x4bEirBa)iONTGOrQmIa*zW9euhoWVT+K?18WH<6+D@?T)F;HnA;xpZE>$OTjbu$@MTb6gR=b zams5UdA$xy^?lryR9bOO_&K~@(;9~~?RC?c0;$2G!{Rknk}*?Z{NJk6dkX}*=?%rj zJk%8Swk6+C+)K;1N!csmBt|sWBZa|*yTeB%K6Fh!uY4+t`)J{FIBGjw5|bc)7aC@c zTf|_c&X!_Lce-h)y%E!xM)_BwGnM)=VrPRruvngf;|zps;~#`si{r&;Wow)mxjU3G z)tCmhWRb^YKxEKq%Yjwr?{KTK;yVwrF6`cEoJ{-CPd*YgzKLPgi zJ+BO3NIz9qd^=2aSyN!zJ4c0h`|X;bw5~oDlQs3k%PQ0dt+8U=$9u41fbdaiMM&{} zhtk+8BBU?OLabE=3O7BHBwnC}lhD6DoP>4$59%s-XR;XFl;j^5_eSSiGucqotE_Jz zHi>A_nUz5bCnovaR2{tcG}Qe^UUvj+$TT8Nd_)(VGuYA#43U#m`r@*_F5s7 z&Hn}u`xTY{&d;EwgIr6!Iv5VsGbu>T&(qg76EnlsKEm7Ra|4H<)n8g};O&pfQ+byb ziWcfDiB{!?0x>H>T}NICwHjQYj-_%7RZlDohjHFb9DnQ6=+lR|bZO1R4|E0bZwqb* z?}aZ1hal`|^!^G?Y5(S8iwJhx-Y~H9Qu~fFi`{BTy~`lu&=w*#z>1-%W-Y~4D&JdP zLw7&Sk4$sumX2~VpU~fMht=)OuB%^?JR`7!28_tTAx)E2*u-k0a#z^7Rk} z#Lu~bIhZBlNmYpM^HKRDT!@##YW~z9ppRT>DC<%xonk~Z9R2u~fx=4}Zt<83Y+H-b z=Vx`Ks2Ktk^!JEUbq?+Hm&uGCm08x_zQ2c znj5inM3hD_wrj&C^cnd*OfW6qF%)+xKeQ1~%Hf9n`1>IWx1AR5#Xd!gj^crEss`_c z`W~)lXcPt#W+}V^ z-iQk9Fqa5!Om|EuUv?7@a1m&Uf`0rv*G|8iLler)VH)y9F!<7(ftT{WL-fsw9^zi3 znp$L<7D0(yu!jOA>01Y^OMTTDA%wZTpsw+~#5JnA{1IaOp)IzX+#+Yvtlpv?mdgBt zf|ee81o>CyA~Uwkxtdlm02(@pD^O-!>$-z4i6Sz*5r${!4tueHRyw>GDe!rUUQya zncepALc;M_bxe8wQ?-0m&1%*maUfLjfrD1yV4;zmuv zNCP^KOIv{ zkLluhO~^=-`itvv{ZT=t1vA9OVRHH+t?7sCe`H!#NgIAA3A-J26NP$1v__Male?NBs?TG3s+Gf%aNC#{qum3cKfapV{D?kG`66xS9B*^PJIIS46N4*CCTj~{q_O;N&Q!^MTwD|` zWA-2BDt$V% z#lpLSKhmO3m`lR08BJasgJ`>eSU*C|f0`EamM9AriSe46{gI|~L7Sn92VjP}wm8CX z^GDjwNpn6Svc_F`Y23zR5aFG`DA6Hvb0;Yv}KaWA+&KJaBOcw8- z{zzLoZOY82^jspIj*#t)Ik}5=FT-TLe5v@m+N}OaySd2i24`-;(>*3rOMj%zoHb|S zQMN4?UyEqXzs%|V%KDeFTV-5oG|&Fql-}>y_$oH+H2EX#-;a9`m*1jf=HJVo| ze`GKI$DEA|J2-vT#x!I%sV;^ImwV2q0iF7~GnRKv#_QZ265nlI8uuJPvU^>%Ri z!07C5d!&tAGUbazBPld|sGP2hdPmHN==ztPVrevd7`A-3zc2o+ z$r)*Qh>A|b)>HRkau#=Pt?6)Xx%SI8iS0C_RmCoeG@qR;uND8tHl7?FT$E$?FCBKb z1J?%GC1|Dbhin?gZRH}`(>6LS`z!Mz4tjjE_z&Njclr3GGKV)(tGmpLJC(y*#B63* zg@Tc{*rmKr z#M8WtCy=)@E&FTpB3_#GsrV~GA;{VUsk zA!^hP$`gdN>~Bq}opxHfUHpS#+lgHj&Hc`twj?T<-*3IL150Rjfm3i({(Lzt`+M^; z4yET#Y=*1XM2DWX#1gwJn!C@ugcJ73u6WYlUy0{%rJ_(Mp`Jg8X}Ld`mtp4;?7F5h zYBxF`D@4n;#Kcnc6^k|ZNAq%S+*an-CV#CFFK}M&!1BM+viF-8@lgA(#mkJ=<>g)0 zdEMailX+<`qLUD}uwgGc3X^ie9!u;ZLPDMc=4E_v+Vu;JarB#4vePg;HNhrQ$noZ9jGi1%5gSQ>qIv^Dpzc{vA~92fdhY4mQf0ZqD9%BHd> za0{x#e(?j9w~yu_PA>O|IR?Ay$F9>UcmISTj9+_#v9!BCIxY8S^HRwBfyn@zo9xp$ zA08T;M6QG4TPlWw?2iKDb8`$g%%7!XqM?V_*3zZeW@PUwq~`u&UW7#@@glb6ax`r} zEMDQv5GKHG);b-LQ}0o8C}a##??c`2GqwtFrOi$$+hJk9nHTY?SE%xpPk+G-s8-WQ z+ZJN5{@uKkUnxB*c4x*_K;2eF$5HIpVDpD*0jwIa*kt#*tn1`xrT*_?UA6FnqPRHv z?q?J}W?r}i&AI1+*vysB{vq;WqSH-R_Ms0PH!s9u)%i`UGhJ|#Eb};4(@b19!hgA5 zosn+B33Dtb;bvG0tvDfG;#ID1kBhN8osskSDRTrDq}lUqY@U*LQcMuob)#Z>=r+lPO_a(S_ttL0c4RS#p+U*^Sp*gq;Ag>dmne~W*ya?PlrS5D3T+q{UMKKVyH zL%5PtME2&GS^qJmMJ}>j^_Gt3Jlh#zw`)8eRV(gD_xPMSObPZc7QPnSMA`kXn6JVh z3kX-PJJQt7o5MJ$?|(QUF{oD?gxY6v9+~&$q1NgL<^T9 z5R%snfY?jn9qQiOr8w%;UrxvsSY&SPrJYcY@LOy?q175FhvJ!DScEnVh4*G}q^~cU zW3fdx5AKH~ZPn=g^>wAs)bQ&U4v2A)YLd*cSo#V3YHW#hPJ-jMr4c6ZIWq1}w5l&{ z=YTGkDxfdYYGrd!&&ojw@I6@lT$<5ex7TPfFYZ+~StVEy*N?ioaQY%w7t!YBStc7H z#*#)iCrSVEFk$!;TONcv9b;a|&m!8BSqyocvME`@LWqTg3l3WMD5kDh(}F%b9FbX9 z;fSvomm&5JX)i$!d!t;XH1IrS|`^xN;a z8qqfJ03_w?Swp3V#TxRbChPiju3zaccRvIJ9ec5A6VRpv`&Jp2=x zJ5=RFjsS_~Ed69}C|%~o(L=Z1CZ}a3nNs_ery5C^JbA2Xw;>p;&Oa?CWT%=JC{e6U zB%4~osT#1WByvON3)8XJPYat$R|tWp#l?>62K+R0zD@n~YDacPZj|mY>nR?43BBQzex<~|QZaQNBqZhLnX_`R zlf>;Y$#l&nolpZ1uT;gv7DskWn>mCN-GoJ9(kPE~Rjr~AEeZS1H!sAF-Vi#Ot6%|N z7o8Kai|Aa3&tYSup?N7cu7|LIOJcE%$~}J8XLwO;VZDJZtVZTVJ<9pkth;b>q*!dNGzfCrP6C0P=FgY7N$^WUY_MMV=;NkHyv38nHYj|aT!Lt zBJ)Cu|3>CqF=B01?z(QN(9FEJ6MF)sgOSGS+m2F$#a*PvEEFw{CUn9$zU)S+i6NdX z{uZf~`k&)FNiLP6x?Xj;BiF0V%{j82j6K5meE=$`M9DB#2`k{W2^#%)ACH+dqRzhZM3FSYH8{>AFy(tI12 zC(=e+nU_}{>n6piCskhEaqvVAUv~5Iet0ejGD@L3J>a=8F}zqwsMUy6XNf6{!wzk* zs2*y(4^D)w4V%@EWBUW)Ix$q-OG>0(Ut(BZ!y=Wo>YdlG{tVNo_)`7dX%B z=0Ary&ytm&^n$tZE`U3cYWYajJI%{GsA(VRj8B~XYDB8uV-CZv+#qBmm4@7^OM~kj9Ok-^53f0l2QVyT zg0>Hk)W9q^5_PCBkxKQM!(in+Q1WT5;?uNx-Ezin4&bBrZ-=wSd!kR%>UGPR*5>8? za8t@>tIHYW+L?o~>G$7pKFIsup)&Y$>lec9|6s5*firR8=7?I$NS(JaW#VLU5e?|Y zA)3j!{hGqB3lwN;j!~kV8Oqjey#M+&Rr@1V-Oe1qL3fm4Rm3&r*ED_IvZ%dzc{X;R zeH!}tdbo57M>I`+ZX9!QU0ip1+QA&gg)11sDP$5Vy@M_CRdM_pPUJRXsW}dF0oY-< zQZoXxza~!!$GQG^yQ4XdhfRYUVltIGD=@$_9Jkx9I$JoSj#!c1V2-2g9)*!rlS5Un z+a5VQ+-P3j2k(iUzf%7((p3#c?a$XAsorD`!}>EjC8eEXr3-3p;3=?Ha@!+&?7|HE znV+1pA9j9ZiA=zH-e&4BPHLdP`ay%nNgLGLLP*dY(=dG+dwK`|0=0u6iPoU;Qj=iu znkQia&ySaea*QH%1{d8r$}=V?e(F5IfKt8|C`%_wb?NB|(o_zG%*@eE#J)Pv;@W|N z$(N~eqM=fcPn2F)1yXe*R_ZvI557)wy@LYxQtPg|W8x(pakUAehl=l!3WH5K^d4!TT3M|xr@x|!w8jKL z&l*fw(+#!tOqVv|XoqJ9j%n=HLEfwt#E=sBwi!}`Btwt*6@$Z#y?WE+M-ch3bcR$D z%=Bw%ByQAU*VPVN;s|WGkU`(wD|kKNA&X5wU-Yj=cU+L;hjB>jy#B*qNTIgj4}CgaTD>Xj`dFMSmEh*>4tkk z3Rk|GEg?9MCDJWaM}clDwTdYwV4^}^8H#N{kVaKqx}q; z`hcM&pLzh}6+Z_@idz`ZOFGD=+ChS;ZE10(L2*AlC?S;=!zt2iW{VEA*#w9up=}lH z5?cb;mv6pAu0z8I#nz#%4@(PGxsYuZmHSDD+gdxEnq0QIvDc5)dVT&9+_1awh%}g= zBNH;l#xzb@rM2r*wZes7F(T#B@K*hRmOqVrL+2lr)K>tC)Bq&$a~<*HS`q!mWU*BL z@o>;>Dkwi|@Ao*6dE!?jh(*oYf2oQ3sR=Mo1Y2zEC9iV0KxsV-t$RXxhNJ1GHgcB+ z_gU?5p@gyjZ4Nas!D*PxaSFBeomQjaY_A>1Pe{z#HrTv;j$QXOHWv=B!tq1gJn2T5 zD^JE6(?mSAPXqhX1S~8AE4@E2Tzvyn^(}7{U5Dr`7C=82lS9^gsgUt#rft-Tb$-ye zd{qk<T{~!4a@CBWK6)>d zwsQp_Zz>efUPswuf}&?kr8!R#U^z7BB_fCsyPrdbkOf>2UXLWh6aR! z#nSI8fTn5E0e&z6;2BF>1Xa9%VDc)E>QEF7r;H2RAeb397%KN;?*4Df>$XSR1FzURfm!q{~ zso6q(L19iWmF`r_BfBT2v~hy=cIxjY<%8)vhp#eN#iX~ST(WJ9&7g#raKL4K83&E6 zmg9g6Sv%oRKdPf1s}&W=K9QueF@5-wp)Y;;GG-1W(o8l$HeDgLWWf#aTwc{7PnbXk zb8BvT$sp*#71DS$3IiE5QD0=>VynTZsFStBgc4?k!L)l1_2r2(>%WA%o2YLqj;dSF z-#YAR6IeuBJc+P@$Qn;jWkYgH$YvEa+5V~>BqHx56VTY-ds5BIIK$`pCqu0wzlwuk z9-@F`kznH0nfznIB$)nj{;T2momKp;(NI9ms|cXrsL1z-ZrEQ(JzqO221Vp}Y#LsF zv<>ozu_z2(sj>9qYe-ZBVA$DY9rJ<-CT7v4z^p-D+hG6ZnCs)mjNbK8{Djd(< z2s!8Jm=|lubh!w58Z)si|>3Kw4lnAJFemXRCGl$(RMhZl>6ls`3Heaou9vr(l=soIZ-@H;S5!Tn z*%BR1C*IbBPLTf%a6NRm#M5GEb4(GEkm)&(ctQL2dI_@?)zhtwz%PTnM~VL1AZg!|8IHn&Gb&8uas#4DJ{?mF&6slaHN2dK|yQ2Vukaq+b2L+NX_ zX@F6;ua0D^T_7YwLGrmAy0|%fqUfcnn* zRPx#uxNHojxi*wNspB-M9S5h5T3WZ7t>MFD&!BYii<@H=n7mDD&yF?<^_`bhI&^^v zG*9K$%Ba8rD(2~J(yhUiZ5V?-(~*j5M+)X@qjevJ3%N-JoIM6%F6Tay_|3QAX4o$} zY%>#BV`ew9ei9BnG6iN09L(V<$IKzRwN(^6=_TLzoJ=-YsXek*X ztN%H@d|Q+9ermJJaLO=lm-M0vps9bO4$!`K065vV?KT{m%-tS>g7vK z%KPc|8pBRKMptA}*@bapU0=M6_C}5L2!ktbC7#idZm1mz@xrmN6)ot8CO;sh7`x~k zfhO-l7yWpTG+PbQ(Os*0r?oD6W9`6d9n^;YySovud4|jPbk$q=wac6=XMo9>*N(WK=;}KkfR&9osb(0Aw5BqkW^(@ zw^5Jpp)Yp6-AoG<5+Bu3dzzr~v`nG%uxsB-)f$54(yq}Fde@HNL+a=m>pH5WM97Q3{zGRww8LKVVsLunfUD^M8~&aKIK?*pS%ntk!n@YX`*mt|lfT z=YGRb&~v}^s3w`F^Fcb$023fDA$L$JwfV`Q^ILzCrl>GlERcA&4l}S;7#B8NQx6!d zvC{!bO?T7Gt10;z9br)I2-s%ndN6!XY%0w;ggvnZ2jS`SCf6c^#0@&u5ECpn^*UrY z;lB5f^tP&BEjCEpr6UZh9l@a|t06iZegbzGLk06M4_`y1?yaq1}cMPw2qI zYX=Tymm%xVp@vhM|18y0<2IUU0YhT!Us@NK&4N)QR=g!XCsh8(F9wzKDS5{)(hwXy z7Zho631Sl+ORb-FnL@ll#~WEY9tKl2?F%98 zOI@0G)PVc?QR!ojR-^?Zi9hRTqiRQU(|f;#k9Z%^@)KA}F5eRqO?7^k6b(;XlYjoV zrn51%rj6t&dKcv1|Z8hupRmZ#21W(UwLJg0FS`+Dwwf@#j z3q}%!e>B0y*A9d;C6CdQjUT|ERuU1!!P!f-w*H9o!9L`jVPR|A-20)TDu$?M_KHI=mJ-?|nw8}p2mST{bd09{x7M50U@SL>(77a zK=;=+rua*+uVlqwGrFR3MCHheGHSkG%ARf5+3&9oez)7I#*VI-QmoTWt*ELRJ8|?> zTj9j=2^B4D+JCpOO`ABjs)cPtc~!;e$|=+FVNB(es!#1^Q$I`qVc(k~~zG%B>`A=);2Wl0c-MwX=+@$i+Bg#gX&!9asr3PaS zh~2`)*XeA9V@EQfC%3TOT{(sQS~XJrrK)mV#l#l2u~W-NOq*I&IpKelnzkKb0oKX7iVmtsrT||eM_w4cI)5&&B zN;R6&oa>|mRmBurp)O<@lZ*e2|2B2Rn2HJIWvb9s<#&v)(0(6PQC>A|N=4a-%JI`C zptI`#T{V3Y^gh1gu8Q$6!7*dUk1QkS3Mn!1`nsI05DF8uj=NZ|l1O`4*5Jqfmh{I> zS)vu6AW``=cno*_BLx#3V;2gMP2;8Lz}64r1)26(K+xlyq`wG&L8ve=?-Q$FVQFgu zTaQ}Ee_pyfl>ViV>YvA1VKo2Tyruy?{tq0#cK=E@7->#2nwTKO@BfBD1P<0%g;<^! zXKPnk4vgKGL?6~cf{2qBq??Q|FI)gj92YWhb$>41+Y&^!BDo3m_#AoA5-v*hHF7l; z?bKY^D#UYOq>&81=*Q#KdVgoJ3UxG*5L@pC)PhS=*D!!(^#rR>mjkrBEKM+cE28Z^ zViWL|#frK@M!1X(6!}?1lP(aZ>J8X_WR2K?CZe@_4y{Hy9pz z4<=V)-`M-^$gPe%JehRhnxwzV&C0Ro*@Z+V*rE?*J$ow$WN)eqNm{*R!o%n3G`J}$ zw?mM78aM~=1UgbcLHsuK!bP+h7yYz=jNQQp%IS(I2Q?aek{oXVu@puVB{|r=c<>T? zCtOI;zcrw;vzBO*t|1+EX$%s!pAnFym;dce{!Y6r*VC26i?cVvg;Z8s7nND$(AzHT z!E*MzH+wE52N4hP*C6en6et?f#!>K%Zkl;pNLmibUfo9Zd8eM!5Kmx?JBOl>rkBo{ z$M~>UveAg!kSh}pS7(qlRxS@CV6RsTnL(Z6wK_d-Q|MJ{A&b}i#bP0!9!rA0d8JR) zL#6SgbX{r{9}YbHGu>_w$7Hq2G9;`11 zM*|iu#GWn|@;O&LGF-qadlT8;l9OodapciHlMrlIj>jGz#!)2i#6R`||Jhb~v{5$p zY%t!VW-7qry&HAK^XwAr-Cm)w%IkLUYTH^DyXR>nwmqCAH_+*W@AL{tkHAK;!O20n zdBg~@=XG(83rRBAi@3p;(mAK#W4J;AXNL!C;dZj|1wF01&IF|D)3rh&r^4&8X=F>4 z2k8Xt30a|tf5W4$OM`#bpK=wN@o#vh>FKa;f=@HmBDs~}S;E~-LW2n} zz}_Jg{2UBVSIyAH=ik`7e?n`u-{W1rERMLS9>!0!Zam=?zeHN=i;#}J*N2&b8w+06 z3mOYMQ09;732m8pji^lnY>34b$zyb$oPxb>C$yupUn2Zw{70DJ_!PqjZO#-Y)OaWf zZ^ng{Vo%fw?RhQqN9BS!-8n3KPfqB-V4bWMcZ8K@ufGYUyfj)nhZeL54deVAmc7v? zbmRp3J86&V2%LaDktW>03GkR%W7_IM6FyKZ_YI?B&zcE0s)G!kBhv>*_4JcHLMGhA zA;RAfqjq+Aco+_QFihygNEercQ20R}d+1B(%u$9O$C$|{lr)DNd@+Diu@}08n^oT- z;S9!iMz;8NG;E$zF4lQ*Quc_}!aq`Jy;Ig*4E4y6J$DtDEu;nxe<;%<7XX4v>*F)K z9Iw7pC3NM&bhwPU1Flkq4vswu^}#3v2&B@YtC006PsnP6??DBg!=2MP@4>^wJ5ESS z<_*p79D+UO6xi`01RMq$yuOsH_WMb#P@4rlIr!8XdyFaY-p8qIx|9ho8#Ps3w_$#H zV3<(sjiW%x7iz_FT8DfU@bd*cJ5z}dG-MCOM@>=mLwN)1G2ZwNI?#fA6z z5-M1~t=vWD!U@@HLV>t%Qwa|RjW>Mp1$yn%O5-`7;Iz%lum^hrzwS>ZXfuu~+snFR zryf+X_jCel52X^Gxmln?^7A3~h)rPi(Nw|{GtE`~9FB*6)_1_u48u$Dbm*?q(73>h zu(xLdKmVQz&s`>4G2RFs+%eP*ISG3LCh)|GAc=)$IdB!V^$ihp>@kWsa z9ib2BqGot-q>-)yek0pW6pf5fTf3qr+|xmea58KJ>TiD}6tu9Fw6KLwcD%Hg7S@G( zfHyeC8BvLtUogBkI$eibLSqKoZxcLtOHXLZ)d!|=?5&B3dRayw;;er|n$Se2h^3QV zLKA~Zfsoc5utk5X3@_@T+Zy5n-Z{vjb=|@_i+Jz;;il?W{?foSV_BpM*;gnuV@dvX z@1O6;;qP}BqcVEd{50y>Q!X=>6umBrbpsb^>w|+UlQYSyo))poaq7P@Y`xf$uJj5z zlbna>_0gg>K;Q}6xAvCjgyH+_^h^hQg+66McJz>|%+!SwnOi#PUy-Felcp!jZRk+n zkco4p`CR1f3OX%8coTI3v8AE4L7T*Kj3?9BHgaM(Zx5~Sg;IC+4_Axm^U;(6_`rvt z-YlDTtA*I)ezQ5;hIY3hOEyog{hBW{A#o}EcJkhiV#^217HTyhWXS4Q2R$*&0Gg#P zO@P-UOe(*Ny4(Re%xPGav$etUe-L-Kr9V2n?RO+)>^U&x$EwI4${d9f6JJ9VH=Bhp zs)ywvN1m|bccPtNb$4IGw?1O`IIom^&khYi{lhH-l5RU}Hd1?W5-8>SL z|CtH|@LgZZiJNO`-Sw5T2IrzD@5Kk?*Uh09h6KB<_SHixXX7g}-0I6i?SqfjJ%A6q z36NG+cPZFcySpSZhwO#OOPwAqhpgA@iE^uCF)37fFh*9!mC1Nbjc;b)I%YJL zwTX_;y=;!)L&Bef2V-)Gx?3{n@^JZMweaZoWowp{q8osgy*9|%N7g%JJSB-u6Wp`3 z=Ej**I-(p@b36u#&_^IInTp<#EWpI!P+B@~g29+&@v#-8@5WnyEy1`LAT0jLdK-Tuio6a3`PZC>bO{*IzVV zX1S30ok~2A+CA*EM>#k_eoesj$ReJymR@cb_T8r(tdu?MyN`b9WccL6tAC>=%k1X0 zhOFEY_SvDlHbs6AlHvX&4c#loQd0q&Df15-6yd{-?Z))_6uA+ts*-6kJ&R_^$qOU)MfudU!5}U&(oJn@q{E%Y7HXQ{5AvYmfsdlwvz@w? ze_6AKnCi^wP*yx9N3(mAg>W|PEhJlTbnxMtww;jFH<@;~h)t$RKUw0H=1-tGK<&#N zZ4Qb)8Z4JYuRe*Pg_XF|yo3+J>ztBk&M&yrYn>;v%*s9*cg@%(kn?x_F{C_!NJ%tn zzWfiTdF)?f3%XcW)w2=j(3yp}Qs}=Ih0+(w7ul7LhiU|LDK!#I+#SfH!Oh%voC`eGc(f2)*;1G3Vhu|`BU8}wOr zVT5_crcs_X|_ixqaw3Jmqk& z7v(Fe>fU?{+WykC2+{{N3l>SDR*BIT+ObqVZ7BSVshmh1WaotyT)WKD>V_+q*4)MB zWn3^`JuPy!vU!CZYbZ#|sx9@cNI9=hhCbv48pi1Jl{PQSYc$f`&muMP;b)^0=)^s8 za-4c@?eqAQ(pTkC8coYDAjb=)av;$Umpz>h&4^8-qLuPBRnd1ghr(VnFXxE5gUORY zWqHx{X#Z*?xfs4mE>H*R-QM_Ys#%3M1YWPi#5H7S$fuNrL(w^5{RL@vS{MrMf18iv1%&IJiMd~z^YcYbppFY;JCV&3jkUHS z7buH2%Fk%&l{;^N!R|IK4UcSiuyibvn+r+G(RbxCt@PtZF)YR178BmH5lV(lxMyRX zjAg8{_kB4{qkrT#(03RX(jp=$P`ZKCTbvoY4QpBrg{T$q#KO)4GveH z9|J@Sy7Czuq*`yY3(*^8e5)WaS+eK9@IZf**jVDgWi{W+*}S1`hl=S%2iONok~tUZ;C|r!GoOKGrA$!*2ktOHM`|mnj~{l zVz3929g{#iI!6?Ob9Ymy5Ut;hIe*eItedy~00-3C&62(NtJpe=FNuhsJcc(Qhkq@< ztyT|LM6I;BVd9d*w060+|gbqP_=hwd*EJVy-3C zWYOk_YuGh}qW}lp5~Y3geY=?Y^$k;n-{Db)d@p|rpJ7&9E1K!V+IT@(q*(CkY3+xx z9jNq2SyoUvSQ1V;eAH=-MW0FhPP@`_zx?OBR# z9}T|^Gv~=Y80a=%mJ4v`l1SG+!W3}k8jR@ zQ4)`^S&_4dYYV?8>&4*o;Kxupgy-*O==G@g_So2szsU16@)uhI_p?!PR(c{aLM$Ys zEsZEh_F>WSbn*|mluG9d*yY&{1@OPUc~pKu6#$o5t+_VS=7#Gz$dDQw9T?j@yJF)O z?}5A8>w}8W9OuY620jK)5_D88>daT63G4OX7*@ z%yNo1SDrX7_tQA4skEGk8cer^Pdt7&q=k&WfZIO}p9s&fW^ZgFoj8u;^RNDt9|&HB zwbJ6Q5&aTLK{9FQkGLxR;YoR~YFN0tjrX_T2(=YF)WK?|^1QPcVr-{n#K`0Hs)$Pa z!5ujBrktcJ0lsHvPO5;k2dDDomY7!7?Ex}RS3G`unz4SRt#?0LOmsaGQ@rqy+M z&Rd(x07n$#BH$LkDxi%o1l#xp2Zr9Uts=TEE}q`T)1oV=Eh>t0wj%ARfa|gbmYOs5 z!1j4^b)e*O+gD)wnB;)x%l*%E6i2GcT(zxRUX_0ewRLVMQ(o{^U25|6%G0qK%7SZb z$$R7=T7F$D7Gh)DL>Rr-@y;>Jbc}>E1#MTzsqG5k|LOcYA!~sb+IZ0kOCvfaT7GBr zzT95sVtAqoIq}W1<%~+)&zzX8&^B9<>T>Tn_?-WC#a`WRi{&7{a7$m!fv525@SykZ z_t}+e(H2}$<)@yXJ&4c$y=1AMY#ia?O}a2qAPEmpZ5N@5)v*?6f}bwFkd3z9xiWnH z;71ayW-RGC4#MIsSWB@3(8W2ROc)#Lj&K@nbXJbbqi5<^4lyErNj8DLSQiQB!lZb5>jlSYHvu~s^>KY~9z z!9td8y?}ZrT28Cg;go|9M51|=g-MqD5L#l6xpY35zn((fM)rMzth8%j31C@`ob2@R z8H_HkF3|N=f;=zGcR7FnB&1yD^s&n5R0|v!PLCV2*6im@xS_N|sY$bJ4{{~jlB9fRF91BPW5lsY?he@tXIsj*!~z#9KX^Pmr9rOEtk}CxUFl=U1MI(gS?Wb zd~x|SzLDh@^~e|(a;@~{mWcXe^KV6C3nmJF-hA$9(VG31DXhb;ls2{CiI5<1?g!?? zuvyyR#2IHGbb2b)mdR(Qo=6+%>lP{_-O9J1LX8(argh|CH-L0kNR<$%v zXLvsN&rQo?v(V1VD@Cm=*k0v4J&nP0ugOkJi5;#Y(tO{7wpeCoZP6Pe?YHLCuBeuD zWH2O@9G1(RI(vt?JRYTw)A90l-AF&@Y0V+VPZb3dmRm8BKM7JZyq@Zm;3iDz zfUT<9G&dW>kYL4jR$rAMa7d8A8>r7$WCtc$Rayk!NCHh9$2V!9!W9V{h0{mDkBU~^ zVE=Q!)I%#)oLimM^U1Si;B4z@-$MM`hN|-3LSid2a|0tZ$|UUkkj5%pgRy{$lr@mC zLW;n!#43>wYqe#`rzWcRG71?QAd3oe>&ub&jQ|?ZO!Y(nYCIF7QdF5POI_Xy>H0Gj z(#2RoLT(@_xzS@>ZC3tKbCvMCfSnZHgAK&ZD5uqXK6Vypd+>^eH&F_o@=eD#xiaz&Y%vJ-5DDFNjP?5WO)0II1eE`sSopf?Q z+g2=D`U(I)s*eiJC<}UUx^z1?@>+IqTBX;5UA|9x)i*TDn3+V$jlx!J=6BlC#!0U^80@Fh~ z1Kv_Z7fsIJ_to;|@2MIQP%PwNlqZVWt5oJ#RSY>X1}2mMW+Mm6dUO;nue>xB9LN-+ zN`V6U^AzXFWaqo4(=#YVjJFH`7rm7=r7O9`Yvj_#t?r&A>2W+cy^i_hg z!sYYU#u6i)QgvlX#2jp8g0R@g-bo$;0c-2U9F=E{(s4?;2h``o-ZdXXpzp+naGvt^Xb5JM zK`8AHK%77pBB&;Cq3RRGz}$PTyf<$4sKID)2;pP~gU#So3E^bnWb`7{8^IiP#*jt4 zRYo)NhZbWfJTPKDC}Wts^w=iJnWZW{u`gAf6i{i*SnMRi36^-}~z?CDRdRcB>Tl`^P- ztXl)imX0P1=2I(!8gZ0HFkgwxCtPt7xlZ+vVG$b7r{|Q>Oz^o-G}7o9o&lS(9x9#9 z9mCTpMT)qA*~pg7st0mppFT{vWjo$0#oPACQQL}_Ap^ET8k`Y< z5haxI9Hi1V)gv$<#PI3$ilDI1Q6OoDsw9d3!lCZscGYzj6xhF!2j30@p9L@$GN>=3 zOpiL(#(ng~E1Wwa1uSBX1qcp&;!`Bhbs!ujuTlu(3ngQ`aTl|Gm+F`R%_9D?pL#-@ zy<37j^3!k@}ZlcauKD|wuWvf@E%stQ@FEpA!vf+1#g@hJF z&Oh)e>^6T68@as~3XOr#z&(<7$wLybx5iLO7_c7(QKS7Zj2SQu+>1z3X2Zb`$x`XO z5w5`=MPSi*`_H(K1By1n9pRmaT*TbM7`8VDRA<}}DxLF0Fu?L-2x&nGr>T8NmFAWWbb%!y8bAKShb@3mnV{Qn03j4ZEq!G{9lob$8A6C6|gAE9Fmm(t~2m+2^|B|Bu z^$nCp#F${gLJvBEy|(W$EZ7Z4vCy5-g!+ccc(@Fqc;V!BIA%2Txazu$7tu%=&&DU6 zP?d6JuBkGx-3v8@(FlM3qzVS1%vdvJ90%rtF*frw)^nTFs^>BuSsd*#usB`C3XzZJ zqHs_1#~IZXI=VFSQQuOr5!BE?4E*p@@542eHT3vxKibHttExOUPFAwd?1^h9;nxLRkd83=MizEaj79?q zc!Dmb{SDvu{2Ms@f=-3Zi$0vUef})uRqU_gv8ZrP4Sa$H5;Ya^90@m7?}1<^RjeN3 zWHdmWf^GsZ^)~*s<`xqtgrRow1f)6~fT5&HHUcz1-Nv3kDK2jB2}obWW`JoqP!)}G zus70p*q(~xixY8a0pqj@!a3=V>Leqbc+*1*87+`vMjA1*q2%;krnc<*{>r4S66v=0 zAgJ%)IwHvn)j^q$ar|j>PqmIUwA{yY@N9hQeR_tLqh}p{o-lU)-~%_9h@r}SIQWEz zDpdHD2_|wL1QEu9SYMpQWSH>Uo-la9_X+j`3R9l2*xYXw8^s+)bs88PMqWHo`I8q< zp_ev2rLJ0vtrbo@CIftHQScj%Ql>*^l+j6hj+%i@k+8N8S+hKWE+HyAus+9!g6^t{|Aq?j^n{#gv7VN5N%3@$_)GrYlo3#aok&4dV-t1DhS|>zrY3Mgp8P=%(#U| zoVc)7$|KMVa}$rmDmR4-r_0NH7^eb38vRYCf9=_{4b}vUB5&-Y#?wXK>L)U|{wL-3 z>C`g3cwelB_daT|hb+i^sxqDfT}7;T(ac2%1exxKXE7T2sS#B|?IcqLE6xa9bLe=MG4E;*w=4gfo`dEs8UQ`W@&(;Xd6Cei$ z`?;SsaF1aVKnE39)36=7oLEW$Q6_H|hN2{CGzyw3poBCEEGFN7oAdh3i2GXB?-FFz z_c$$$EGc!FLWVC?Zh>qISAPs>B9 z*xevc>fIpg5A`JM5LRjlvLPA~dpK0Ua$g0@J)s1o8NY3FVe4mJZU-{W1SE_ctN{Tz z6Qh317@|oBBzw2>uF!}uPE2e`0jzKtr~}vqG;0j$6Gn3Gu6Ob9Es7&!BS9hRV#r9~ z$kHDmg1?thKV;3&j0F8wWjqtQh5Uq)B(R+Nu@i%I{N1GoH2Q|YG?|3iN{?03e5_c) zNN-mrZGlE-Xi*g)rT>;!Uvi?6O~=(@j)cL8j94oLWH}OQzDh-Pq%dbRM}m|&sTSjs z0cj(-l@Z>MR|!*aypoz8)P&TAh&{@D*m)qq>np*M{?}a^cVOKk(V{pBj0cwk;?&es z7KoC=H)KFu8DoPI0sY8R1XYq9@oJXeQc_OH5Ue!$ze&VRwRBBuv>Uv_1R5rTnp*p@ zi87>w$;!OR&gRpK9YZqu3cEUrpKer_6Eh2FriK2DGKR^^LGv^s$yhTGYvW%oh@@?f zOLxuC6#$JQ<*g9#Cl>WPIpZSy59KCEYD<)dscgqXYQ1b&*K(T1mRJN`xgNoA>5UE; zMHW~phoc4Olnc8JoX3}R)zlwlNd0+bW*yK& zNEShXvgKGbjzo!Hnkzy`D}-jb@(V8>!q5QD rcxPG(In$a8DI#3g8}T?Ob1hd+ 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 From c1a6904cb5e207fde6078dc1dcdff9c7d7992d4b Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 11 Mar 2022 19:24:29 -0300 Subject: [PATCH 10/24] add twilio flex ticketer test constant --- testsuite/testdata/constants.go | 1 + 1 file changed, 1 insertion(+) diff --git a/testsuite/testdata/constants.go b/testsuite/testdata/constants.go index 6b5bcfc24..750f61669 100644 --- a/testsuite/testdata/constants.go +++ b/testsuite/testdata/constants.go @@ -86,6 +86,7 @@ var Internal = &Ticketer{1, "8bd48029-6ca1-46a8-aa14-68f7213b82b3"} var Mailgun = &Ticketer{2, "f9c9447f-a291-4f3c-8c79-c089bbd4e713"} var Zendesk = &Ticketer{3, "4ee6d4f3-f92b-439b-9718-8da90c05490b"} var RocketChat = &Ticketer{4, "6c50665f-b4ff-4e37-9625-bc464fe6a999"} +var Twilioflex = &Ticketer{6, "12cc5dcf-44c2-4b25-9781-27275873e0df"} var Luis = &Classifier{1, "097e026c-ae79-4740-af67-656dbedf0263"} var Wit = &Classifier{2, "ff2a817c-040a-4eb2-8404-7d92e8b79dd0"} From 38a1843f487ca397be39ddc405813334e56041e9 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 11 Mar 2022 19:24:51 -0300 Subject: [PATCH 11/24] twilioflex client test --- services/tickets/twilioflex/client_test.go | 351 +++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 services/tickets/twilioflex/client_test.go diff --git a/services/tickets/twilioflex/client_test.go b/services/tickets/twilioflex/client_test.go new file mode 100644 index 000000000..5c3272a1d --- /dev/null +++ b/services/tickets/twilioflex/client_test.go @@ -0,0 +1,351 @@ +package twilioflex_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/mailroom/services/tickets/twilioflex" + "github.com/stretchr/testify/assert" +) + +const ( + authToken = "token" + accountSid = "AC81d44315e19372138bdaffcc13cf3b94" + serviceSid = "IS38067ec392f1486bb6e4de4610f26fb3" + workspaceSid = "WS954611f5aebc7672d71de836c0179113" + flexFlowSid = "FOedbb8c9e54f04afaef409246f728a44d" +) + +func TestCreateUser(t *testing.T) { + defer httpx.SetRequestor(httpx.DefaultRequestor) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Users", serviceSid): { + httpx.MockConnectionError, + httpx.NewMockResponse(400, nil, `{"message": "Something went wrong", "detail": "Unknown", "code": 1234, "more_info": "https://www.twilio.com/docs/errors/1234"}`), + httpx.NewMockResponse(201, nil, `{ + "is_notifiable": null, + "date_updated": "2022-03-08T22:18:23Z", + "is_online": null, + "friendly_name": "dummy user", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f", + "date_created": "2022-03-08T22:18:23Z", + "role_sid": "RL6f3f490b35534130845f98202673ffb9", + "sid": "USf4015a97250d482889459f8e8819e09f", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "joined_channels_count": 0, + "identity": "123", + "links": { + "user_channels": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f/Channels", + "user_bindings": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f/Bindings" + } + }`), + }, + })) + + client := twilioflex.NewClient(http.DefaultClient, nil, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid) + params := &twilioflex.CreateChatUserParams{ + Identity: "123", + FriendlyName: "dummy user", + } + + _, _, err := client.CreateUser(params) + assert.EqualError(t, err, "unable to connect to server") + + _, _, err = client.CreateUser(params) + assert.EqualError(t, err, "Something went wrong") + + _, _, err = client.CreateUser(params) + assert.EqualError(t, err, "invalid character 'x' looking for beginning of value") + + user, trace, err := client.CreateUser(params) + assert.NoError(t, err) + assert.Equal(t, "123", user.Identity) + assert.Equal(t, "HTTP/1.0 201 Created\r\nContent-Length: 915\r\n\r\n", string(trace.ResponseTrace)) +} + +func TestFetchUser(t *testing.T) { + userSid := "USf4015a97250d482889459f8e8819e09f" + defer httpx.SetRequestor(httpx.DefaultRequestor) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Users/%s", serviceSid, userSid): { + httpx.MockConnectionError, + httpx.NewMockResponse(400, nil, `{"message": "Something went wrong", "detail": "Unknown", "code": 1234, "more_info": "https://www.twilio.com/docs/errors/1234"}`), + httpx.NewMockResponse(200, nil, `{ + "is_notifiable": null, + "date_updated": "2022-03-08T22:18:23Z", + "is_online": null, + "friendly_name": "dummy user", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f", + "date_created": "2022-03-08T22:18:23Z", + "role_sid": "RL6f3f490b35534130845f98202673ffb9", + "sid": "USf4015a97250d482889459f8e8819e09f", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "joined_channels_count": 0, + "identity": "123", + "links": { + "user_channels": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f/Channels", + "user_bindings": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f/Bindings" + } + }`), + }, + })) + + client := twilioflex.NewClient(http.DefaultClient, nil, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid) + _, _, err := client.FetchUser(userSid) + assert.EqualError(t, err, "unable to connect to server") + + _, _, err = client.FetchUser(userSid) + assert.EqualError(t, err, "Something went wrong") + + user, trace, err := client.FetchUser(userSid) + assert.NoError(t, err) + assert.Equal(t, "123", user.Identity) + assert.Equal(t, "HTTP/1.0 200 OK\r\nContent-Length: 915\r\n\r\n", string(trace.ResponseTrace)) +} + +func TestCreateFlexChannel(t *testing.T) { + defer httpx.SetRequestor(httpx.DefaultRequestor) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + "https://flex-api.twilio.com/v1/Channels": { + httpx.MockConnectionError, + httpx.NewMockResponse(400, nil, `{"message": "Something went wrong", "detail": "Unknown", "code": 1234, "more_info": "https://www.twilio.com/docs/errors/1234"}`), + httpx.NewMockResponse(201, nil, `{ + "task_sid": "WT1d187abc335f7f16ff050a66f9b6a6b2", + "flex_flow_sid": "FOedbb8c9e54f04afaef409246f728a44d", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "user_sid": "USf4015a97250d482889459f8e8819e09f", + "url": "https://flex-api.twilio.com/v1/Channels/CH6442c09c93ba4d13966fa42e9b78f620", + "date_updated": "2022-03-08T22:38:30Z", + "sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "date_created": "2022-03-08T22:38:30Z" + }`), + }, + })) + + client := twilioflex.NewClient(http.DefaultClient, nil, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid) + + params := &twilioflex.CreateFlexChannelParams{ + FlexFlowSid: flexFlowSid, + Identity: "123", + ChatUserFriendlyName: "dummy user", + ChatFriendlyName: "dummy user", + } + + _, _, err := client.CreateFlexChannel(params) + assert.EqualError(t, err, "unable to connect to server") + + _, _, err = client.CreateFlexChannel(params) + assert.EqualError(t, err, "Something went wrong") + + channel, trace, err := client.CreateFlexChannel(params) + assert.NoError(t, err) + assert.Equal(t, "FOedbb8c9e54f04afaef409246f728a44d", channel.FlexFlowSid) + assert.Equal(t, "HTTP/1.0 201 Created\r\nContent-Length: 455\r\n\r\n", string(trace.ResponseTrace)) +} + +func TestFetchFlexChannel(t *testing.T) { + channelSid := "CH6442c09c93ba4d13966fa42e9b78f620" + defer httpx.SetRequestor(httpx.DefaultRequestor) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + fmt.Sprintf("https://flex-api.twilio.com/v1/Channels/%s", channelSid): { + httpx.MockConnectionError, + httpx.NewMockResponse(400, nil, `{"message": "Something went wrong", "detail": "Unknown", "code": 1234, "more_info": "https://www.twilio.com/docs/errors/1234"}`), + httpx.NewMockResponse(200, nil, `{ + "task_sid": "WT1d187abc335f7f16ff050a66f9b6a6b2", + "flex_flow_sid": "FOedbb8c9e54f04afaef409246f728a44d", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "user_sid": "USf4015a97250d482889459f8e8819e09f", + "url": "https://flex-api.twilio.com/v1/Channels/CH6442c09c93ba4d13966fa42e9b78f620", + "date_updated": "2022-03-08T22:38:30Z", + "sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "date_created": "2022-03-08T22:38:30Z" + }`), + }, + })) + + client := twilioflex.NewClient(http.DefaultClient, nil, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid) + + _, _, err := client.FetchFlexChannel(channelSid) + assert.EqualError(t, err, "unable to connect to server") + + _, _, err = client.FetchFlexChannel(channelSid) + assert.EqualError(t, err, "Something went wrong") + + channel, trace, err := client.FetchFlexChannel(channelSid) + assert.NoError(t, err) + assert.Equal(t, "FOedbb8c9e54f04afaef409246f728a44d", channel.FlexFlowSid) + assert.Equal(t, "HTTP/1.0 200 OK\r\nContent-Length: 455\r\n\r\n", string(trace.ResponseTrace)) +} + +func TestCreateFlexChannelWebhook(t *testing.T) { + channelSid := "CH6442c09c93ba4d13966fa42e9b78f620" + defer httpx.SetRequestor(httpx.DefaultRequestor) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Channels/%s/Webhooks", serviceSid, channelSid): { + httpx.MockConnectionError, + httpx.NewMockResponse(400, nil, `{"message": "Something went wrong", "detail": "Unknown", "code": 1234, "more_info": "https://www.twilio.com/docs/errors/1234"}`), + httpx.NewMockResponse(201, nil, `{ + "channel_sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Webhooks/WHa8a9ae86063e494d9f3b754a8da85f8e", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "date_updated": "2022-03-09T19:54:49Z", + "configuration": { + "url": "https://mailroom.com/mr/tickets/types/twilioflex/event_callback/1234/4567", + "retry_count": 1, + "method": "POST", + "filters": [ + "onMessageSent" + ] + }, + "sid": "WHa8a9ae86063e494d9f3b754a8da85f8e", + "date_created": "2022-03-09T19:54:49Z", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "type": "webhook" + }`), + }, + })) + + callbackURL := fmt.Sprintf( + "https://%s/mr/tickets/types/twilioflex/event_callback/%s/%s", + "mailroom.domain.com", + "ticketer-uuid-1234-4567-7890", + "ticket-uuid-1234-4567-7890", + ) + + channelWebhook := &twilioflex.CreateChatChannelWebhookParams{ + ConfigurationUrl: callbackURL, + ConfigurationFilters: []string{"onMessageSent", "onChannelUpdated"}, + ConfigurationMethod: "POST", + ConfigurationRetryCount: 1, + Type: "webhook", + } + + client := twilioflex.NewClient(http.DefaultClient, nil, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid) + + _, _, err := client.CreateFlexChannelWebhook(channelWebhook, channelSid) + assert.EqualError(t, err, "unable to connect to server") + + _, _, err = client.CreateFlexChannelWebhook(channelWebhook, channelSid) + assert.EqualError(t, err, "Something went wrong") + + webhook, trace, err := client.CreateFlexChannelWebhook(channelWebhook, channelSid) + assert.NoError(t, err) + assert.Equal(t, "CH6442c09c93ba4d13966fa42e9b78f620", webhook.ChannelSid) + assert.Equal(t, "HTTP/1.0 201 Created\r\nContent-Length: 728\r\n\r\n", string(trace.ResponseTrace)) +} + +func TestCreateMessage(t *testing.T) { + channelSid := "CH6442c09c93ba4d13966fa42e9b78f620" + defer httpx.SetRequestor(httpx.DefaultRequestor) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Channels/%s/Messages", serviceSid, channelSid): { + httpx.MockConnectionError, + httpx.NewMockResponse(400, nil, `{"message": "Something went wrong", "detail": "Unknown", "code": 1234, "more_info": "https://www.twilio.com/docs/errors/1234"}`), + httpx.NewMockResponse(201, nil, `{ + "body": "hello", + "index": 0, + "channel_sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "from": "123", + "date_updated": "2022-03-09T20:27:47Z", + "type": "text", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "to": "CH6442c09c93ba4d13966fa42e9b78f620", + "last_updated_by": null, + "date_created": "2022-03-09T20:27:47Z", + "media": null, + "sid": "IM8842e723153b459b9e03a0bae87298d8", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Messages/IM8842e723153b459b9e03a0bae87298d8", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "was_edited": false + }`), + }, + })) + + client := twilioflex.NewClient(http.DefaultClient, nil, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid) + + msg := &twilioflex.ChatMessage{ + From: "123", + Body: "hello", + ChannelSid: channelSid, + } + + _, _, err := client.CreateMessage(msg) + assert.EqualError(t, err, "unable to connect to server") + + _, _, err = client.CreateMessage(msg) + assert.EqualError(t, err, "Something went wrong") + + response, trace, err := client.CreateMessage(msg) + assert.NoError(t, err) + assert.Equal(t, "hello", response.Body) + assert.Equal(t, "HTTP/1.0 201 Created\r\nContent-Length: 708\r\n\r\n", string(trace.ResponseTrace)) +} + +func TestCompleteTask(t *testing.T) { + taskSid := "WT1d187abc335f7f16ff050a66f9b6a6b2" + defer httpx.SetRequestor(httpx.DefaultRequestor) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + fmt.Sprintf("https://taskrouter.twilio.com/v1/Workspaces/%s/Tasks/%s", workspaceSid, taskSid): { + httpx.MockConnectionError, + httpx.NewMockResponse(400, nil, `{"message": "Something went wrong", "detail": "Unknown", "code": 1234, "more_info": "https://www.twilio.com/docs/errors/1234"}`), + httpx.NewMockResponse(400, nil, `{ + "code": 20001, + "message": "Cannot complete task WT1d187abc335f7f16ff050a66f9b6a6b2 in workspace WS954611f5aebc7672d71de836c0179113 for account AC81d44315e19372138bdaffcc13cf3b94 because it is not currently assigned.", + "more_info": "https://www.twilio.com/docs/errors/20001", + "status": 400 + }`), + httpx.NewMockResponse(200, nil, `{ + "workspace_sid": "WS954611f5aebc7672d71de836c0179113", + "assignment_status": "completed", + "date_updated": "2022-03-09T21:57:00Z", + "task_queue_entered_date": "2022-03-08T22:38:30Z", + "age": 83910, + "sid": "WT1d187abc335f7f16ff050a66f9b6a6b2", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "priority": 0, + "url": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/Tasks/WT1d187abc335f7f16ff050a66f9b6a6b2", + "reason": "resolved", + "task_queue_sid": "WQa9e71cb17d52c8b75e4934b75e3297bc", + "workflow_friendly_name": "Assign to Anyone", + "timeout": 86400, + "attributes": "{\"channelSid\":\"CH6442c09c93ba4d13966fa42e9b78f620\",\"name\":\"dummy user\",\"channelType\":\"web\"}", + "date_created": "2022-03-08T22:38:30Z", + "task_channel_sid": "TCf7fafe38a5210ee6b328b2bc42a1e950", + "addons": "{}", + "task_channel_unique_name": "chat", + "workflow_sid": "WWfaeaff148cfdefce03443a4980149558", + "task_queue_friendly_name": "Everyone", + "links": { + "reservations": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/Tasks/WT1d187abc335f7f16ff050a66f9b6a6b2/Reservations", + "task_queue": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/TaskQueues/WQa9e71cb17d52c8b75e4934b75e3297bc", + "workspace": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113", + "workflow": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/Workflows/WWfaeaff148cfdefce03443a4980149558" + } + }`), + }, + })) + + client := twilioflex.NewClient(http.DefaultClient, nil, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid) + + _, _, err := client.CompleteTask(taskSid) + assert.EqualError(t, err, "unable to connect to server") + + _, _, err = client.CompleteTask(taskSid) + assert.EqualError(t, err, "Something went wrong") + + _, _, err = client.CompleteTask(taskSid) + assert.EqualError(t, err, "Cannot complete task WT1d187abc335f7f16ff050a66f9b6a6b2 in workspace WS954611f5aebc7672d71de836c0179113 for account AC81d44315e19372138bdaffcc13cf3b94 because it is not currently assigned.") + + response, trace, err := client.CompleteTask(taskSid) + assert.NoError(t, err) + assert.Equal(t, "completed", response.AssignmentStatus) + assert.Equal(t, "HTTP/1.0 200 OK\r\nContent-Length: 1602\r\n\r\n", string(trace.ResponseTrace)) + +} From d04f571fd13ff11c409eedf1b8d7c638899b0db6 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 11 Mar 2022 19:25:06 -0300 Subject: [PATCH 12/24] twilioflex ticketer service test --- services/tickets/twilioflex/service_test.go | 320 ++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 services/tickets/twilioflex/service_test.go diff --git a/services/tickets/twilioflex/service_test.go b/services/tickets/twilioflex/service_test.go new file mode 100644 index 000000000..46abe698c --- /dev/null +++ b/services/tickets/twilioflex/service_test.go @@ -0,0 +1,320 @@ +package twilioflex_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/nyaruka/gocommon/dates" + "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/gocommon/uuids" + "github.com/nyaruka/goflow/assets" + "github.com/nyaruka/goflow/assets/static" + "github.com/nyaruka/goflow/envs" + "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/goflow/test" + "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/mailroom/core/models" + "github.com/nyaruka/mailroom/services/tickets/twilioflex" + "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenAndForward(t *testing.T) { + ctx, rt, _, _ := testsuite.Get() + + defer dates.SetNowSource(dates.DefaultNowSource) + dates.SetNowSource(dates.NewSequentialNowSource(time.Date(2019, 10, 7, 15, 21, 30, 0, time.UTC))) + + session, _, err := test.CreateTestSession("", envs.RedactionPolicyNone) + require.NoError(t, err) + + defer uuids.SetGenerator(uuids.DefaultGenerator) + defer httpx.SetRequestor(httpx.DefaultRequestor) + + uuids.SetGenerator(uuids.NewSeededGenerator(12345)) + + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/1234567": { + httpx.NewMockResponse(404, nil, `{ + "code": 20404, + "message": "The requested resource /Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/1234567 was not found", + "more_info": "https://www.twilio.com/docs/errors/20404", + "status": 404 + }`), + }, + "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users": { + httpx.NewMockResponse(201, nil, `{ + "is_notifiable": null, + "date_updated": "2022-03-08T22:18:23Z", + "is_online": null, + "friendly_name": "dummy user", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f", + "date_created": "2022-03-08T22:18:23Z", + "role_sid": "RL6f3f490b35534130845f98202673ffb9", + "sid": "USf4015a97250d482889459f8e8819e09f", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "joined_channels_count": 0, + "identity": "123", + "links": { + "user_channels": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f/Channels", + "user_bindings": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f/Bindings" + } + }`), + }, + "https://flex-api.twilio.com/v1/Channels": { + httpx.NewMockResponse(201, nil, `{ + "task_sid": "WT1d187abc335f7f16ff050a66f9b6a6b2", + "flex_flow_sid": "FOedbb8c9e54f04afaef409246f728a44d", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "user_sid": "USf4015a97250d482889459f8e8819e09f", + "url": "https://flex-api.twilio.com/v1/Channels/CH6442c09c93ba4d13966fa42e9b78f620", + "date_updated": "2022-03-08T22:38:30Z", + "sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "date_created": "2022-03-08T22:38:30Z" + }`), + }, + "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Webhooks": { + httpx.NewMockResponse(201, nil, `{ + "channel_sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Webhooks/WHa8a9ae86063e494d9f3b754a8da85f8e", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "date_updated": "2022-03-09T19:54:49Z", + "configuration": { + "url": "https://mailroom.com/mr/tickets/types/twilioflex/event_callback/1234/4567", + "retry_count": 1, + "method": "POST", + "filters": [ + "onMessageSent" + ] + }, + "sid": "WHa8a9ae86063e494d9f3b754a8da85f8e", + "date_created": "2022-03-09T19:54:49Z", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "type": "webhook" + }`), + }, + "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Messages": { + httpx.MockConnectionError, + httpx.NewMockResponse(201, nil, `{ + "body": "It's urgent", + "index": 0, + "channel_sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "from": "123", + "date_updated": "2022-03-09T20:27:47Z", + "type": "text", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "to": "CH6442c09c93ba4d13966fa42e9b78f620", + "last_updated_by": null, + "date_created": "2022-03-09T20:27:47Z", + "media": null, + "sid": "IM8842e723153b459b9e03a0bae87298d8", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Messages/IM8842e723153b459b9e03a0bae87298d8", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "was_edited": false + }`), + }, + })) + + ticketer := flows.NewTicketer(static.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "twilioflex")) + + _, err = twilioflex.NewService( + rt.Config, + http.DefaultClient, + nil, + ticketer, + map[string]string{}, + ) + assert.EqualError(t, err, "missing auth_token or account_sid or chat_service_sid or workspace_sid in twilio flex config") + + svc, err := twilioflex.NewService( + rt.Config, + http.DefaultClient, + nil, + ticketer, + map[string]string{ + "auth_token": authToken, + "account_sid": accountSid, + "chat_service_sid": serviceSid, + "workspace_sid": workspaceSid, + "flex_flow_sid": flexFlowSid, + }, + ) + assert.NoError(t, err) + + oa, err := models.GetOrgAssets(ctx, rt, testdata.Org1.ID) + require.NoError(t, err) + defaultTopic := oa.SessionAssets().Topics().FindByName("General") + + logger := &flows.HTTPLogger{} + ticket, err := svc.Open(session, defaultTopic, "Where are my cookies?", nil, logger.Log) + // assert.EqualError(t, err, "error calling Twilioflex: unable to connect to server") + assert.NoError(t, err) + assert.Equal(t, flows.TicketUUID("e7187099-7d38-4f60-955c-325957214c42"), ticket.UUID()) + assert.Equal(t, "General", ticket.Topic().Name()) + assert.Equal(t, "Where are my cookies?", ticket.Body()) + assert.Equal(t, "CH6442c09c93ba4d13966fa42e9b78f620", ticket.ExternalID()) + assert.Equal(t, 4, len(logger.Logs)) + test.AssertSnapshot(t, "open_ticket", logger.Logs[0].Request) + + dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Cathy.ID, testdata.Twilioflex.ID, "CH6442c09c93ba4d13966fa42e9b78f620", testdata.DefaultTopic.ID, "Where are my cookies?", models.NilUserID, map[string]interface{}{ + "contact-uuid": string(testdata.Cathy.UUID), + "contact-display": "Cathy", + }) + logger = &flows.HTTPLogger{} + err = svc.Forward(dbTicket, flows.MsgUUID("4fa340ae-1fb0-4666-98db-2177fe9bf31c"), "It's urgent", nil, logger.Log) + assert.EqualError(t, err, "error calling Twilio: unable to connect to server") + + logger = &flows.HTTPLogger{} + attachments := []utils.Attachment{ + "image/jpg:https://link.to/image.jpg", + "video/mp4:https://link.to/video.mp4", + "audio/ogg:https://link.to/audio.ogg", + } + err = svc.Forward(dbTicket, flows.MsgUUID("4fa340ae-1fb0-4666-98db-2177fe9bf31c"), "It's urgent", attachments, logger.Log) + require.NoError(t, err) + assert.Equal(t, 1, len(logger.Logs)) + test.AssertSnapshot(t, "forward_message", logger.Logs[0].Request) +} + +func TestCloseAndReopen(t *testing.T) { + _, rt, _, _ := testsuite.Get() + + defer uuids.SetGenerator(uuids.DefaultGenerator) + defer httpx.SetRequestor(httpx.DefaultRequestor) + + uuids.SetGenerator(uuids.NewSeededGenerator(12345)) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + "https://flex-api.twilio.com/v1/Channels/CH6442c09c93ba4d13966fa42e9b78f620": { + httpx.MockConnectionError, + httpx.NewMockResponse(200, nil, `{ + "task_sid": "WT1d187abc335f7f16ff050a66f9b6a6b2", + "flex_flow_sid": "FOedbb8c9e54f04afaef409246f728a44d", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "user_sid": "USf4015a97250d482889459f8e8819e09f", + "url": "https://flex-api.twilio.com/v1/Channels/CH6442c09c93ba4d13966fa42e9b78f620", + "date_updated": "2022-03-08T22:38:30Z", + "sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "date_created": "2022-03-08T22:38:30Z" + }`), + httpx.NewMockResponse(200, nil, `{ + "task_sid": "WT1d187abc335f7f16ff050a66f9b6a6b2", + "flex_flow_sid": "FOedbb8c9e54f04afaef409246f728a44d", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "user_sid": "USf4015a97250d482889459f8e8819e09f", + "url": "https://flex-api.twilio.com/v1/Channels/CH6442c09c93ba4d13966fa42e9b78f620", + "date_updated": "2022-03-08T22:38:30Z", + "sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "date_created": "2022-03-08T22:38:30Z" + }`), + }, + "https://flex-api.twilio.com/v1/Channels/CH8692e17c93ba4d13966fa42e9b78f853": { + httpx.NewMockResponse(200, nil, `{ + "task_sid": "WT1d187abc335f7f16ff050a66f9b6a6b2", + "flex_flow_sid": "FOedbb8c9e54f04afaef409246f728a44d", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "user_sid": "USf4015a97250d482889459f8e8819e09f", + "url": "https://flex-api.twilio.com/v1/Channels/CH8692e17c93ba4d13966fa42e9b78f853", + "date_updated": "2022-03-08T22:38:30Z", + "sid": "CH8692e17c93ba4d13966fa42e9b78f853", + "date_created": "2022-03-08T22:38:30Z" + }`), + }, + fmt.Sprintf("https://taskrouter.twilio.com/v1/Workspaces/%s/Tasks/WT1d187abc335f7f16ff050a66f9b6a6b2", workspaceSid): { + httpx.NewMockResponse(200, nil, `{ + "workspace_sid": "WS954611f5aebc7672d71de836c0179113", + "assignment_status": "completed", + "date_updated": "2022-03-09T21:57:00Z", + "task_queue_entered_date": "2022-03-08T22:38:30Z", + "age": 83910, + "sid": "WT1d187abc335f7f16ff050a66f9b6a6b2", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "priority": 0, + "url": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/Tasks/WT1d187abc335f7f16ff050a66f9b6a6b2", + "reason": "resolved", + "task_queue_sid": "WQa9e71cb17d52c8b75e4934b75e3297bc", + "workflow_friendly_name": "Assign to Anyone", + "timeout": 86400, + "attributes": "{\"channelSid\":\"CH6442c09c93ba4d13966fa42e9b78f620\",\"name\":\"dummy user\",\"channelType\":\"web\"}", + "date_created": "2022-03-08T22:38:30Z", + "task_channel_sid": "TCf7fafe38a5210ee6b328b2bc42a1e950", + "addons": "{}", + "task_channel_unique_name": "chat", + "workflow_sid": "WWfaeaff148cfdefce03443a4980149558", + "task_queue_friendly_name": "Everyone", + "links": { + "reservations": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/Tasks/WT1d187abc335f7f16ff050a66f9b6a6b2/Reservations", + "task_queue": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/TaskQueues/WQa9e71cb17d52c8b75e4934b75e3297bc", + "workspace": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113", + "workflow": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/Workflows/WWfaeaff148cfdefce03443a4980149558" + } + }`), + httpx.NewMockResponse(200, nil, `{ + "workspace_sid": "WS954611f5aebc7672d71de836c0179113", + "assignment_status": "completed", + "date_updated": "2022-03-09T21:57:00Z", + "task_queue_entered_date": "2022-03-08T22:38:30Z", + "age": 83910, + "sid": "WT1d187abc335f7f16ff050a66f9b6a6b2", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "priority": 0, + "url": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/Tasks/WT1d187abc335f7f16ff050a66f9b6a6b2", + "reason": "resolved", + "task_queue_sid": "WQa9e71cb17d52c8b75e4934b75e3297bc", + "workflow_friendly_name": "Assign to Anyone", + "timeout": 86400, + "attributes": "{\"channelSid\":\"CH6442c09c93ba4d13966fa42e9b78f620\",\"name\":\"dummy user\",\"channelType\":\"web\"}", + "date_created": "2022-03-08T22:38:30Z", + "task_channel_sid": "TCf7fafe38a5210ee6b328b2bc42a1e950", + "addons": "{}", + "task_channel_unique_name": "chat", + "workflow_sid": "WWfaeaff148cfdefce03443a4980149558", + "task_queue_friendly_name": "Everyone", + "links": { + "reservations": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/Tasks/WT1d187abc335f7f16ff050a66f9b6a6b2/Reservations", + "task_queue": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/TaskQueues/WQa9e71cb17d52c8b75e4934b75e3297bc", + "workspace": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113", + "workflow": "https://taskrouter.twilio.com/v1/Workspaces/WS954611f5aebc7672d71de836c0179113/Workflows/WWfaeaff148cfdefce03443a4980149558" + } + }`), + }, + })) + + ticketer := flows.NewTicketer(static.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "twilioflex")) + + svc, err := twilioflex.NewService( + rt.Config, + http.DefaultClient, + nil, + ticketer, + map[string]string{ + "auth_token": authToken, + "account_sid": accountSid, + "chat_service_sid": serviceSid, + "workspace_sid": workspaceSid, + "flex_flow_sid": flexFlowSid, + }, + ) + assert.NoError(t, err) + + ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Cathy.ID, testdata.RocketChat.ID, "CH6442c09c93ba4d13966fa42e9b78f620", testdata.DefaultTopic.ID, "Where my cookies?", models.NilUserID, nil) + ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Bob.ID, testdata.RocketChat.ID, "CH8692e17c93ba4d13966fa42e9b78f853", testdata.DefaultTopic.ID, "Where my shoes?", models.NilUserID, nil) + + logger := &flows.HTTPLogger{} + err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) + assert.EqualError(t, err, "error calling Twilio API: unable to connect to server") + + logger = &flows.HTTPLogger{} + err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log) + assert.NoError(t, err) + test.AssertSnapshot(t, "close_tickets", logger.Logs[0].Request) + + err = svc.Reopen([]*models.Ticket{ticket2}, logger.Log) + assert.EqualError(t, err, "Twilio Flex ticket type doesn't support reopening") +} From 6da5613459b8a4375ca964e836e97a798222e209 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 11 Mar 2022 19:26:17 -0300 Subject: [PATCH 13/24] add twilioflex ticketer testdata --- .../TestCloseAndReopen_close_tickets.snap | 7 ++ .../TestOpenAndForward_forward_message.snap | 9 +++ .../TestOpenAndForward_open_ticket.snap | 8 ++ .../twilioflex/testdata/event_callback.json | 75 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 services/tickets/twilioflex/testdata/TestCloseAndReopen_close_tickets.snap create mode 100644 services/tickets/twilioflex/testdata/TestOpenAndForward_forward_message.snap create mode 100644 services/tickets/twilioflex/testdata/TestOpenAndForward_open_ticket.snap create mode 100644 services/tickets/twilioflex/testdata/event_callback.json diff --git a/services/tickets/twilioflex/testdata/TestCloseAndReopen_close_tickets.snap b/services/tickets/twilioflex/testdata/TestCloseAndReopen_close_tickets.snap new file mode 100644 index 000000000..20cb060cd --- /dev/null +++ b/services/tickets/twilioflex/testdata/TestCloseAndReopen_close_tickets.snap @@ -0,0 +1,7 @@ +GET /v1/Channels/CH6442c09c93ba4d13966fa42e9b78f620 HTTP/1.1 +Host: flex-api.twilio.com +User-Agent: Go-http-client/1.1 +Authorization: Basic QUM4MWQ0NDMxNWUxOTM3MjEzOGJkYWZmY2MxM2NmM2I5NDp0b2tlbg== +Content-Type: application/x-www-form-urlencoded +Accept-Encoding: gzip + diff --git a/services/tickets/twilioflex/testdata/TestOpenAndForward_forward_message.snap b/services/tickets/twilioflex/testdata/TestOpenAndForward_forward_message.snap new file mode 100644 index 000000000..909aa91cf --- /dev/null +++ b/services/tickets/twilioflex/testdata/TestOpenAndForward_forward_message.snap @@ -0,0 +1,9 @@ +POST /v2/Services/****************/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Messages HTTP/1.1 +Host: chat.twilio.com +User-Agent: Go-http-client/1.1 +Content-Length: 115 +Authorization: Basic QUM4MWQ0NDMxNWUxOTM3MjEzOGJkYWZmY2MxM2NmM2I5NDp0b2tlbg== +Content-Type: application/x-www-form-urlencoded +Accept-Encoding: gzip + +Body=It%27s+urgent&ChannelSid=CH6442c09c93ba4d13966fa42e9b78f620&From=10000&Index=0&Media=map%5B%5D&WasEdited=false \ No newline at end of file diff --git a/services/tickets/twilioflex/testdata/TestOpenAndForward_open_ticket.snap b/services/tickets/twilioflex/testdata/TestOpenAndForward_open_ticket.snap new file mode 100644 index 000000000..931e2ac2d --- /dev/null +++ b/services/tickets/twilioflex/testdata/TestOpenAndForward_open_ticket.snap @@ -0,0 +1,8 @@ +POST /v2/Services/****************/Users/1234567 HTTP/1.1 +Host: chat.twilio.com +User-Agent: Go-http-client/1.1 +Content-Length: 0 +Authorization: Basic QUM4MWQ0NDMxNWUxOTM3MjEzOGJkYWZmY2MxM2NmM2I5NDp0b2tlbg== +Content-Type: application/x-www-form-urlencoded +Accept-Encoding: gzip + diff --git a/services/tickets/twilioflex/testdata/event_callback.json b/services/tickets/twilioflex/testdata/event_callback.json new file mode 100644 index 000000000..88758a9d4 --- /dev/null +++ b/services/tickets/twilioflex/testdata/event_callback.json @@ -0,0 +1,75 @@ +[ + { + "label": "error response if no such ticketer", + "method": "POST", + "path": "/mr/tickets/types/twilioflex/event_callback/XYZ/XYZ", + "body": { + "event_type": "onMessageSent", + "instance_sid": "12345", + "body": "we can help" + }, + "status": 404, + "response": { + "error": "not found: /mr/tickets/types/twilioflex/event_callback/XYZ/XYZ" + } + }, + { + "label": "unauthorized response if auth fails", + "method": "POST", + "path": "/mr/tickets/types/twilioflex/event_callback/12cc5dcf-44c2-4b25-9781-27275873e0df/564fee60-7e84-4a9e-ade3-4fce01af19a2", + "body": "EventType=onMessageSent&InstanceSid=IS38067ec392f1486bb6e4de4610f26fb3&Attributes=%7B%7D&DateCreated=2022-03-10T23%3A56%3A43.412Z&Index=1&From=teste_2Etwilioflex&MessageSid=IM4b440f124820414b8f500a1235532ac1&AccountSid=AC92d44315e19372138bdaffcc13cf3b05&Source=SDK&ChannelSid=CH1880a9cde40c4dbb88dd97fc3aedac08&ClientIdentity=teste_2Etwilioflex&RetryCount=0&WebhookType=webhook&Body=ola&WebhookSid=WH99d1f1895a7c4e6fa10ac5e8ac0c2242", + "status": 401, + "response": { + "status": "unauthorized" + } + }, + { + "label": "error response if no such ticket", + "method": "POST", + "path": "/mr/tickets/types/twilioflex/event_callback/12cc5dcf-44c2-4b25-9781-27275873e0df/564fee60-7e84-4a9e-ade3-4fce01af19a2", + "body": "EventType=onMessageSent&InstanceSid=IS38067ec392f1486bb6e4de4610f26fb3&Attributes=%7B%7D&DateCreated=2022-03-10T23%3A56%3A43.412Z&Index=1&From=teste_2Etwilioflex&MessageSid=IM4b440f124820414b8f500a1235532ac1&AccountSid=AC81d44315e19372138bdaffcc13cf3b94&Source=SDK&ChannelSid=CH1880a9cde40c4dbb88dd97fc3aedac08&ClientIdentity=teste_2Etwilioflex&RetryCount=0&WebhookType=webhook&Body=ola&WebhookSid=WH99d1f1895a7c4e6fa10ac5e8ac0c2242", + "status": 404, + "response": { + "error": "no such ticket 564fee60-7e84-4a9e-ade3-4fce01af19a2" + } + }, + { + "label": "create message if everything is correct", + "method": "POST", + "path": "/mr/tickets/types/twilioflex/event_callback/12cc5dcf-44c2-4b25-9781-27275873e0df/$cathy_ticket_uuid$", + "headers": { + "Authorization": "Token 123456789" + }, + "body": "EventType=onMessageSent&InstanceSid=IS38067ec392f1486bb6e4de4610f26fb3&Attributes=%7B%7D&DateCreated=2022-03-10T23%3A56%3A43.412Z&Index=1&From=teste_2Etwilioflex&MessageSid=IM4b440f124820414b8f500a1235532ac1&AccountSid=AC81d44315e19372138bdaffcc13cf3b94&Source=SDK&ChannelSid=CH1880a9cde40c4dbb88dd97fc3aedac08&ClientIdentity=teste_2Etwilioflex&RetryCount=0&WebhookType=webhook&Body=We can help&WebhookSid=WH99d1f1895a7c4e6fa10ac5e8ac0c2242", + "status": 200, + "response": { + "status": "handled" + }, + "db_assertions": [ + { + "query": "select count(*) from msgs_msg where direction = 'O'", + "count": 1 + }, + { + "query": "select count(*) from tickets_ticket where status = 'O'", + "count": 1 + } + ] + }, + { + "label": "close room if everything is correct", + "method": "POST", + "path": "/mr/tickets/types/twilioflex/event_callback/12cc5dcf-44c2-4b25-9781-27275873e0df/$cathy_ticket_uuid$", + "body": "CreatedBy=system&FriendlyName=dummy%20user&EventType=onChannelUpdated&InstanceSid=IS38067ec392f1486bb6e4de4610f26fb3&DateUpdated=2022-03-11T19%3A22%3A26.236Z&Attributes=%7B%22task_sid%22%3A%22WT3010541794b70ae138f62dcb83b84eb6%22%2C%22from%22%3A%22dummy%20user2%22%2C%22channel_type%22%3A%22web%22%2C%22status%22%3A%22INACTIVE%22%2C%22long_lived%22%3Afalse%7D&DateCreated=2022-03-11T19%3A17%3A51.196Z&AccountSid=AC81d44315e19372138bdaffcc13cf3b94&Source=SDK&ChannelSid=CH6442c09c93ba4d13966fa42e9b78f620&ClientIdentity=teste_2Etwilioflex&RetryCount=0&WebhookType=webhook&ChannelType=private&WebhookSid=WH2154dcf90a06454cb420923ac1d2253f", + "status": 200, + "response": { + "status": "handled" + }, + "db_assertions": [ + { + "query": "select count(*) from tickets_ticket where status = 'C'", + "count": 1 + } + ] + } +] \ No newline at end of file From 6fbb3e87a54e27b9fbfbc2573b87f3232c66b69f Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 11 Mar 2022 19:26:42 -0300 Subject: [PATCH 14/24] twilioflex ticketer web test --- services/tickets/twilioflex/web_test.go | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 services/tickets/twilioflex/web_test.go diff --git a/services/tickets/twilioflex/web_test.go b/services/tickets/twilioflex/web_test.go new file mode 100644 index 000000000..7c5416312 --- /dev/null +++ b/services/tickets/twilioflex/web_test.go @@ -0,0 +1,30 @@ +package twilioflex_test + +import ( + "log" + "testing" + + "github.com/nyaruka/mailroom/testsuite" + "github.com/nyaruka/mailroom/testsuite/testdata" + "github.com/nyaruka/mailroom/web" +) + +func TestEventCallback(t *testing.T) { + ctx, rt, db, _ := testsuite.Get() + + defer testsuite.Reset(testsuite.ResetData | testsuite.ResetStorage) + + ticket := testdata.InsertOpenTicket( + db, + testdata.Org1, + testdata.Cathy, + testdata.Twilioflex, + testdata.DefaultTopic, + "Have you seen my cookies?", + "CH6442c09c93ba4d13966fa42e9b78f620", + nil, + ) + + log.Println(string(ticket.UUID)) + web.RunWebTests(t, ctx, rt, "testdata/event_callback.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) +} From 0d62683acf7080d7a4b66e7f8342fb8da404b04a Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 11 Mar 2022 19:27:50 -0300 Subject: [PATCH 15/24] refactor twilioflex ticketer web --- services/tickets/twilioflex/web.go | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/services/tickets/twilioflex/web.go b/services/tickets/twilioflex/web.go index 861037073..999e58d72 100644 --- a/services/tickets/twilioflex/web.go +++ b/services/tickets/twilioflex/web.go @@ -19,7 +19,7 @@ import ( func init() { base := "/mr/tickets/types/twilioflex" - web.RegisterRoute(http.MethodPost, base+"/event_callback/{ticketer:[a-f0-9\\-]+}/{ticket:[a-f0-9\\-]+}", handleEventCallback) + web.RegisterJSONRoute(http.MethodPost, base+"/event_callback/{ticketer:[a-f0-9\\-]+}/{ticket:[a-f0-9\\-]+}", web.WithHTTPLogs(handleEventCallback)) } type eventCallbackRequest struct { @@ -40,33 +40,33 @@ type eventCallbackRequest struct { WebhookSid string `json:"webhook_sid,omitempty"` } -func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) error { +func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { ticketerUUID := assets.TicketerUUID(chi.URLParam(r, "ticketer")) request := &eventCallbackRequest{} if err := web.DecodeAndValidateForm(request, r); err != nil { - return errors.Wrapf(err, "error decoding form") + return errors.Wrapf(err, "error decoding form"), http.StatusBadRequest, nil } ticketer, _, err := tickets.FromTicketerUUID(ctx, rt, ticketerUUID, typeTwilioFlex) if err != nil { - return errors.Errorf("no such ticketer %s", ticketerUUID) + return errors.Errorf("no such ticketer %s", ticketerUUID), http.StatusNotFound, nil } accountSid := request.AccountSid if accountSid != ticketer.Config(configurationAccountSid) { - return errors.New("Unauthorized") + return map[string]string{"status": "unauthorized"}, http.StatusUnauthorized, nil } ticketUUID := uuids.UUID(chi.URLParam(r, "ticket")) ticket, _, _, err := tickets.FromTicketUUID(ctx, rt, flows.TicketUUID(ticketUUID), typeTwilioFlex) if err != nil { - return errors.Errorf("no such ticket %s", ticketUUID) + return errors.Errorf("no such ticket %s", ticketUUID), http.StatusNotFound, nil } oa, err := models.GetOrgAssets(ctx, rt, ticket.OrgID()) if err != nil { - return err + return err, http.StatusBadRequest, nil } switch request.EventType { @@ -74,23 +74,20 @@ func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Reque // TODO: Attachments _, err = tickets.SendReply(ctx, rt, ticket, request.Body, []*tickets.File{}) if err != nil { - return err + return err, http.StatusBadRequest, nil } case "onChannelUpdated": jsonMap := make(map[string]interface{}) err = json.Unmarshal([]byte(request.Attributes), &jsonMap) if err != nil { - return err + return err, http.StatusBadRequest, nil } if jsonMap["status"] == "INACTIVE" { err = tickets.Close(ctx, rt, oa, ticket, false, nil) if err != nil { - return err + return err, http.StatusBadRequest, nil } } } - return nil -} - -type EventAttributes struct { + return map[string]string{"status": "handled"}, http.StatusOK, nil } From f631afc48bfc3029dcecce9ac9ff8f53636493cb Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Wed, 16 Mar 2022 14:53:16 -0300 Subject: [PATCH 16/24] support to media message --- services/tickets/twilioflex/client.go | 101 +++++++++- services/tickets/twilioflex/client_test.go | 92 ++++++++- services/tickets/twilioflex/service.go | 55 +++++- services/tickets/twilioflex/service_test.go | 185 +++++++++++++++++- .../TestOpenAndForward_forward_message.snap | 4 +- .../twilioflex/testdata/event_callback.json | 49 +++++ services/tickets/twilioflex/web.go | 58 ++++-- 7 files changed, 513 insertions(+), 31 deletions(-) diff --git a/services/tickets/twilioflex/client.go b/services/tickets/twilioflex/client.go index df2b8ceea..b75f0de73 100644 --- a/services/tickets/twilioflex/client.go +++ b/services/tickets/twilioflex/client.go @@ -1,8 +1,11 @@ package twilioflex import ( + "bytes" "errors" "fmt" + "io" + "mime/multipart" "net/http" "net/url" "strconv" @@ -162,7 +165,7 @@ func (c *Client) CreateFlexChannelWebhook(channelWebhook *CreateChatChannelWebho } // CreateMessage create a message in chat channel. -func (c *Client) CreateMessage(message *ChatMessage) (*ChatMessage, *httpx.Trace, error) { +func (c *Client) CreateMessage(message *CreateChatMessageParams) (*ChatMessage, *httpx.Trace, error) { url := fmt.Sprintf("https://chat.twilio.com/v2/Services/%s/Channels/%s/Messages", c.serviceSid, message.ChannelSid) response := &ChatMessage{} data, err := query.Values(message) @@ -197,6 +200,66 @@ func (c *Client) CompleteTask(taskSid string) (*TaskrouterTask, *httpx.Trace, er return response, trace, nil } +func (c *Client) CreateMedia(media *CreateMediaParams) (*Media, *httpx.Trace, error) { + url := fmt.Sprintf("https://mcs.us1.twilio.com/v1/Services/%s/Media", c.serviceSid) + response := &Media{} + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + mediaPart, err := writer.CreateFormFile("Media", media.FileName) + if err != nil { + return nil, nil, err + } + mediaReader := bytes.NewReader(media.Media) + io.Copy(mediaPart, mediaReader) + + filenamePart, err := writer.CreateFormField("FileName") + if err != nil { + return nil, nil, err + } + filenameReader := bytes.NewReader([]byte(media.FileName)) + io.Copy(filenamePart, filenameReader) + + writer.Close() + + req, err := httpx.NewRequest("POST", url, body, map[string]string{}) + if err != nil { + return nil, nil, err + } + req.SetBasicAuth(c.accountSid, c.authToken) + req.Header.Add("Content-Type", writer.FormDataContentType()) + + trace, err := httpx.DoTrace(c.httpClient, req, c.httpRetries, nil, -1) + if err != nil { + return nil, trace, err + } + + if trace.Response.StatusCode >= 400 { + response := &errorResponse{} + jsonx.Unmarshal(trace.ResponseBody, response) + return nil, trace, errors.New(response.Message) + } + + err = jsonx.Unmarshal(trace.ResponseBody, response) + if err != nil { + return nil, trace, err + } + + return response, trace, nil +} + +// FetchMedia fetch a twilio flex Media by this sid. +func (c *Client) FetchMedia(mediaSid string) (*Media, *httpx.Trace, error) { + fetchUrl := fmt.Sprintf("https://mcs.us1.twilio.com/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/%s", mediaSid) + response := &Media{} + data := url.Values{} + trace, err := c.get(fetchUrl, data, response) + if err != nil { + return nil, trace, err + } + return response, trace, err +} + // https://www.twilio.com/docs/chat/rest/user-resource#user-properties type ChatUser struct { AccountSid string `json:"account_sid,omitempty"` @@ -284,6 +347,13 @@ type ChatMessage struct { WasEdited bool `json:"was_edited,omitempty"` } +type CreateChatMessageParams struct { + Body string `json:"Body,omitempty"` + From string `json:"From,omitempty"` + MediaSid string `json:"MediaSid,omitempty"` + ChannelSid string `json:"channel_sid,omitempty"` +} + // https://www.twilio.com/docs/chat/rest/channel-webhook-resource#channelwebhook-properties type ChatChannelWebhook struct { AccountSid string `json:"account_sid,omitempty"` @@ -333,6 +403,35 @@ type TaskrouterTask struct { WorkspaceSid string `json:"workspace_sid,omitempty"` } +// https://www.twilio.com/docs/chat/rest/media +type CreateMediaParams struct { + FileName string `json:"FileName,omitempty"` + Media []byte `json:"Media,omitempty"` + Author string `json:"Author,omitempty"` +} + +// https://www.twilio.com/docs/chat/rest/media +type Media struct { + Sid string `json:"sid"` + ServiceSid string `json:"service_sid"` + DateCreated string `json:"date_created"` + DateUploadUpdated string `json:"date_upload_updated"` + DateUpdated string `json:"date_updated"` + Links struct { + Content string `json:"content"` + ContentDirectTemporary string `json:"content_direct_temporary"` + } `json:"links"` + Size int `json:"size"` + ContentType string `json:"content_type"` + Filename string `json:"filename"` + Author string `json:"author"` + Category string `json:"category"` + MessageSid interface{} `json:"message_sid"` + ChannelSid interface{} `json:"channel_sid"` + URL string `json:"url"` + IsMultipartUpstream bool `json:"is_multipart_upstream"` +} + // removeEmpties remove empty values from url.Values func removeEmpties(uv url.Values) url.Values { for k, v := range uv { diff --git a/services/tickets/twilioflex/client_test.go b/services/tickets/twilioflex/client_test.go index 5c3272a1d..de64bfc06 100644 --- a/services/tickets/twilioflex/client_test.go +++ b/services/tickets/twilioflex/client_test.go @@ -270,7 +270,7 @@ func TestCreateMessage(t *testing.T) { client := twilioflex.NewClient(http.DefaultClient, nil, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid) - msg := &twilioflex.ChatMessage{ + msg := &twilioflex.CreateChatMessageParams{ From: "123", Body: "hello", ChannelSid: channelSid, @@ -347,5 +347,95 @@ func TestCompleteTask(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "completed", response.AssignmentStatus) assert.Equal(t, "HTTP/1.0 200 OK\r\nContent-Length: 1602\r\n\r\n", string(trace.ResponseTrace)) +} + +func TestCreateMediaResource(t *testing.T) { + defer httpx.SetRequestor(httpx.DefaultRequestor) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + fmt.Sprintf("https://mcs.us1.twilio.com/v1/Services/%s/Media", serviceSid): { + httpx.MockConnectionError, + httpx.NewMockResponse(400, nil, `{"message": "Something went wrong", "detail": "Unknown", "code": 1234, "more_info": "https://www.twilio.com/docs/errors/1234"}`), + httpx.NewMockResponse(201, nil, `{ + "sid": "ME59b872f1e52fbd6fe6ad956bbb4fa9bd", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "date_created": "2022-03-14T13:10:38.897143-07:00", + "date_upload_updated": "2022-03-14T13:10:38.906058-07:00", + "date_updated": "2022-03-14T13:10:38.897143-07:00", + "links": { + "content": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME59b872f1e52fbd6fe6ad956bbb4fa9bd/Content" + }, + "size": 153611, + "content_type": "image/jpeg", + "filename": "00ac28a5d76a30d5c8ec4f3a73964887.jpg", + "author": "system", + "category": "media", + "message_sid": null, + "channel_sid": null, + "url": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME59b872f1e52fbd6fe6ad956bbb4fa9bd", + "is_multipart_upstream": false + }`), + }, + })) + + client := twilioflex.NewClient(http.DefaultClient, nil, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid) + + mediaContent := &twilioflex.CreateMediaParams{ + FileName: "00ac28a5d76a30d5c8ec4f3a73964887.jpg", + Media: []byte(""), + } + + _, _, err := client.CreateMedia(mediaContent) + assert.EqualError(t, err, "unable to connect to server") + + _, _, err = client.CreateMedia(mediaContent) + assert.EqualError(t, err, "Something went wrong") + + response, trace, err := client.CreateMedia(mediaContent) + assert.NoError(t, err) + assert.Equal(t, "00ac28a5d76a30d5c8ec4f3a73964887.jpg", response.Filename) + assert.Equal(t, "HTTP/1.0 201 Created\r\nContent-Length: 788\r\n\r\n", string(trace.ResponseTrace)) +} + +func TestFetchMedia(t *testing.T) { + mediaSid := "ME59b872f1e52fbd6fe6ad956bbb4fa9bd" + defer httpx.SetRequestor(httpx.DefaultRequestor) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + fmt.Sprintf("https://mcs.us1.twilio.com/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/%s", mediaSid): { + httpx.MockConnectionError, + httpx.NewMockResponse(400, nil, `{"message": "Something went wrong", "detail": "Unknown", "code": 1234, "more_info": "https://www.twilio.com/docs/errors/1234"}`), + httpx.NewMockResponse(200, nil, `{ + "sid": "ME59b872f1e52fbd6fe6ad956bbb4fa9bd", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "date_created": "2022-03-14T13:10:38.897143-07:00", + "date_upload_updated": "2022-03-14T13:10:38.906058-07:00", + "date_updated": "2022-03-14T13:10:38.897143-07:00", + "links": { + "content": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME59b872f1e52fbd6fe6ad956bbb4fa9bd/Content", + "content_direct_temporary": "https://media.us1.twilio.com/ME59b872f1e52fbd6fe6ad956bbb4fa9bd?Expires=1647355356&Signature=n05WWrmDwS4yQ521cNeL9LSH7g1RZg3gpmZ83TAy6eHHuW8KqAGn~wl0p5KGlTJYIhGmfTKhYS8o~zSr1L2iDmFyZDawiueHXqeebFNJiM~tviKn5Inna0mgI~nKSl6iV6F6sKUPnkeAc~AVb7Z3qfDaiyf87ucjyBKRTYkKT7a85c2hhBy4z8DOOeVBWNCEZxA08x-iZDsKYwPtIp~jJIwXrHA5nn3GE62jomjLkfd7RoFVggQhPjmrQQsF9Ock-piPiTb-J3o1risNaHux2rycKCO~U4hndnyo26FEeS71iemIK71hxV7MHtfFEubx04eRYijYRfaUEoWc6IXdxQ__&Key-Pair-Id=APKAJWF6YVTMIIYOF3AA" + }, + "size": 153611, + "content_type": "image/jpeg", + "filename": "00ac28a5d76a30d5c8ec4f3a73964887.jpg", + "author": "system", + "category": "media", + "message_sid": "IMadceb005ef924c728b6abde17d02775c", + "channel_sid": "CH180fa48ef2ba40a08fa5c9fb5c8ddd99", + "url": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME59b872f1e52fbd6fe6ad956bbb4fa9bd", + "is_multipart_upstream": false + }`), + }, + })) + + client := twilioflex.NewClient(http.DefaultClient, nil, authToken, accountSid, serviceSid, workspaceSid, flexFlowSid) + _, _, err := client.FetchMedia(mediaSid) + assert.EqualError(t, err, "unable to connect to server") + + _, _, err = client.FetchMedia(mediaSid) + assert.EqualError(t, err, "Something went wrong") + + response, trace, err := client.FetchMedia(mediaSid) + assert.NoError(t, err) + assert.Equal(t, "ME59b872f1e52fbd6fe6ad956bbb4fa9bd", response.Sid) + assert.Equal(t, "HTTP/1.0 200 OK\r\nContent-Length: 1342\r\n\r\n", string(trace.ResponseTrace)) } diff --git a/services/tickets/twilioflex/service.go b/services/tickets/twilioflex/service.go index 3ecbeaa7f..935563e66 100644 --- a/services/tickets/twilioflex/service.go +++ b/services/tickets/twilioflex/service.go @@ -3,6 +3,8 @@ package twilioflex import ( "fmt" "net/http" + "net/url" + "path" "github.com/pkg/errors" @@ -117,12 +119,60 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error { identity := fmt.Sprint(ticket.ContactID()) - msg := &ChatMessage{ + + // TODO: attachments + if len(attachments) > 0 { + mediaAttachements := []CreateMediaParams{} + for _, attachment := range attachments { + attUrl := attachment.URL() + req, err := http.NewRequest("GET", attUrl, nil) + resp, err := httpx.DoTrace(s.restClient.httpClient, req, s.restClient.httpRetries, nil, -1) + if err != nil { + return err + } + // mediaBody, err := io.ReadAll(resp) + // if err != nil { + // return err + // } + + parsedURL, err := url.Parse(attUrl) + if err != nil { + return err + } + filename := path.Base(parsedURL.Path) + + media := CreateMediaParams{ + FileName: filename, + Media: resp.ResponseBody, + Author: identity, + } + + mediaAttachements = append(mediaAttachements, media) + + // resp.Body.Close() + } + + for _, mediaParams := range mediaAttachements { + media, trace, err := s.restClient.CreateMedia(&mediaParams) + if err != nil { + return err + } + logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) + + msg := &CreateChatMessageParams{ + From: identity, + ChannelSid: string(ticket.ExternalID()), + MediaSid: media.Sid, + } + _, trace, err = s.restClient.CreateMessage(msg) + } + + } + msg := &CreateChatMessageParams{ From: identity, Body: text, ChannelSid: string(ticket.ExternalID()), } - // TODO: attachments _, trace, err := s.restClient.CreateMessage(msg) if trace != nil { logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) @@ -130,6 +180,7 @@ func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text str if err != nil { return errors.Wrap(err, "error calling Twilio") } + return nil } diff --git a/services/tickets/twilioflex/service_test.go b/services/tickets/twilioflex/service_test.go index 46abe698c..56f725e4b 100644 --- a/services/tickets/twilioflex/service_test.go +++ b/services/tickets/twilioflex/service_test.go @@ -60,7 +60,7 @@ func TestOpenAndForward(t *testing.T) { "attributes": "{}", "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", "joined_channels_count": 0, - "identity": "123", + "identity": "10000", "links": { "user_channels": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f/Channels", "user_bindings": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Users/USf4015a97250d482889459f8e8819e09f/Bindings" @@ -105,7 +105,7 @@ func TestOpenAndForward(t *testing.T) { "body": "It's urgent", "index": 0, "channel_sid": "CH6442c09c93ba4d13966fa42e9b78f620", - "from": "123", + "from": "10000", "date_updated": "2022-03-09T20:27:47Z", "type": "text", "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", @@ -120,6 +120,163 @@ func TestOpenAndForward(t *testing.T) { "was_edited": false }`), }, + "https://link.to/dummy_image.jpg": { + httpx.NewMockResponse(200, map[string]string{"Content-Type": "image/jpeg"}, `imagebytes`), + }, + "https://link.to/dummy_video.mp4": { + httpx.NewMockResponse(200, map[string]string{"Content-Type": "video/mp4"}, `videobytes`), + }, + "https://link.to/dummy_audio.ogg": { + httpx.NewMockResponse(200, map[string]string{"Content-Type": "audio/ogg"}, `audiobytes`), + }, + "https://mcs.us1.twilio.com/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media": { + httpx.NewMockResponse(201, nil, `{ + "sid": "ME59b872f1e52fbd6fe6ad956bbb4fa9bd", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "date_created": "2022-03-14T13:10:38.897143-07:00", + "date_upload_updated": "2022-03-14T13:10:38.906058-07:00", + "date_updated": "2022-03-14T13:10:38.897143-07:00", + "links": { + "content": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME59b872f1e52fbd6fe6ad956bbb4fa9bd/Content" + }, + "size": 153611, + "content_type": "image/jpeg", + "filename": "dummy_image.jpg", + "author": "10000", + "category": "media", + "message_sid": null, + "channel_sid": null, + "url": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME59b872f1e52fbd6fe6ad956bbb4fa9bd", + "is_multipart_upstream": false + }`), + httpx.NewMockResponse(201, nil, `{ + "sid": "ME60b872f1e52fbd6fe6ad956bbb4fa9ce", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "date_created": "2022-03-14T13:10:38.897143-07:00", + "date_upload_updated": "2022-03-14T13:10:38.906058-07:00", + "date_updated": "2022-03-14T13:10:38.897143-07:00", + "links": { + "content": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME60b872f1e52fbd6fe6ad956bbb4fa9ce/Content" + }, + "size": 153611, + "content_type": "video/mp4", + "filename": "dummy_video.mp4", + "author": "10000", + "category": "media", + "message_sid": null, + "channel_sid": null, + "url": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME60b872f1e52fbd6fe6ad956bbb4fa9ce", + "is_multipart_upstream": false + }`), + httpx.NewMockResponse(201, nil, `{ + "sid": "ME71b872f1e52fbd6fe6ad956bbb4fa9df", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "date_created": "2022-03-14T13:10:38.897143-07:00", + "date_upload_updated": "2022-03-14T13:10:38.906058-07:00", + "date_updated": "2022-03-14T13:10:38.897143-07:00", + "links": { + "content": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME71b872f1e52fbd6fe6ad956bbb4fa9df/Content" + }, + "size": 153611, + "content_type": "audio/ogg", + "filename": "dummy_audio.ogg", + "author": "10000", + "category": "media", + "message_sid": null, + "channel_sid": null, + "url": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME71b872f1e52fbd6fe6ad956bbb4fa9df", + "is_multipart_upstream": false + }`), + }, + "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH180fa48ef2ba40a08fa5c9fb5c8ddd99/Messages": { + httpx.NewMockResponse(201, nil, `{ + "body": null, + "index": 0, + "channel_sid": "CH180fa48ef2ba40a08fa5c9fb5c8ddd99", + "from": "10000", + "date_updated": "2022-03-14T20:11:08Z", + "type": "media", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "to": "CH180fa48ef2ba40a08fa5c9fb5c8ddd99", + "last_updated_by": null, + "date_created": "2022-03-14T20:11:08Z", + "media": { + "size": 153611, + "filename": "dummy_image.jpg", + "content_type": "image/jpeg", + "sid": "ME59b872f1e52fbd6fe6ad956bbb4fa9bd" + }, + "sid": "IMadceb005ef924c728b6abde17d02775c", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH180fa48ef2ba40a08fa5c9fb5c8ddd99/Messages/IMadceb005ef924c728b6abde17d02775c", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "was_edited": false + }`), + httpx.NewMockResponse(201, nil, `{ + "body": null, + "index": 1, + "channel_sid": "CH180fa48ef2ba40a08fa5c9fb5c8ddd99", + "from": "10000", + "date_updated": "2022-03-14T20:11:08Z", + "type": "media", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "to": "CH180fa48ef2ba40a08fa5c9fb5c8ddd99", + "last_updated_by": null, + "date_created": "2022-03-14T20:11:08Z", + "media": { + "size": 153611, + "filename": "dummy_video.mp4", + "content_type": "video/mp4", + "sid": "ME60b872f1e52fbd6fe6ad956bbb4fa9ce" + }, + "sid": "IMbcdeb005ef924c728b6abde17d02786d", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH180fa48ef2ba40a08fa5c9fb5c8ddd99/Messages/IMbcdeb005ef924c728b6abde17d02786d", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "was_edited": false + }`), + httpx.NewMockResponse(201, nil, `{ + "body": null, + "index": 2, + "channel_sid": "CH180fa48ef2ba40a08fa5c9fb5c8ddd99", + "from": "10000", + "date_updated": "2022-03-14T20:11:08Z", + "type": "media", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "to": "CH180fa48ef2ba40a08fa5c9fb5c8ddd99", + "last_updated_by": null, + "date_created": "2022-03-14T20:11:08Z", + "media": { + "size": 153611, + "filename": "dummy_sound.ogg", + "content_type": "sound/ogg", + "sid": "ME71b872f1e52fbd6fe6ad956bbb4fa9df" + }, + "sid": "IMcedfb005ef924c728b6abde17d02798e", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH180fa48ef2ba40a08fa5c9fb5c8ddd99/Messages/IMcedfb005ef924c728b6abde17d02798e", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "was_edited": false + }`), + httpx.NewMockResponse(201, nil, `{ + "body": "It's urgent", + "index": 0, + "channel_sid": "CH180fa48ef2ba40a08fa5c9fb5c8ddd99", + "from": "10000", + "date_updated": "2022-03-09T20:27:47Z", + "type": "text", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "to": "CH6442c09c93ba4d13966fa42e9b78f620", + "last_updated_by": null, + "date_created": "2022-03-09T20:27:47Z", + "media": null, + "sid": "IM8842e723153b459b9e03a0bae87298d8", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH180fa48ef2ba40a08fa5c9fb5c8ddd99/Messages/IM8842e723153b459b9e03a0bae87298d8", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "was_edited": false + }`), + }, })) ticketer := flows.NewTicketer(static.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "twilioflex")) @@ -172,15 +329,25 @@ func TestOpenAndForward(t *testing.T) { assert.EqualError(t, err, "error calling Twilio: unable to connect to server") logger = &flows.HTTPLogger{} - attachments := []utils.Attachment{ - "image/jpg:https://link.to/image.jpg", - "video/mp4:https://link.to/video.mp4", - "audio/ogg:https://link.to/audio.ogg", - } - err = svc.Forward(dbTicket, flows.MsgUUID("4fa340ae-1fb0-4666-98db-2177fe9bf31c"), "It's urgent", attachments, logger.Log) - require.NoError(t, err) + err = svc.Forward(dbTicket, flows.MsgUUID("4fa340ae-1fb0-4666-98db-2177fe9bf31c"), "It's urgent", nil, logger.Log) + assert.NoError(t, err) assert.Equal(t, 1, len(logger.Logs)) test.AssertSnapshot(t, "forward_message", logger.Logs[0].Request) + + dbTicket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Cathy.ID, testdata.Twilioflex.ID, "CH180fa48ef2ba40a08fa5c9fb5c8ddd99", testdata.DefaultTopic.ID, "Where are my cookies?", models.NilUserID, map[string]interface{}{ + "contact-uuid": string(testdata.Cathy.UUID), + "contact-display": "Cathy", + }) + + logger = &flows.HTTPLogger{} + attachments := []utils.Attachment{ + "image/jpg:https://link.to/dummy_image.jpg", + "video/mp4:https://link.to/dummy_video.mp4", + "audio/ogg:https://link.to/dummy_audio.ogg", + } + err = svc.Forward(dbTicket2, flows.MsgUUID("5ga340ae-1fb0-4666-98db-2177fe9bf31c"), "It's urgent", attachments, logger.Log) + assert.NoError(t, err) + assert.Equal(t, 4, len(logger.Logs)) } func TestCloseAndReopen(t *testing.T) { diff --git a/services/tickets/twilioflex/testdata/TestOpenAndForward_forward_message.snap b/services/tickets/twilioflex/testdata/TestOpenAndForward_forward_message.snap index 909aa91cf..a36abde04 100644 --- a/services/tickets/twilioflex/testdata/TestOpenAndForward_forward_message.snap +++ b/services/tickets/twilioflex/testdata/TestOpenAndForward_forward_message.snap @@ -1,9 +1,9 @@ POST /v2/Services/****************/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Messages HTTP/1.1 Host: chat.twilio.com User-Agent: Go-http-client/1.1 -Content-Length: 115 +Content-Length: 75 Authorization: Basic QUM4MWQ0NDMxNWUxOTM3MjEzOGJkYWZmY2MxM2NmM2I5NDp0b2tlbg== Content-Type: application/x-www-form-urlencoded Accept-Encoding: gzip -Body=It%27s+urgent&ChannelSid=CH6442c09c93ba4d13966fa42e9b78f620&From=10000&Index=0&Media=map%5B%5D&WasEdited=false \ No newline at end of file +Body=It%27s+urgent&ChannelSid=CH6442c09c93ba4d13966fa42e9b78f620&From=10000 \ No newline at end of file diff --git a/services/tickets/twilioflex/testdata/event_callback.json b/services/tickets/twilioflex/testdata/event_callback.json index 88758a9d4..ad2f215bc 100644 --- a/services/tickets/twilioflex/testdata/event_callback.json +++ b/services/tickets/twilioflex/testdata/event_callback.json @@ -56,6 +56,55 @@ } ] }, + { + "label": "create message with attachments if everything is correct", + "method": "POST", + "path": "/mr/tickets/types/twilioflex/event_callback/12cc5dcf-44c2-4b25-9781-27275873e0df/$cathy_ticket_uuid$", + "body": "MediaSid=ME59b872f1e52fbd6fe6ad956bbb4fa9bd&MediaSize=153575&EventType=onMediaMessageSent&InstanceSid=IS38067ec392f1486bb6e4de4610f26fb3&Attributes=%7B%7D&DateCreated=2022-03-14T19%3A48%3A35.727Z&Index=3&From=teste_2Etwilioflex&MessageSid=IM8c57eaf105f34905883b1192e9499641&AccountSid=AC81d44315e19372138bdaffcc13cf3b94&Source=SDK&ChannelSid=CH1880a9cde40c4dbb88dd97fc3aedac08&ClientIdentity=teste_2Etwilioflex&RetryCount=0&MediaContentType=image%2Fjpeg&WebhookType=webhook&MediaFilename=dummy_image.jpg&Body=&WebhookSid=WH4ab46f21e24d4b58b8e3b3a20ce6a1ec", + "http_mocks": { + "https://mcs.us1.twilio.com/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME59b872f1e52fbd6fe6ad956bbb4fa9bd": [ + { + "status": 200, + "body": { + "sid": "ME59b872f1e52fbd6fe6ad956bbb4fa9bd", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "date_created": "2022-03-14T13:10:38.897143-07:00", + "date_upload_updated": "2022-03-14T13:10:38.906058-07:00", + "date_updated": "2022-03-14T13:10:38.897143-07:00", + "links": { + "content": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME59b872f1e52fbd6fe6ad956bbb4fa9bd/Content", + "content_direct_temporary": "https://media.us1.twilio.com/ME59b872f1e52fbd6fe6ad956bbb4fa9bd?Expires=1647355356&Signature=n05WWrmDwS4yQ521cNeL9LSH7g1RZg3gpmZ83TAy6eHHuW8KqAGn~wl0p5KGlTJYIhGmfTKhYS8o~zSr1L2iDmFyZDawiueHXqeebFNJiM~tviKn5Inna0mgI~nKSl6iV6F6sKUPnkeAc~AVb7Z3qfDaiyf87ucjyBKRTYkKT7a85c2hhBy4z8DOOeVBWNCEZxA08x-iZDsKYwPtIp~jJIwXrHA5nn3GE62jomjLkfd7RoFVggQhPjmrQQsF9Ock-piPiTb-J3o1risNaHux2rycKCO~U4hndnyo26FEeS71iemIK71hxV7MHtfFEubx04eRYijYRfaUEoWc6IXdxQ__&Key-Pair-Id=APKAJWF6YVTMIIYOF3AA" + }, + "size": 153611, + "content_type": "image/jpeg", + "filename": "dummy_image.jpg", + "author": "system", + "category": "media", + "message_sid": "IM8c57eaf105f34905883b1192e9499641", + "channel_sid": "CH1880a9cde40c4dbb88dd97fc3aedac08", + "url": "/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/ME59b872f1e52fbd6fe6ad956bbb4fa9bd", + "is_multipart_upstream": false + } + } + ], + "https://media.us1.twilio.com/ME59b872f1e52fbd6fe6ad956bbb4fa9bd?Expires=1647355356&Signature=n05WWrmDwS4yQ521cNeL9LSH7g1RZg3gpmZ83TAy6eHHuW8KqAGn~wl0p5KGlTJYIhGmfTKhYS8o~zSr1L2iDmFyZDawiueHXqeebFNJiM~tviKn5Inna0mgI~nKSl6iV6F6sKUPnkeAc~AVb7Z3qfDaiyf87ucjyBKRTYkKT7a85c2hhBy4z8DOOeVBWNCEZxA08x-iZDsKYwPtIp~jJIwXrHA5nn3GE62jomjLkfd7RoFVggQhPjmrQQsF9Ock-piPiTb-J3o1risNaHux2rycKCO~U4hndnyo26FEeS71iemIK71hxV7MHtfFEubx04eRYijYRfaUEoWc6IXdxQ__&Key-Pair-Id=APKAJWF6YVTMIIYOF3AA": [ + { + "status": 200, + "body": "IMAGE" + } + ] + }, + "status": 200, + "response": { + "status": "handled" + }, + "db_assertions": [ + { + "query": "select count(*) from msgs_msg where direction = 'O' and attachments = '{image/jpeg:https:///_test_media_storage/media/1/6929/26ea/692926ea-09d6-4942-bd38-d266ec8d3716}'", + "count": 1 + } + ] + }, { "label": "close room if everything is correct", "method": "POST", diff --git a/services/tickets/twilioflex/web.go b/services/tickets/twilioflex/web.go index 999e58d72..868bd7c92 100644 --- a/services/tickets/twilioflex/web.go +++ b/services/tickets/twilioflex/web.go @@ -23,21 +23,25 @@ func init() { } type eventCallbackRequest struct { - EventType string `json:"event_type,omitempty"` - InstanceSid string `json:"instance_sid,omitempty"` - Attributes string `json:"attributes,omitempty"` - DateCreated *time.Time `json:"date_created,omitempty"` - Index int `json:"index,omitempty"` - From string `json:"from,omitempty"` - MessageSid string `json:"message_sid,omitempty"` - AccountSid string `json:"account_sid,omitempty"` - Source string `json:"source,omitempty"` - ChannelSid string `json:"channel_sid,omitempty"` - ClientIdentity string `json:"client_identity,omitempty"` - RetryCount int `json:"retry_count,omitempty"` - WebhookType string `json:"webhook_type,omitempty"` - Body string `json:"body,omitempty"` - WebhookSid string `json:"webhook_sid,omitempty"` + EventType string `json:"event_type,omitempty"` + InstanceSid string `json:"instance_sid,omitempty"` + Attributes string `json:"attributes,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + Index int `json:"index,omitempty"` + From string `json:"from,omitempty"` + MessageSid string `json:"message_sid,omitempty"` + AccountSid string `json:"account_sid,omitempty"` + Source string `json:"source,omitempty"` + ChannelSid string `json:"channel_sid,omitempty"` + ClientIdentity string `json:"client_identity,omitempty"` + RetryCount int `json:"retry_count,omitempty"` + WebhookType string `json:"webhook_type,omitempty"` + Body string `json:"body,omitempty"` + WebhookSid string `json:"webhook_sid,omitempty"` + MediaSid string `json:"media_sid,omitempty"` + MediaSize string `json:"media_size,omitempty"` + MediaContentType string `json:"media_content_type,omitempty"` + MediaFilename string `json:"media_filename,omitempty"` } func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) { @@ -71,11 +75,33 @@ func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Reque switch request.EventType { case "onMessageSent": - // TODO: Attachments _, err = tickets.SendReply(ctx, rt, ticket, request.Body, []*tickets.File{}) if err != nil { return err, http.StatusBadRequest, nil } + case "onMediaMessageSent": + config := ticketer.Config + authToken := config(configurationAuthToken) + accountSid := config(configurationAccountSid) + chatServiceSid := config(configurationChatServiceSid) + workspaceSid := config(configurationWorkspaceSid) + flexFlowSid := config(configurationFlexFlowSid) + + client := NewClient(http.DefaultClient, nil, authToken, accountSid, chatServiceSid, workspaceSid, flexFlowSid) + + mediaContent, _, err := client.FetchMedia(request.MediaSid) + if err != nil { + return err, http.StatusBadRequest, nil + } + file, err := tickets.FetchFile(mediaContent.Links.ContentDirectTemporary, nil) + file.ContentType = mediaContent.ContentType + if err != nil { + return errors.Wrapf(err, "error fetching ticket file '%s'", mediaContent.Links.ContentDirectTemporary), http.StatusBadRequest, nil + } + _, err = tickets.SendReply(ctx, rt, ticket, request.Body, []*tickets.File{file}) + if err != nil { + return err, http.StatusBadRequest, nil + } case "onChannelUpdated": jsonMap := make(map[string]interface{}) err = json.Unmarshal([]byte(request.Attributes), &jsonMap) From 40d90a064032416b269c4f7aba19d719e7414366 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Thu, 17 Mar 2022 12:54:56 -0300 Subject: [PATCH 17/24] adjust twilio flex ticketer client test --- services/tickets/twilioflex/client_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/services/tickets/twilioflex/client_test.go b/services/tickets/twilioflex/client_test.go index de64bfc06..39169e390 100644 --- a/services/tickets/twilioflex/client_test.go +++ b/services/tickets/twilioflex/client_test.go @@ -58,9 +58,6 @@ func TestCreateUser(t *testing.T) { _, _, err = client.CreateUser(params) assert.EqualError(t, err, "Something went wrong") - _, _, err = client.CreateUser(params) - assert.EqualError(t, err, "invalid character 'x' looking for beginning of value") - user, trace, err := client.CreateUser(params) assert.NoError(t, err) assert.Equal(t, "123", user.Identity) From 3e4dd993200fc0c0363c2dcd3803cd288f22a182 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 18 Mar 2022 19:04:42 -0300 Subject: [PATCH 18/24] add in ticketer twilioflex the custom fields to flex channel TaskAttributes --- services/tickets/twilioflex/service.go | 27 +++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/services/tickets/twilioflex/service.go b/services/tickets/twilioflex/service.go index 935563e66..50cbad4c0 100644 --- a/services/tickets/twilioflex/service.go +++ b/services/tickets/twilioflex/service.go @@ -9,6 +9,7 @@ import ( "github.com/pkg/errors" "github.com/nyaruka/gocommon/httpx" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/goflow/flows" "github.com/nyaruka/goflow/utils" "github.com/nyaruka/mailroom/core/models" @@ -53,6 +54,7 @@ func NewService(rtCfg *runtime.Config, httpClient *http.Client, httpRetries *htt return nil, errors.New("missing auth_token or account_sid or chat_service_sid or workspace_sid in twilio flex config") } +// Open opens a ticket wich for Twilioflex means create a Chat Channel associated to a Chat User func (s *service) Open(session flows.Session, topic *flows.Topic, body string, assignee *flows.User, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) { ticket := flows.OpenTicket(s.ticketer, topic, body, assignee) contact := session.Contact() @@ -82,7 +84,25 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a Identity: fmt.Sprint(contact.ID()), ChatUserFriendlyName: contact.Name(), ChatFriendlyName: contact.Name(), + TaskAttributes: body, } + + extra := &struct { + Department string `json:"department"` + CustomFields map[string]string `json:"custom_fields"` + }{} + + if err := jsonx.Unmarshal([]byte(body), extra); err == nil { + taskAttributes := map[string]interface{}{ + "department": extra.Department, + "custom_fields": extra.CustomFields, + } + + if attributes, err := jsonx.Marshal(taskAttributes); err == nil { + flexChannelParams.TaskAttributes = string(attributes) + } + } + newFlexChannel, trace, err := s.restClient.CreateFlexChannel(flexChannelParams) if trace != nil { logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) @@ -120,7 +140,6 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error { identity := fmt.Sprint(ticket.ContactID()) - // TODO: attachments if len(attachments) > 0 { mediaAttachements := []CreateMediaParams{} for _, attachment := range attachments { @@ -130,10 +149,6 @@ func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text str if err != nil { return err } - // mediaBody, err := io.ReadAll(resp) - // if err != nil { - // return err - // } parsedURL, err := url.Parse(attUrl) if err != nil { @@ -148,8 +163,6 @@ func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text str } mediaAttachements = append(mediaAttachements, media) - - // resp.Body.Close() } for _, mediaParams := range mediaAttachements { From 0fe4d35d202d9f5f41bb76c1b565088e17de03ce Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Thu, 24 Mar 2022 18:20:43 -0300 Subject: [PATCH 19/24] twilioflex ticketer history --- core/models/msgs.go | 53 +++++++++++ go.mod | 1 + go.sum | 2 + services/tickets/twilioflex/client.go | 9 +- services/tickets/twilioflex/service.go | 97 +++++++++++++++++---- services/tickets/twilioflex/service_test.go | 79 ++++++++++++++++- 6 files changed, 220 insertions(+), 21 deletions(-) diff --git a/core/models/msgs.go b/core/models/msgs.go index b4c84295d..3fb963471 100644 --- a/core/models/msgs.go +++ b/core/models/msgs.go @@ -441,6 +441,59 @@ func LoadMessages(ctx context.Context, db Queryer, orgID OrgID, direction MsgDir return msgs, nil } +var selectContactMessagesSQL = ` +SELECT + id, + broadcast_id, + uuid, + text, + created_on, + direction, + status, + visibility, + msg_count, + error_count, + next_attempt, + external_id, + attachments, + metadata, + channel_id, + connection_id, + contact_id, + contact_urn_id, + response_to_id, + org_id, + topup_id +FROM + msgs_msg +WHERE + contact_id = $1 AND + created_on >= $2 +ORDER BY + id ASC` + +// SelectContactMessages loads the given messages for the passed in contact, created after the passed in time +func SelectContactMessages(ctx context.Context, db Queryer, contactID int, after time.Time) ([]*Msg, error) { + rows, err := db.QueryxContext(ctx, selectContactMessagesSQL, contactID, after) + if err != nil { + return nil, errors.Wrapf(err, "error querying msgs for contact: %d", contactID) + } + defer rows.Close() + + msgs := make([]*Msg, 0) + for rows.Next() { + msg := &Msg{} + err = rows.StructScan(&msg.m) + if err != nil { + return nil, errors.Wrapf(err, "error scanning msg row") + } + + msgs = append(msgs, msg) + } + + return msgs, nil +} + // NormalizeAttachment will turn any relative URL in the passed in attachment and normalize it to // include the full host for attachment domains func NormalizeAttachment(cfg *runtime.Config, attachment utils.Attachment) utils.Attachment { diff --git a/go.mod b/go.mod index f0b61eef5..1923474da 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( ) require ( + github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/antlr/antlr4 v0.0.0-20200701161529-3d9351f61e0f // indirect github.com/blevesearch/segment v0.9.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index d3d6560b4..38377cb46 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= diff --git a/services/tickets/twilioflex/client.go b/services/tickets/twilioflex/client.go index b75f0de73..98c470c16 100644 --- a/services/tickets/twilioflex/client.go +++ b/services/tickets/twilioflex/client.go @@ -348,10 +348,11 @@ type ChatMessage struct { } type CreateChatMessageParams struct { - Body string `json:"Body,omitempty"` - From string `json:"From,omitempty"` - MediaSid string `json:"MediaSid,omitempty"` - ChannelSid string `json:"channel_sid,omitempty"` + Body string `json:"Body,omitempty"` + From string `json:"From,omitempty"` + MediaSid string `json:"MediaSid,omitempty"` + ChannelSid string `json:"ChanelSid,omitempty"` + DateCreated string `json:"DateCreated,omitempty"` } // https://www.twilio.com/docs/chat/rest/channel-webhook-resource#channelwebhook-properties diff --git a/services/tickets/twilioflex/service.go b/services/tickets/twilioflex/service.go index 50cbad4c0..52c17e5e0 100644 --- a/services/tickets/twilioflex/service.go +++ b/services/tickets/twilioflex/service.go @@ -1,11 +1,16 @@ package twilioflex import ( + "context" "fmt" "net/http" "net/url" "path" + "strings" + "sync" + "time" + "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/nyaruka/gocommon/httpx" @@ -25,6 +30,26 @@ const ( configurationFlexFlowSid = "flex_flow_sid" ) +var db *sqlx.DB +var lock = &sync.Mutex{} + +func initDB(dbURL string) error { + if db == nil { + lock.Lock() + defer lock.Unlock() + newDB, err := sqlx.Open("postgres", dbURL) + if err != nil { + return errors.Wrapf(err, "unable to open database connection") + } + SetDB(newDB) + } + return nil +} + +func SetDB(newDB *sqlx.DB) { + db = newDB +} + func init() { models.RegisterTicketService(typeTwilioFlex, NewService) } @@ -44,6 +69,11 @@ func NewService(rtCfg *runtime.Config, httpClient *http.Client, httpRetries *htt workspaceSid := config[configurationWorkspaceSid] flexFlowSid := config[configurationFlexFlowSid] if authToken != "" && accountSid != "" && chatServiceSid != "" && workspaceSid != "" { + + if err := initDB(rtCfg.DB); err != nil { + return nil, err + } + return &service{ rtConfig: rtCfg, ticketer: ticketer, @@ -51,6 +81,7 @@ func NewService(rtCfg *runtime.Config, httpClient *http.Client, httpRetries *htt redactor: utils.NewRedactor(flows.RedactionMask, authToken, accountSid, chatServiceSid, workspaceSid), }, nil } + return nil, errors.New("missing auth_token or account_sid or chat_service_sid or workspace_sid in twilio flex config") } @@ -88,11 +119,12 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a } extra := &struct { - Department string `json:"department"` - CustomFields map[string]string `json:"custom_fields"` + Department string `json:"department"` + CustomFields map[string]interface{} `json:"custom_fields"` }{} - if err := jsonx.Unmarshal([]byte(body), extra); err == nil { + err = jsonx.Unmarshal([]byte(body), extra) + if err == nil { taskAttributes := map[string]interface{}{ "department": extra.Department, "custom_fields": extra.CustomFields, @@ -120,9 +152,9 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a channelWebhook := &CreateChatChannelWebhookParams{ ConfigurationUrl: callbackURL, - ConfigurationFilters: []string{"onMessageSent", "onChannelUpdated"}, + ConfigurationFilters: []string{"onMessageSent", "onChannelUpdated", "onMediaMessageSent"}, ConfigurationMethod: "POST", - ConfigurationRetryCount: 1, + ConfigurationRetryCount: 0, Type: "webhook", } _, trace, err = s.restClient.CreateFlexChannelWebhook(channelWebhook, newFlexChannel.Sid) @@ -133,6 +165,36 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a return nil, errors.Wrap(err, "failed to create channel webhook") } + // get messages for history + after := session.Runs()[0].CreatedOn() + cx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + msgs, err := models.SelectContactMessages(cx, db, int(contact.ID()), after) + if err != nil { + return nil, errors.Wrap(err, "failed to get history messages") + } + + // send history + for _, msg := range msgs { + m := &CreateChatMessageParams{ + Body: msg.Text(), + ChannelSid: newFlexChannel.Sid, + DateCreated: msg.CreatedOn().Format(time.RFC3339), + } + if msg.Direction() == "I" { + m.From = fmt.Sprint(contact.ID()) + } else { + m.From = "Bot" + } + _, trace, err = s.restClient.CreateMessage(m) + if trace != nil { + logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) + } + if err != nil { + return nil, errors.Wrap(err, "error calling Twilio") + } + } + ticket.SetExternalID(newFlexChannel.Sid) return ticket, nil } @@ -181,17 +243,20 @@ func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text str } } - msg := &CreateChatMessageParams{ - From: identity, - Body: text, - ChannelSid: string(ticket.ExternalID()), - } - _, trace, err := s.restClient.CreateMessage(msg) - if trace != nil { - logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) - } - if err != nil { - return errors.Wrap(err, "error calling Twilio") + + if strings.TrimSpace(text) != "" { + msg := &CreateChatMessageParams{ + From: identity, + Body: text, + ChannelSid: string(ticket.ExternalID()), + } + _, trace, err := s.restClient.CreateMessage(msg) + if trace != nil { + logHTTP(flows.NewHTTPLog(trace, flows.HTTPStatusFromCode, s.redactor)) + } + if err != nil { + return errors.Wrap(err, "error calling Twilio") + } } return nil diff --git a/services/tickets/twilioflex/service_test.go b/services/tickets/twilioflex/service_test.go index 56f725e4b..a45e61b02 100644 --- a/services/tickets/twilioflex/service_test.go +++ b/services/tickets/twilioflex/service_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" "github.com/nyaruka/gocommon/dates" "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/gocommon/uuids" @@ -100,6 +102,60 @@ func TestOpenAndForward(t *testing.T) { }`), }, "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Messages": { + httpx.NewMockResponse(201, nil, `{ + "body": "Hi! I'll try to help you!", + "index": 0, + "channel_sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "from": "10000", + "date_updated": "2022-03-09T20:27:47Z", + "type": "text", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "to": "CH6442c09c93ba4d13966fa42e9b78f620", + "last_updated_by": null, + "date_created": "2022-03-09T20:27:47Z", + "media": null, + "sid": "IM8842e723153b459b9e03a0bae87298d8", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Messages/IM8842e723153b459b9e03a0bae87298d8", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "was_edited": false + }`), + httpx.NewMockResponse(201, nil, `{ + "body": "Where are you from?", + "index": 0, + "channel_sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "from": "10000", + "date_updated": "2022-03-09T20:27:47Z", + "type": "text", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "to": "CH6442c09c93ba4d13966fa42e9b78f620", + "last_updated_by": null, + "date_created": "2022-03-09T20:27:47Z", + "media": null, + "sid": "IM8842e723153b459b9e03a0bae87298d8", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Messages/IM8842e723153b459b9e03a0bae87298d8", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "was_edited": false + }`), + httpx.NewMockResponse(201, nil, `{ + "body": "I'm from Brazil", + "index": 0, + "channel_sid": "CH6442c09c93ba4d13966fa42e9b78f620", + "from": "10000", + "date_updated": "2022-03-09T20:27:47Z", + "type": "text", + "account_sid": "AC81d44315e19372138bdaffcc13cf3b94", + "to": "CH6442c09c93ba4d13966fa42e9b78f620", + "last_updated_by": null, + "date_created": "2022-03-09T20:27:47Z", + "media": null, + "sid": "IM8842e723153b459b9e03a0bae87298d8", + "url": "https://chat.twilio.com/v2/Services/IS38067ec392f1486bb6e4de4610f26fb3/Channels/CH6442c09c93ba4d13966fa42e9b78f620/Messages/IM8842e723153b459b9e03a0bae87298d8", + "attributes": "{}", + "service_sid": "IS38067ec392f1486bb6e4de4610f26fb3", + "was_edited": false + }`), httpx.MockConnectionError, httpx.NewMockResponse(201, nil, `{ "body": "It's urgent", @@ -290,6 +346,27 @@ func TestOpenAndForward(t *testing.T) { ) assert.EqualError(t, err, "missing auth_token or account_sid or chat_service_sid or workspace_sid in twilio flex config") + mockDB, mock, err := sqlmock.New() + defer mockDB.Close() + sqlxDB := sqlx.NewDb(mockDB, "sqlmock") + + dummyTime, _ := time.Parse(time.RFC1123, "2019-10-07T15:21:30") + + rows := sqlmock.NewRows([]string{"id", "uuid", "text", "high_priority", "created_on", "modified_on", "sent_on", "queued_on", "direction", "status", "visibility", "msg_type", "msg_count", "error_count", "next_attempt", "external_id", "attachments", "metadata", "broadcast_id", "channel_id", "connection_id", "contact_id", "contact_urn_id", "org_id", "response_to_id", "topup_id"}). + AddRow(100, "1348d654-e3dc-4f2f-add0-a9163dc48895", "Hi! I'll try to help you!", true, dummyTime, dummyTime, dummyTime, dummyTime, "O", "W", "V", "F", 1, 0, nil, "398", nil, nil, nil, 3, nil, 2, 2, 3, 325, 3). + AddRow(101, "b9568e35-3a59-4f91-882f-fa021f591b13", "Where are you from?", true, dummyTime, dummyTime, dummyTime, dummyTime, "O", "W", "V", "F", 1, 0, nil, "399", nil, nil, nil, 3, nil, 2, 2, 3, 325, 3). + AddRow(102, "c864c4e0-9863-4fd3-9f76-bee481b4a138", "I'm from Brazil", false, dummyTime, dummyTime, dummyTime, dummyTime, "I", "P", "V", "F", 1, 0, nil, "400", nil, nil, nil, 3, nil, 2, 2, 3, nil, nil) + + after, err := time.Parse("2006-01-02T15:04:05", "2019-10-07T15:21:30") + assert.NoError(t, err) + + // mock.ExpectQuery("SELECT id, broadcast_id, uuid, text, created_on, direction, status, visibility, msg_count, error_count, next_attempt, external_id, attachments, metadata, channel_id, connection_id, contact_id, contact_urn_id, response_to_id, org_id, topup_id FROM msgs_msg WHERE contact_id = $1 AND created_on >= $2 ORDER BY id ASC"). + mock.ExpectQuery("SELECT"). + WithArgs(1234567, after). + WillReturnRows(rows) + + twilioflex.SetDB(sqlxDB) + svc, err := twilioflex.NewService( rt.Config, http.DefaultClient, @@ -317,7 +394,7 @@ func TestOpenAndForward(t *testing.T) { assert.Equal(t, "General", ticket.Topic().Name()) assert.Equal(t, "Where are my cookies?", ticket.Body()) assert.Equal(t, "CH6442c09c93ba4d13966fa42e9b78f620", ticket.ExternalID()) - assert.Equal(t, 4, len(logger.Logs)) + assert.Equal(t, 7, len(logger.Logs)) test.AssertSnapshot(t, "open_ticket", logger.Logs[0].Request) dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Cathy.ID, testdata.Twilioflex.ID, "CH6442c09c93ba4d13966fa42e9b78f620", testdata.DefaultTopic.ID, "Where are my cookies?", models.NilUserID, map[string]interface{}{ From b7177271a485cbffce7593cc54723581a25b77c8 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Thu, 24 Mar 2022 18:22:05 -0300 Subject: [PATCH 20/24] twilioflex ticketer service_test tweaks --- services/tickets/twilioflex/service_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/tickets/twilioflex/service_test.go b/services/tickets/twilioflex/service_test.go index a45e61b02..2df13db02 100644 --- a/services/tickets/twilioflex/service_test.go +++ b/services/tickets/twilioflex/service_test.go @@ -360,7 +360,6 @@ func TestOpenAndForward(t *testing.T) { after, err := time.Parse("2006-01-02T15:04:05", "2019-10-07T15:21:30") assert.NoError(t, err) - // mock.ExpectQuery("SELECT id, broadcast_id, uuid, text, created_on, direction, status, visibility, msg_count, error_count, next_attempt, external_id, attachments, metadata, channel_id, connection_id, contact_id, contact_urn_id, response_to_id, org_id, topup_id FROM msgs_msg WHERE contact_id = $1 AND created_on >= $2 ORDER BY id ASC"). mock.ExpectQuery("SELECT"). WithArgs(1234567, after). WillReturnRows(rows) @@ -388,7 +387,7 @@ func TestOpenAndForward(t *testing.T) { logger := &flows.HTTPLogger{} ticket, err := svc.Open(session, defaultTopic, "Where are my cookies?", nil, logger.Log) - // assert.EqualError(t, err, "error calling Twilioflex: unable to connect to server") + assert.NoError(t, err) assert.Equal(t, flows.TicketUUID("e7187099-7d38-4f60-955c-325957214c42"), ticket.UUID()) assert.Equal(t, "General", ticket.Topic().Name()) From a4dd956afde11aad0176e786fcb0c1fda34d0f04 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Fri, 25 Mar 2022 14:56:08 -0300 Subject: [PATCH 21/24] fix twilioflex createFlexChannelParams in service --- services/tickets/twilioflex/service.go | 1 - 1 file changed, 1 deletion(-) diff --git a/services/tickets/twilioflex/service.go b/services/tickets/twilioflex/service.go index 52c17e5e0..8b0651409 100644 --- a/services/tickets/twilioflex/service.go +++ b/services/tickets/twilioflex/service.go @@ -115,7 +115,6 @@ func (s *service) Open(session flows.Session, topic *flows.Topic, body string, a Identity: fmt.Sprint(contact.ID()), ChatUserFriendlyName: contact.Name(), ChatFriendlyName: contact.Name(), - TaskAttributes: body, } extra := &struct { From da6bde01cc5c1c50bd647dcfec5bdd9c99bb39a5 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Thu, 31 Mar 2022 15:01:48 -0300 Subject: [PATCH 22/24] fix fetchUrl from twilioflex ticketer client FetchMedia --- services/tickets/twilioflex/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/tickets/twilioflex/client.go b/services/tickets/twilioflex/client.go index 98c470c16..28574af4f 100644 --- a/services/tickets/twilioflex/client.go +++ b/services/tickets/twilioflex/client.go @@ -250,7 +250,7 @@ func (c *Client) CreateMedia(media *CreateMediaParams) (*Media, *httpx.Trace, er // FetchMedia fetch a twilio flex Media by this sid. func (c *Client) FetchMedia(mediaSid string) (*Media, *httpx.Trace, error) { - fetchUrl := fmt.Sprintf("https://mcs.us1.twilio.com/v1/Services/IS38067ec392f1486bb6e4de4610f26fb3/Media/%s", mediaSid) + fetchUrl := fmt.Sprintf("https://mcs.us1.twilio.com/v1/Services/%s/Media/%s", c.serviceSid, mediaSid) response := &Media{} data := url.Values{} trace, err := c.get(fetchUrl, data, response) From a5b23aa7302ff5c1db63c30ba3eaf4631935c168 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Thu, 31 Mar 2022 22:10:19 -0300 Subject: [PATCH 23/24] update twilioflex web_test --- services/tickets/twilioflex/web_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/services/tickets/twilioflex/web_test.go b/services/tickets/twilioflex/web_test.go index 7c5416312..154774a12 100644 --- a/services/tickets/twilioflex/web_test.go +++ b/services/tickets/twilioflex/web_test.go @@ -1,7 +1,6 @@ package twilioflex_test import ( - "log" "testing" "github.com/nyaruka/mailroom/testsuite" @@ -25,6 +24,5 @@ func TestEventCallback(t *testing.T) { nil, ) - log.Println(string(ticket.UUID)) web.RunWebTests(t, ctx, rt, "testdata/event_callback.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)}) } From c40b752007696bff5c7f875cbf5e1cd5aa7f3b30 Mon Sep 17 00:00:00 2001 From: Rafael Soares Date: Mon, 4 Apr 2022 14:45:39 -0300 Subject: [PATCH 24/24] test select contact messages --- core/models/msgs_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/core/models/msgs_test.go b/core/models/msgs_test.go index c58728589..f6b2eeafd 100644 --- a/core/models/msgs_test.go +++ b/core/models/msgs_test.go @@ -358,3 +358,19 @@ func TestNewOutgoingIVR(t *testing.T) { testsuite.AssertQuery(t, db, `SELECT text, created_on, sent_on FROM msgs_msg WHERE uuid = $1`, dbMsg.UUID()).Columns(map[string]interface{}{"text": "Hello", "created_on": createdOn, "sent_on": createdOn}) } + +func TestSelectContactMessages(t *testing.T) { + ctx, _, db, _ := testsuite.Get() + + now := time.Now() + msgIn := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "in 1", models.MsgStatusHandled) + msgOut := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "out 1", []utils.Attachment{"image/jpeg:hi.jpg"}, models.MsgStatusSent) + + msgs, err := models.SelectContactMessages(ctx, db, int(testdata.Cathy.ID), now) + + assert.NoError(t, err) + assert.Equal(t, 2, len(msgs)) + assert.Equal(t, msgIn.Text(), msgs[0].Text()) + assert.Equal(t, msgOut.Text(), msgs[1].Text()) + assert.Equal(t, []utils.Attachment{"image/jpeg:hi.jpg"}, msgs[1].Attachments()) +}