package utils import ( "bytes" "fmt" "image" "image/color" stddraw "image/draw" _ "image/gif" // Register GIF format "image/jpeg" _ "image/png" // For PNG encoding "io" "math" "math/rand" "net/http" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/PuerkitoBio/goquery" "github.com/aldinokemal/go-whatsapp-web-multidevice/config" "github.com/disintegration/imaging" "github.com/sirupsen/logrus" _ "golang.org/x/image/webp" // Register WebP format ) // RemoveFile is removing file with delay func RemoveFile(delaySecond int, paths ...string) error { if delaySecond > 0 { time.Sleep(time.Duration(delaySecond) * time.Second) } for _, path := range paths { if path != "" { err := os.Remove(path) if err != nil { return err } } } return nil } // CreateFolder create new folder and sub folder if not exist func CreateFolder(folderPath ...string) error { for _, folder := range folderPath { newFolder := filepath.Join(".", folder) err := os.MkdirAll(newFolder, os.ModePerm) if err != nil { return err } } return nil } // PanicIfNeeded is panic if error is not nil func PanicIfNeeded(err any, message ...string) { if err != nil { if fmt.Sprintf("%s", err) == "record not found" && len(message) > 0 { panic(message[0]) } else { panic(err) } } } func StrToFloat64(text string) float64 { var result float64 if text != "" { result, _ = strconv.ParseFloat(strings.TrimSpace(text), 64) } return result } type Metadata struct { Title string Description string Image string ImageThumb []byte JPEGThumb []byte Height *uint32 Width *uint32 } const ( linkPreviewMaxImageDimension = 1024 linkPreviewMaxJPEGDimension = 400 linkPreviewImageQuality = 85 linkPreviewJPEGQuality = 80 ) func resizeWithinBounds(src image.Image, maxDimension int) image.Image { bounds := src.Bounds() width := bounds.Dx() height := bounds.Dy() if width <= maxDimension && height <= maxDimension { return src } if width >= height { return imaging.Resize(src, maxDimension, 0, imaging.Lanczos) } return imaging.Resize(src, 0, maxDimension, imaging.Lanczos) } func encodeJPEGWithBackground(src image.Image, quality int) ([]byte, error) { bounds := src.Bounds() canvas := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy())) stddraw.Draw(canvas, canvas.Bounds(), &image.Uniform{C: color.White}, image.Point{}, stddraw.Src) stddraw.Draw(canvas, canvas.Bounds(), src, bounds.Min, stddraw.Over) var buffer bytes.Buffer if err := jpeg.Encode(&buffer, canvas, &jpeg.Options{Quality: quality}); err != nil { return nil, err } return buffer.Bytes(), nil } func buildLinkPreviewThumbnails(src image.Image) (imageThumb []byte, jpegThumb []byte, width uint32, height uint32, err error) { preview := resizeWithinBounds(src, linkPreviewMaxImageDimension) previewBounds := preview.Bounds() width = uint32(previewBounds.Dx()) height = uint32(previewBounds.Dy()) imageThumb, err = encodeJPEGWithBackground(preview, linkPreviewImageQuality) if err != nil { return nil, nil, 0, 0, err } inlinePreview := resizeWithinBounds(src, linkPreviewMaxJPEGDimension) jpegThumb, err = encodeJPEGWithBackground(inlinePreview, linkPreviewJPEGQuality) if err != nil { return nil, nil, 0, 0, err } return imageThumb, jpegThumb, width, height, nil } // newBrowserRequest creates an HTTP request with browser-like headers // to avoid being blocked by anti-scraping protections during metadata extraction. func newBrowserRequest(method, reqURL string) (*http.Request, error) { req, err := http.NewRequest(method, reqURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") req.Header.Set("Accept-Language", "en-US,en;q=0.9") req.Header.Set("Referer", reqURL) req.Header.Set("Connection", "keep-alive") return req, nil } // isRetryableStatus returns true for HTTP status codes where a retry may succeed. func isRetryableStatus(statusCode int) bool { switch statusCode { case http.StatusForbidden, http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: return true default: return false } } func GetMetaDataFromURL(urlStr string) (meta Metadata, err error) { // Create HTTP client with timeout client := &http.Client{ Timeout: 15 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") } return nil }, } // Parse the base URL for resolving relative URLs later baseURL, err := url.Parse(urlStr) if err != nil { return meta, fmt.Errorf("invalid URL: %v", err) } // Send an HTTP GET request with browser-like headers and retry on transient blocks const maxRetries = 3 var response *http.Response for attempt := range maxRetries { req, err := newBrowserRequest(http.MethodGet, urlStr) if err != nil { return meta, err } response, err = client.Do(req) if err != nil { return meta, err } if !isRetryableStatus(response.StatusCode) || attempt == maxRetries-1 { break } response.Body.Close() // Exponential backoff with jitter: 500ms, 1s, 2s base + random jitter backoff := time.Duration(1< maxImageSize { logrus.Warnf("Downloaded image exceeds max size: %d > %d", len(imageData), maxImageSize) } else { // Validate image by decoding it imageReader := bytes.NewReader(imageData) img, _, err := image.Decode(imageReader) if err != nil { logrus.Warnf("Failed to decode image: %v", err) } else { imageThumb, jpegThumb, width, height, prepErr := buildLinkPreviewThumbnails(img) if prepErr != nil { logrus.Warnf("Failed to prepare link preview thumbnail: %v", prepErr) } else { meta.ImageThumb = imageThumb meta.JPEGThumb = jpegThumb meta.Width = &width meta.Height = &height logrus.Debugf("Image dimensions: %dx%d", width, height) } } } } } } } } return meta, nil } // ContainsMention is checking if message contains mention, then return only mention without @ func ContainsMention(message string) []string { // Regular expression to find all phone numbers after the @ symbol re := regexp.MustCompile(`@(\d+)`) matches := re.FindAllStringSubmatch(message, -1) var phoneNumbers []string // Loop through the matches and extract the phone numbers for _, match := range matches { if len(match) > 1 { phoneNumbers = append(phoneNumbers, match[1]) } } return phoneNumbers } func DownloadImageFromURL(url string) ([]byte, string, error) { client := &http.Client{ Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") } return nil }, } response, err := client.Get(url) if err != nil { return nil, "", err } defer response.Body.Close() if response.StatusCode != http.StatusOK { return nil, "", fmt.Errorf("HTTP request failed with status: %s", response.Status) } contentType := response.Header.Get("Content-Type") if !strings.HasPrefix(contentType, "image/") { return nil, "", fmt.Errorf("invalid content type: %s", contentType) } // Check content length if available if contentLength := response.ContentLength; contentLength > int64(config.WhatsappSettingMaxImageSize) { return nil, "", fmt.Errorf("image size %d exceeds maximum allowed size %d", contentLength, config.WhatsappSettingMaxImageSize) } // Limit the size from config reader := io.LimitReader(response.Body, int64(config.WhatsappSettingMaxImageSize)) // 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(reader) if err != nil { return nil, "", err } return imageData, fileName, nil } // DownloadAudioFromURL downloads an audio file from the provided URL and returns the bytes and sanitized filename. // It validates that the content-type returned by the server starts with "audio/" and that the size is below // WhatsappSettingMaxDownloadSize limit to avoid memory exhaustion. Only the MIME types defined in audio validation // are allowed to ensure WhatsApp compatibility. func DownloadAudioFromURL(audioURL string) ([]byte, string, error) { client := &http.Client{ Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") } return nil }, } resp, err := client.Get(audioURL) if err != nil { return nil, "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, "", fmt.Errorf("HTTP request failed with status: %s", resp.Status) } // Extract only the MIME type portion (ignore parameters like charset) contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0]) // Align audio MIME validation with the one used for uploaded files to ensure consistency with WhatsApp requirements. allowedMimes := 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/wma": true, "audio/x-ms-wma": true, "audio/wav": true, "audio/vnd.wav": true, "audio/vnd.wave": true, "audio/wave": true, "audio/x-pn-wav": true, "audio/x-wav": true, "video/mp4": true, // Sometimes audio is served as mp4 "application/ogg": true, // Ogg audio "application/x-mpeg": true, "audio/webm": true, // WebM audio "video/webm": true, // WebM audio/video "audio/mp4": true, } // If content type is generic or not in list, just warn but allow download (let WhatsApp reject if invalid) if !allowedMimes[contentType] { logrus.Warnf("DownloadAudioFromURL: unexpected content type '%s', proceeding anyway", contentType) } // Validate content length when it is provided by the server. maxSize := config.WhatsappSettingMaxDownloadSize if resp.ContentLength > 0 && resp.ContentLength > maxSize { return nil, "", fmt.Errorf("audio size %d exceeds maximum allowed size %d", resp.ContentLength, maxSize) } // Guard against servers that do not set Content-Length by reading at most (maxSize+1) bytes // and erroring if the limit is exceeded. limit := maxSize if limit < math.MaxInt64 { limit++ } limitedReader := &io.LimitedReader{R: resp.Body, N: limit} audioData, err := io.ReadAll(limitedReader) if err != nil { return nil, "", err } if int64(len(audioData)) > maxSize { return nil, "", fmt.Errorf("downloaded audio size of %d bytes exceeds the maximum allowed size of %d bytes", len(audioData), maxSize) } // Derive filename from URL path (strip query parameters if present) segments := strings.Split(audioURL, "/") fileName := segments[len(segments)-1] fileName = strings.Split(fileName, "?")[0] if fileName == "" { fileName = fmt.Sprintf("audio_%d", time.Now().Unix()) } return audioData, fileName, nil } // DownloadVideoFromURL downloads a video file from the provided URL and returns the bytes and sanitized filename. // It validates that the content-type returned by the server is one of the supported WhatsApp video formats and // that the size does not exceed WhatsappSettingMaxDownloadSize to avoid memory exhaustion. func DownloadVideoFromURL(videoURL string) ([]byte, string, error) { client := &http.Client{ Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") } return nil }, } resp, err := client.Get(videoURL) if err != nil { return nil, "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, "", fmt.Errorf("HTTP request failed with status: %s", resp.Status) } // Extract MIME type without parameters contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0]) allowedMimes := map[string]bool{ "video/mp4": true, "video/x-matroska": true, // mkv "video/avi": true, "video/x-msvideo": true, } if !allowedMimes[contentType] { return nil, "", fmt.Errorf("invalid content type: %s", contentType) } // Validate content length if provided maxSize := config.WhatsappSettingMaxDownloadSize if resp.ContentLength > 0 && resp.ContentLength > maxSize { return nil, "", fmt.Errorf("video size %d exceeds maximum allowed size %d", resp.ContentLength, maxSize) } // Guard against unknown Content-Length by limiting reader limit := maxSize if limit < math.MaxInt64 { limit++ } limitedReader := &io.LimitedReader{R: resp.Body, N: limit} videoData, err := io.ReadAll(limitedReader) if err != nil { return nil, "", err } if int64(len(videoData)) > maxSize { return nil, "", fmt.Errorf("downloaded video size of %d bytes exceeds the maximum allowed size of %d bytes", len(videoData), maxSize) } // Derive filename from URL path segments := strings.Split(videoURL, "/") fileName := segments[len(segments)-1] fileName = strings.Split(fileName, "?")[0] if fileName == "" { fileName = fmt.Sprintf("video_%d.mp4", time.Now().Unix()) } return videoData, fileName, nil } // FormatBusinessHourTime converts numeric time format (e.g., 600, 1200) to HH:MM format (e.g., "06:00", "12:00") func FormatBusinessHourTime(timeValue any) string { var timeInt int switch v := timeValue.(type) { case int: timeInt = v case int32: timeInt = int(v) case int64: timeInt = int(v) case uint: timeInt = int(v) case uint32: timeInt = int(v) case uint64: timeInt = int(v) case string: parsed, err := strconv.Atoi(v) if err != nil { return v // Return as-is if it's already a string and can't be parsed } timeInt = parsed default: return fmt.Sprintf("%v", timeValue) // Return as-is for unknown types } // Extract hours and minutes hours := timeInt / 100 minutes := timeInt % 100 return fmt.Sprintf("%02d:%02d", hours, minutes) } // UniqueStrings removes duplicate strings from a slice while preserving order func UniqueStrings(input []string) []string { seen := make(map[string]bool) result := []string{} for _, s := range input { if !seen[s] { seen[s] = true result = append(result, s) } } return result } // DownloadFileFromURL downloads a file from the provided URL and returns the bytes and sanitized filename. // It enforces max file size limit. func DownloadFileFromURL(fileURL string) ([]byte, string, error) { client := &http.Client{ Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("too many redirects") } return nil }, } resp, err := client.Get(fileURL) if err != nil { return nil, "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, "", fmt.Errorf("HTTP request failed with status: %s", resp.Status) } // Validate content length when it is provided by the server. maxSize := config.WhatsappSettingMaxFileSize if resp.ContentLength > 0 && resp.ContentLength > maxSize { return nil, "", fmt.Errorf("file size %d exceeds maximum allowed size %d", resp.ContentLength, maxSize) } // Limit reader limit := maxSize if limit < math.MaxInt64 { limit++ } limitedReader := &io.LimitedReader{R: resp.Body, N: limit} fileData, err := io.ReadAll(limitedReader) if err != nil { return nil, "", err } if int64(len(fileData)) > maxSize { return nil, "", fmt.Errorf("downloaded file size of %d bytes exceeds the maximum allowed size of %d bytes", len(fileData), maxSize) } // Derive filename from URL path segments := strings.Split(fileURL, "/") fileName := segments[len(segments)-1] fileName = strings.Split(fileName, "?")[0] if fileName == "" { fileName = fmt.Sprintf("file_%d", time.Now().Unix()) } return fileData, fileName, nil }