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