diff --git a/.gitignore b/.gitignore index 036beb944..494792c01 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ docs/* docs .DS_Store +_storage/ # Test binary, build with `go test -c` *.test diff --git a/services/tickets/mailgun/web.go b/services/tickets/mailgun/web.go index 4bf64b36d..858fe8aed 100644 --- a/services/tickets/mailgun/web.go +++ b/services/tickets/mailgun/web.go @@ -112,7 +112,7 @@ func handleReceive(ctx context.Context, s *web.Server, r *http.Request, l *model return errors.Wrapf(err, "error updating ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil } - msg, err := tickets.SendReply(ctx, s.DB, s.RP, ticket, request.StrippedText) + msg, err := tickets.SendReply(ctx, s.DB, s.RP, s.Storage, s.Config.S3MediaPrefix, ticket, request.StrippedText, nil) if err != nil { return err, http.StatusInternalServerError, nil } diff --git a/services/tickets/utils.go b/services/tickets/utils.go index 42d0f44d6..2a2f1d97d 100644 --- a/services/tickets/utils.go +++ b/services/tickets/utils.go @@ -2,12 +2,19 @@ package tickets import ( "context" + "net/http" + "path/filepath" + "time" + "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/goflow/assets" "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/goflow/utils/uuids" "github.com/nyaruka/mailroom/courier" "github.com/nyaruka/mailroom/models" + "github.com/nyaruka/mailroom/utils/storage" "github.com/gomodule/redigo/redis" "github.com/jmoiron/sqlx" @@ -69,22 +76,37 @@ func FromTicketerUUID(ctx context.Context, db *sqlx.DB, uuid assets.TicketerUUID } // SendReply sends a message reply from the ticket system user to the contact -func SendReply(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ticket *models.Ticket, text string) (*models.Msg, error) { +func SendReply(ctx context.Context, db *sqlx.DB, rp *redis.Pool, store storage.Storage, mediaPrefix string, ticket *models.Ticket, text string, fileURLs []string) (*models.Msg, error) { // look up our assets - assets, err := models.GetOrgAssets(ctx, db, ticket.OrgID()) + oa, err := models.GetOrgAssets(ctx, db, ticket.OrgID()) if err != nil { return nil, errors.Wrapf(err, "error looking up org #%d", ticket.OrgID()) } - // build a simple translation - translations := map[envs.Language]*models.BroadcastTranslation{ - envs.Language("base"): {Text: text}, + // fetch and files and prepare as attachments + attachments := make([]utils.Attachment, len(fileURLs)) + for i, fileURL := range fileURLs { + fileBody, err := fetchFile(fileURL) + if err != nil { + return nil, errors.Wrapf(err, "error fetching file %s for ticket reply", fileURL) + } + + filename := string(uuids.New()) + filepath.Ext(fileURL) + + attachments[i], err = oa.Org().StoreAttachment(store, mediaPrefix, filename, fileBody) + if err != nil { + return nil, errors.Wrapf(err, "error storing attachment %s for ticket reply", fileURL) + } } + // build a simple translation + base := &models.BroadcastTranslation{Text: text, Attachments: attachments} + translations := map[envs.Language]*models.BroadcastTranslation{envs.Language("base"): base} + // we'll use a broadcast to send this message - bcast := models.NewBroadcast(assets.OrgID(), models.NilBroadcastID, translations, models.TemplateStateEvaluated, envs.Language("base"), nil, nil, nil) + bcast := models.NewBroadcast(oa.OrgID(), models.NilBroadcastID, translations, models.TemplateStateEvaluated, envs.Language("base"), nil, nil, nil) batch := bcast.CreateBatch([]models.ContactID{ticket.ContactID()}) - msgs, err := models.CreateBroadcastMessages(ctx, db, rp, assets, batch) + msgs, err := models.CreateBroadcastMessages(ctx, db, rp, oa, batch) if err != nil { return nil, errors.Wrapf(err, "error creating message batch") } @@ -101,3 +123,17 @@ func SendReply(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ticket *models. } return msg, nil } + +func fetchFile(url string) ([]byte, error) { + req, _ := httpx.NewRequest("GET", url, nil, nil) + + trace, err := httpx.DoTrace(http.DefaultClient, req, httpx.NewFixedRetries(time.Second*5, time.Second*10), nil, 10*1024*1024) + if err != nil { + return nil, err + } + if trace.Response.StatusCode/100 != 2 { + return nil, errors.New("fetch returned non-200 response") + } + + return trace.ResponseBody, nil +} diff --git a/services/tickets/utils_test.go b/services/tickets/utils_test.go index 922aa92df..9d2cc8047 100644 --- a/services/tickets/utils_test.go +++ b/services/tickets/utils_test.go @@ -1,10 +1,14 @@ package tickets_test import ( + "io/ioutil" "testing" + "github.com/nyaruka/gocommon/httpx" "github.com/nyaruka/goflow/envs" "github.com/nyaruka/goflow/flows" + "github.com/nyaruka/goflow/utils" + "github.com/nyaruka/goflow/utils/uuids" "github.com/nyaruka/mailroom/models" "github.com/nyaruka/mailroom/services/tickets" _ "github.com/nyaruka/mailroom/services/tickets/mailgun" @@ -118,6 +122,20 @@ func TestSendReply(t *testing.T) { testsuite.ResetDB() ctx := testsuite.CTX() db := testsuite.DB() + rp := testsuite.RP() + defer testsuite.ResetStorage() + + defer uuids.SetGenerator(uuids.DefaultGenerator) + uuids.SetGenerator(uuids.NewSeededGenerator(12345)) + + image, err := ioutil.ReadFile("../../models/testdata/test.jpg") + require.NoError(t, err) + + defer httpx.SetRequestor(httpx.DefaultRequestor) + httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{ + "http://coolfilesfortickets.com/a.jpg": {httpx.MockResponse{Status: 200, Body: image}}, + "http://badfiles.com/b.jpg": {httpx.MockResponse{Status: 400, Body: nil}}, + })) ticketUUID := flows.TicketUUID("f7358870-c3dd-450d-b5ae-db2eb50216ba") @@ -128,9 +146,15 @@ func TestSendReply(t *testing.T) { ticket, err := models.LookupTicketByUUID(ctx, db, ticketUUID) require.NoError(t, err) - msg, err := tickets.SendReply(ctx, db, testsuite.RP(), ticket, "I'll get back to you") + msg, err := tickets.SendReply(ctx, db, rp, testsuite.Storage(), "media", ticket, "I'll get back to you", []string{"http://coolfilesfortickets.com/a.jpg"}) require.NoError(t, err) assert.Equal(t, "I'll get back to you", msg.Text()) assert.Equal(t, models.CathyID, msg.ContactID()) + assert.Equal(t, []utils.Attachment{"image/jpeg:https:///_test_storage/media/1/1ae9/6956/1ae96956-4b34-433e-8d1a-f05fe6923d6d.jpg"}, msg.Attachments()) + assert.FileExists(t, "_test_storage/media/1/1ae9/6956/1ae96956-4b34-433e-8d1a-f05fe6923d6d.jpg") + + // try with file that can't be fetched + _, err = tickets.SendReply(ctx, db, rp, testsuite.Storage(), "media", ticket, "I'll get back to you", []string{"http://badfiles.com/b.jpg"}) + assert.EqualError(t, err, "error fetching file http://badfiles.com/b.jpg for ticket reply: fetch returned non-200 response") } diff --git a/services/tickets/zendesk/client.go b/services/tickets/zendesk/client.go index 905576cb3..04ca35a14 100644 --- a/services/tickets/zendesk/client.go +++ b/services/tickets/zendesk/client.go @@ -2,7 +2,6 @@ package zendesk import ( "bytes" - "encoding/json" "errors" "fmt" "io" @@ -224,13 +223,19 @@ func NewPushClient(httpClient *http.Client, httpRetries *httpx.RetryConfig, subd return &PushClient{baseClient: newBaseClient(httpClient, httpRetries, subdomain, token)} } +// FieldValue is a value for the named field +type FieldValue struct { + ID string `json:"id"` + Value string `json:"value"` +} + // Author see https://developer.zendesk.com/rest_api/docs/support/channel_framework#author-object type Author struct { - ExternalID string `json:"external_id"` - Name string `json:"name,omitempty"` - ImageURL string `json:"image_url,omitempty"` - Locale string `json:"locale,omitempty"` - Fields json.RawMessage `json:"fields,omitempty"` + ExternalID string `json:"external_id"` + Name string `json:"name,omitempty"` + ImageURL string `json:"image_url,omitempty"` + Locale string `json:"locale,omitempty"` + Fields []FieldValue `json:"fields,omitempty"` } // DisplayInfo see https://developer.zendesk.com/rest_api/docs/support/channel_framework#display_info-object @@ -250,6 +255,8 @@ type ExternalResource struct { Author Author `json:"author"` DisplayInfo []DisplayInfo `json:"display_info,omitempty"` AllowChannelback bool `json:"allow_channelback"` + Fields []FieldValue `json:"fields,omitempty"` + FileURLs []string `json:"file_urls,omitempty"` } // Status see https://developer.zendesk.com/rest_api/docs/support/channel_framework#status-object diff --git a/services/tickets/zendesk/web.go b/services/tickets/zendesk/web.go index 4986a28e9..bc7b60b71 100644 --- a/services/tickets/zendesk/web.go +++ b/services/tickets/zendesk/web.go @@ -76,7 +76,7 @@ func handleChannelback(ctx context.Context, s *web.Server, r *http.Request) (int return errors.Wrapf(err, "error updating ticket: %s", ticket.UUID()), http.StatusBadRequest, nil } - msg, err := tickets.SendReply(ctx, s.DB, s.RP, ticket, request.Message) + msg, err := tickets.SendReply(ctx, s.DB, s.RP, s.Storage, s.Config.S3MediaPrefix, ticket, request.Message, request.FileURLs) if err != nil { return err, http.StatusBadRequest, nil } diff --git a/utils/storage/fs.go b/utils/storage/fs.go index 1a1c7e6cb..d6f492bda 100644 --- a/utils/storage/fs.go +++ b/utils/storage/fs.go @@ -21,6 +21,12 @@ func (s *fsStorage) Name() string { } func (s *fsStorage) Test() error { + path, err := s.Put("test.txt", "text/plain", []byte(`test`)) + if err != nil { + return err + } + + os.Remove(path) return nil } diff --git a/utils/storage/fs_test.go b/utils/storage/fs_test.go index c0d138787..af761f681 100644 --- a/utils/storage/fs_test.go +++ b/utils/storage/fs_test.go @@ -15,6 +15,13 @@ func TestFS(t *testing.T) { s := storage.NewFS("_testing") assert.NoError(t, s.Test()) + // break our ability to write to that directory + require.NoError(t, os.Chmod("_testing", 0555)) + + assert.EqualError(t, s.Test(), "open _testing/test.txt: permission denied") + + require.NoError(t, os.Chmod("_testing", 0777)) + url, err := s.Put("/foo/bar.txt", "text/plain", []byte(`hello world`)) assert.NoError(t, err) assert.Equal(t, "_testing/foo/bar.txt", url)