diff --git a/src/domains/send/audio.go b/src/domains/send/audio.go index f2e5063..16da723 100644 --- a/src/domains/send/audio.go +++ b/src/domains/send/audio.go @@ -4,5 +4,5 @@ import "mime/multipart" type AudioRequest struct { Phone string `json:"phone" form:"phone"` - Audio *multipart.FileHeader `json:"Audio" form:"Audio"` + Audio *multipart.FileHeader `json:"audio" form:"audio"` } diff --git a/src/domains/send/image.go b/src/domains/send/image.go index 783b030..b6baf95 100644 --- a/src/domains/send/image.go +++ b/src/domains/send/image.go @@ -6,6 +6,7 @@ type ImageRequest struct { Phone string `json:"phone" form:"phone"` Caption string `json:"caption" form:"caption"` Image *multipart.FileHeader `json:"image" form:"image"` + ImageURL *string `json:"image_url" form:"image_url"` ViewOnce bool `json:"view_once" form:"view_once"` Compress bool `json:"compress"` } diff --git a/src/internal/rest/send.go b/src/internal/rest/send.go index 637c32b..e30e9e4 100644 --- a/src/internal/rest/send.go +++ b/src/internal/rest/send.go @@ -52,9 +52,10 @@ func (controller *Send) SendImage(c *fiber.Ctx) error { utils.PanicIfNeeded(err) file, err := c.FormFile("image") - utils.PanicIfNeeded(err) + if err == nil { + request.Image = file + } - request.Image = file whatsapp.SanitizePhone(&request.Phone) response, err := controller.Service.SendImage(c.UserContext(), request) diff --git a/src/pkg/utils/general.go b/src/pkg/utils/general.go index 71685f6..aa64d55 100644 --- a/src/pkg/utils/general.go +++ b/src/pkg/utils/general.go @@ -131,3 +131,35 @@ func ContainsMention(message string) []string { } return phoneNumbers } + +func DownloadImageFromURL(url string) ([]byte, string, error) { + response, err := http.Get(url) + if err != nil { + return nil, "", err + } + defer response.Body.Close() + + // Extract the file name from the URL and remove query parameters if present + segments := strings.Split(url, "/") + fileName := segments[len(segments)-1] + fileName = strings.Split(fileName, "?")[0] + + // Check if the file extension is supported + allowedExtensions := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".webp": true, + } + extension := strings.ToLower(filepath.Ext(fileName)) + if !allowedExtensions[extension] { + return nil, "", fmt.Errorf("unsupported file type: %s", extension) + } + + imageData, err := io.ReadAll(response.Body) + if err != nil { + return nil, "", err + } + + return imageData, fileName, nil +} diff --git a/src/pkg/utils/general_test.go b/src/pkg/utils/general_test.go index 6c80a29..9eb5b1a 100644 --- a/src/pkg/utils/general_test.go +++ b/src/pkg/utils/general_test.go @@ -1,12 +1,21 @@ package utils_test import ( + "net/http" + "os" + "testing" + "time" + "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils" "github.com/stretchr/testify/assert" - "testing" + "github.com/stretchr/testify/suite" ) -func TestContainsMention(t *testing.T) { +type UtilsTestSuite struct { + suite.Suite +} + +func (suite *UtilsTestSuite) TestContainsMention() { type args struct { message string } @@ -32,9 +41,79 @@ func TestContainsMention(t *testing.T) { }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + suite.T().Run(tt.name, func(t *testing.T) { got := utils.ContainsMention(tt.args.message) assert.Equal(t, tt.want, got) }) } } + +func (suite *UtilsTestSuite) TestRemoveFile() { + tempFile, err := os.CreateTemp("", "testfile") + assert.NoError(suite.T(), err) + tempFilePath := tempFile.Name() + tempFile.Close() + + err = utils.RemoveFile(0, tempFilePath) + assert.NoError(suite.T(), err) + _, err = os.Stat(tempFilePath) + assert.True(suite.T(), os.IsNotExist(err)) +} + +func (suite *UtilsTestSuite) TestCreateFolder() { + tempDir := "testdir" + err := utils.CreateFolder(tempDir) + assert.NoError(suite.T(), err) + _, err = os.Stat(tempDir) + assert.NoError(suite.T(), err) + assert.True(suite.T(), err == nil) + os.RemoveAll(tempDir) +} + +func (suite *UtilsTestSuite) TestPanicIfNeeded() { + assert.PanicsWithValue(suite.T(), "test error", func() { + utils.PanicIfNeeded("test error") + }) + + assert.NotPanics(suite.T(), func() { + utils.PanicIfNeeded(nil) + }) +} + +func (suite *UtilsTestSuite) TestStrToFloat64() { + assert.Equal(suite.T(), 123.45, utils.StrToFloat64("123.45")) + assert.Equal(suite.T(), 0.0, utils.StrToFloat64("invalid")) + assert.Equal(suite.T(), 0.0, utils.StrToFloat64("")) +} + +func (suite *UtilsTestSuite) TestGetMetaDataFromURL() { + // Mock HTTP server + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`Test Title`)) + }) + go http.ListenAndServe(":8080", nil) + time.Sleep(1 * time.Second) // Allow server to start + + meta := utils.GetMetaDataFromURL("http://localhost:8080") + assert.Equal(suite.T(), "Test Title", meta.Title) + assert.Equal(suite.T(), "Test Description", meta.Description) + assert.Equal(suite.T(), "http://example.com/image.jpg", meta.Image) +} + +func (suite *UtilsTestSuite) TestDownloadImageFromURL() { + // Mock HTTP server + http.HandleFunc("/image.jpg", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("image data")) + }) + go http.ListenAndServe(":8081", nil) + time.Sleep(1 * time.Second) // Allow server to start + + imageData, fileName, err := utils.DownloadImageFromURL("http://localhost:8081/image.jpg") + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), []byte("image data"), imageData) + assert.Equal(suite.T(), "image.jpg", fileName) +} + +func TestUtilsTestSuite(t *testing.T) { + suite.Run(t, new(UtilsTestSuite)) +} diff --git a/src/services/send.go b/src/services/send.go index aa8f9a4..0e82961 100644 --- a/src/services/send.go +++ b/src/services/send.go @@ -119,14 +119,31 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima var ( imagePath string imageThumbnail string + imageName string deletedItems []string + oriImagePath string ) - // Save image to server - oriImagePath := fmt.Sprintf("%s/%s", config.PathSendItems, request.Image.Filename) - err = fasthttp.SaveMultipartFile(request.Image, oriImagePath) - if err != nil { - return response, err + if request.ImageURL != nil && *request.ImageURL != "" { + // Download image from URL + imageData, fileName, err := utils.DownloadImageFromURL(*request.ImageURL) + oriImagePath = fmt.Sprintf("%s/%s", config.PathSendItems, fileName) + if err != nil { + return response, pkgError.InternalServerError(fmt.Sprintf("failed to download image from URL %v", err)) + } + imageName = fileName + err = os.WriteFile(oriImagePath, imageData, 0644) + if err != nil { + return response, pkgError.InternalServerError(fmt.Sprintf("failed to save downloaded image %v", err)) + } + } else if request.Image != nil { + // Save image to server + oriImagePath = fmt.Sprintf("%s/%s", config.PathSendItems, request.Image.Filename) + err = fasthttp.SaveMultipartFile(request.Image, oriImagePath) + if err != nil { + return response, err + } + imageName = request.Image.Filename } deletedItems = append(deletedItems, oriImagePath) @@ -138,7 +155,7 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima // Resize Thumbnail resizedImage := imaging.Resize(srcImage, 100, 0, imaging.Lanczos) - imageThumbnail = fmt.Sprintf("%s/thumbnails-%s", config.PathSendItems, request.Image.Filename) + imageThumbnail = fmt.Sprintf("%s/thumbnails-%s", config.PathSendItems, imageName) if err = imaging.Save(resizedImage, imageThumbnail); err != nil { return response, pkgError.InternalServerError(fmt.Sprintf("failed to save thumbnail %v", err)) } @@ -151,7 +168,7 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima return response, pkgError.InternalServerError(fmt.Sprintf("failed to open image %v", err)) } newImage := imaging.Resize(openImageBuffer, 600, 0, imaging.Lanczos) - newImagePath := fmt.Sprintf("%s/new-%s", config.PathSendItems, request.Image.Filename) + newImagePath := fmt.Sprintf("%s/new-%s", config.PathSendItems, imageName) if err = imaging.Save(newImage, newImagePath); err != nil { return response, pkgError.InternalServerError(fmt.Sprintf("failed to save image %v", err)) } diff --git a/src/validations/send_validation.go b/src/validations/send_validation.go index ee67368..db535a8 100644 --- a/src/validations/send_validation.go +++ b/src/validations/send_validation.go @@ -3,6 +3,7 @@ package validations import ( "context" "fmt" + "sort" "github.com/aldinokemal/go-whatsapp-web-multidevice/config" domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send" @@ -27,21 +28,34 @@ func ValidateSendMessage(ctx context.Context, request domainSend.MessageRequest) func ValidateSendImage(ctx context.Context, request domainSend.ImageRequest) error { err := validation.ValidateStructWithContext(ctx, &request, validation.Field(&request.Phone, validation.Required), - validation.Field(&request.Image, validation.Required), ) if err != nil { return pkgError.ValidationError(err.Error()) } - availableMimes := map[string]bool{ - "image/jpeg": true, - "image/jpg": true, - "image/png": true, + if request.Image == nil && (request.ImageURL == nil || *request.ImageURL == "") { + return pkgError.ValidationError("either Image or ImageURL must be provided") + } + + if request.Image != nil { + availableMimes := map[string]bool{ + "image/jpeg": true, + "image/jpg": true, + "image/png": true, + } + + fmt.Println(request.Image.Header.Get("Content-Type")) + + if !availableMimes[request.Image.Header.Get("Content-Type")] { + return pkgError.ValidationError("your image is not allowed. please use jpg/jpeg/png") + } } - if !availableMimes[request.Image.Header.Get("Content-Type")] { - return pkgError.ValidationError("your image is not allowed. please use jpg/jpeg/png") + if request.ImageURL != nil { + if *request.ImageURL == "" { + return pkgError.ValidationError("ImageURL cannot be empty") + } } return nil @@ -146,18 +160,16 @@ func ValidateSendAudio(ctx context.Context, request domainSend.AudioRequest) err } availableMimes := map[string]bool{ - "audio/aac": true, - "audio/amr": true, - "audio/flac": true, - "audio/m4a": true, - "audio/m4r": true, - "audio/mp3": true, - "audio/mpeg": true, - "audio/ogg": true, - + "audio/aac": true, + "audio/amr": true, + "audio/flac": true, + "audio/m4a": true, + "audio/m4r": true, + "audio/mp3": true, + "audio/mpeg": true, + "audio/ogg": true, "audio/wma": true, "audio/x-ms-wma": true, - "audio/wav": true, "audio/vnd.wav": true, "audio/vnd.wave": true, @@ -166,7 +178,15 @@ func ValidateSendAudio(ctx context.Context, request domainSend.AudioRequest) err "audio/x-wav": true, } availableMimesStr := "" + + // Sort MIME types for consistent error message order + mimeKeys := make([]string, 0, len(availableMimes)) for k := range availableMimes { + mimeKeys = append(mimeKeys, k) + } + sort.Strings(mimeKeys) + + for _, k := range mimeKeys { availableMimesStr += k + "," } @@ -178,11 +198,15 @@ func ValidateSendAudio(ctx context.Context, request domainSend.AudioRequest) err } func ValidateSendPoll(ctx context.Context, request domainSend.PollRequest) error { + // Validate options first to ensure it is not blank before validating MaxAnswer + if len(request.Options) == 0 { + return pkgError.ValidationError("options: cannot be blank.") + } + err := validation.ValidateStructWithContext(ctx, &request, validation.Field(&request.Phone, validation.Required), validation.Field(&request.Question, validation.Required), - validation.Field(&request.Options, validation.Required), validation.Field(&request.Options, validation.Each(validation.Required)), validation.Field(&request.MaxAnswer, validation.Required), @@ -204,7 +228,6 @@ func ValidateSendPoll(ctx context.Context, request domainSend.PollRequest) error } return nil - } func ValidateSendPresence(ctx context.Context, request domainSend.PresenceRequest) error { diff --git a/src/validations/send_validation_test.go b/src/validations/send_validation_test.go index a9810cd..cccf463 100644 --- a/src/validations/send_validation_test.go +++ b/src/validations/send_validation_test.go @@ -2,12 +2,13 @@ package validations import ( "context" + "mime/multipart" + "testing" + domainMessage "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/message" domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send" pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" "github.com/stretchr/testify/assert" - "mime/multipart" - "testing" ) func TestValidateSendMessage(t *testing.T) { @@ -91,7 +92,7 @@ func TestValidateSendImage(t *testing.T) { Phone: "1728937129312@s.whatsapp.net", Image: nil, }}, - err: pkgError.ValidationError("image: cannot be blank."), + err: pkgError.ValidationError("either Image or ImageURL must be provided"), }, { name: "should error with invalid image type", @@ -528,3 +529,183 @@ func TestValidateSendLocation(t *testing.T) { }) } } + +func TestValidateSendAudio(t *testing.T) { + audio := &multipart.FileHeader{ + Filename: "sample-audio.mp3", + Size: 100, + Header: map[string][]string{"Content-Type": {"audio/mp3"}}, + } + + type args struct { + request domainSend.AudioRequest + } + tests := []struct { + name string + args args + err any + }{ + { + name: "should success with normal condition", + args: args{request: domainSend.AudioRequest{ + Phone: "1728937129312@s.whatsapp.net", + Audio: audio, + }}, + err: nil, + }, + { + name: "should error with empty phone", + args: args{request: domainSend.AudioRequest{ + Phone: "", + Audio: audio, + }}, + err: pkgError.ValidationError("phone: cannot be blank."), + }, + { + name: "should error with empty audio", + args: args{request: domainSend.AudioRequest{ + Phone: "1728937129312@s.whatsapp.net", + Audio: nil, + }}, + err: pkgError.ValidationError("audio: cannot be blank."), + }, + { + name: "should error with invalid audio type", + args: args{request: domainSend.AudioRequest{ + Phone: "1728937129312@s.whatsapp.net", + Audio: &multipart.FileHeader{ + Filename: "sample-audio.txt", + Size: 100, + Header: map[string][]string{"Content-Type": {"text/plain"}}, + }, + }}, + err: pkgError.ValidationError("your audio type is not allowed. please use (audio/aac,audio/amr,audio/flac,audio/m4a,audio/m4r,audio/mp3,audio/mpeg,audio/ogg,audio/vnd.wav,audio/vnd.wave,audio/wav,audio/wave,audio/wma,audio/x-ms-wma,audio/x-pn-wav,audio/x-wav,)"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSendAudio(context.Background(), tt.args.request) + assert.Equal(t, tt.err, err) + }) + } +} + +func TestValidateSendPoll(t *testing.T) { + type args struct { + request domainSend.PollRequest + } + tests := []struct { + name string + args args + err any + }{ + { + name: "should success with normal condition", + args: args{request: domainSend.PollRequest{ + Phone: "1728937129312@s.whatsapp.net", + Question: "What is your favorite color?", + Options: []string{"Red", "Blue", "Green"}, + MaxAnswer: 1, + }}, + err: nil, + }, + { + name: "should error with empty phone", + args: args{request: domainSend.PollRequest{ + Phone: "", + Question: "What is your favorite color?", + Options: []string{"Red", "Blue", "Green"}, + MaxAnswer: 1, + }}, + err: pkgError.ValidationError("phone: cannot be blank."), + }, + { + name: "should error with empty question", + args: args{request: domainSend.PollRequest{ + Phone: "1728937129312@s.whatsapp.net", + Question: "", + Options: []string{"Red", "Blue", "Green"}, + MaxAnswer: 1, + }}, + err: pkgError.ValidationError("question: cannot be blank."), + }, + { + name: "should error with empty options", + args: args{request: domainSend.PollRequest{ + Phone: "1728937129312@s.whatsapp.net", + Question: "What is your favorite color?", + Options: []string{}, + MaxAnswer: 5, + }}, + err: pkgError.ValidationError("options: cannot be blank."), + }, + { + name: "should error with duplicate options", + args: args{request: domainSend.PollRequest{ + Phone: "1728937129312@s.whatsapp.net", + Question: "What is your favorite color?", + Options: []string{"Red", "Red", "Green"}, + MaxAnswer: 1, + }}, + err: pkgError.ValidationError("options should be unique"), + }, + { + name: "should error with max answer greater than options", + args: args{request: domainSend.PollRequest{ + Phone: "1728937129312@s.whatsapp.net", + Question: "What is your favorite color?", + Options: []string{"Red", "Blue", "Green"}, + MaxAnswer: 5, + }}, + err: pkgError.ValidationError("max_answer: must be no greater than 3."), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSendPoll(context.Background(), tt.args.request) + assert.Equal(t, tt.err, err) + }) + } +} + +func TestValidateSendPresence(t *testing.T) { + type args struct { + request domainSend.PresenceRequest + } + tests := []struct { + name string + args args + err any + }{ + { + name: "should success with available type", + args: args{request: domainSend.PresenceRequest{ + Type: "available", + }}, + err: nil, + }, + { + name: "should success with unavailable type", + args: args{request: domainSend.PresenceRequest{ + Type: "unavailable", + }}, + err: nil, + }, + { + name: "should error with invalid type", + args: args{request: domainSend.PresenceRequest{ + Type: "invalid", + }}, + err: pkgError.ValidationError("type: must be a valid value."), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSendPresence(context.Background(), tt.args.request) + assert.Equal(t, tt.err, err) + }) + } +} diff --git a/src/views/components/SendImage.js b/src/views/components/SendImage.js index 35682f2..4fbe6d5 100644 --- a/src/views/components/SendImage.js +++ b/src/views/components/SendImage.js @@ -14,6 +14,7 @@ export default { type: window.TYPEUSER, loading: false, selected_file: null, + image_url: null, preview_url: null } }, @@ -38,7 +39,7 @@ export default { return false; } - if (!this.selected_file) { + if (!this.selected_file && !this.image_url) { return false; } @@ -66,7 +67,7 @@ export default { payload.append("compress", this.compress) payload.append("caption", this.caption) payload.append('image', $("#file_image")[0].files[0]) - + payload.append('image_url', this.image_url) let response = await window.http.post(`/send/image`, payload) this.handleReset(); return response.data.message; @@ -86,6 +87,7 @@ export default { this.caption = ''; this.preview_url = null; this.selected_file = null; + this.image_url = null; $("#file_image").val(''); }, handleImageChange(event) { @@ -145,6 +147,12 @@ export default { +
+ + +
+
or you can upload image from your device