Browse Source

feat: support send image from url

pull/237/head
Aldino Kemal 1 year ago
parent
commit
47043b78b1
  1. 2
      src/domains/send/audio.go
  2. 1
      src/domains/send/image.go
  3. 5
      src/internal/rest/send.go
  4. 32
      src/pkg/utils/general.go
  5. 85
      src/pkg/utils/general_test.go
  6. 23
      src/services/send.go
  7. 33
      src/validations/send_validation.go
  8. 187
      src/validations/send_validation_test.go
  9. 12
      src/views/components/SendImage.js

2
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"`
}

1
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"`
}

5
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
}
whatsapp.SanitizePhone(&request.Phone)
response, err := controller.Service.SendImage(c.UserContext(), request)

32
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
}

85
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(`<!DOCTYPE html><html><head><title>Test Title</title><meta name='description' content='Test Description'><meta property='og:image' content='http://example.com/image.jpg'></head><body></body></html>`))
})
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))
}

23
src/services/send.go

@ -119,15 +119,32 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima
var (
imagePath string
imageThumbnail string
imageName string
deletedItems []string
oriImagePath string
)
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)
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)
/* Generate thumbnail with smalled image size */
@ -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))
}

33
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,22 +28,35 @@ 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())
}
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 request.ImageURL != nil {
if *request.ImageURL == "" {
return pkgError.ValidationError("ImageURL cannot be empty")
}
}
return nil
}
@ -154,10 +168,8 @@ func ValidateSendAudio(ctx context.Context, request domainSend.AudioRequest) err
"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 {

187
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)
})
}
}

12
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 {
<label>Check for compressing image to smaller size</label>
</div>
</div>
<div class="field">
<label>Image URL</label>
<input type="text" v-model="image_url" placeholder="https://example.com/image.jpg"
aria-label="image_url"/>
</div>
<div style="text-align: left; font-weight: bold; margin: 10px 0;">or you can upload image from your device</div>
<div class="field" style="padding-bottom: 30px">
<label>Image</label>
<input type="file" style="display: none" id="file_image" accept="image/png,image/jpg,image/jpeg" @change="handleImageChange"/>

Loading…
Cancel
Save