Skip to content

Commit f3de545

Browse files
authored
feat: add on-demand media download API and auto-download UI (#398)
- Add DownloadMedia API endpoint (GET /message/:id/download) - Implement media download with organized storage by chat/date - Support all media types: image, video, audio, document, sticker - Add auto-download functionality to ChatMessages UI component - Display actual media content instead of "Media Available" labels - Include loading states, error handling, and retry functionality - Implement concurrency control for batch downloads (max 3 concurrent)
1 parent 3421d9c commit f3de545

File tree

7 files changed

+444
-15
lines changed

7 files changed

+444
-15
lines changed

src/domains/message/interfaces.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type IMessageActions interface {
1616
type IMessageManagement interface {
1717
DeleteMessage(ctx context.Context, request DeleteRequest) (err error)
1818
StarMessage(ctx context.Context, request StarRequest) (err error)
19+
DownloadMedia(ctx context.Context, request DownloadMediaRequest) (response DownloadMediaResponse, err error)
1920
}
2021

2122
// IMessageUsecase combines all message interfaces

src/domains/message/message.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,17 @@ type StarRequest struct {
3737
Phone string `json:"phone" form:"phone"`
3838
IsStarred bool `json:"is_starred"`
3939
}
40+
41+
type DownloadMediaRequest struct {
42+
MessageID string `json:"message_id" uri:"message_id"`
43+
Phone string `json:"phone" form:"phone"`
44+
}
45+
46+
type DownloadMediaResponse struct {
47+
MessageID string `json:"message_id"`
48+
Status string `json:"status"`
49+
MediaType string `json:"media_type"`
50+
Filename string `json:"filename"`
51+
FilePath string `json:"file_path"`
52+
FileSize int64 `json:"file_size"`
53+
}

src/pkg/utils/environment.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,19 @@ func IsLocal() bool {
1717
}
1818

1919
func Env[T any](key string, defValue ...T) T {
20-
if env := viper.Get(key); env != nil {
21-
// Ensure type safety for retrieved environment variables
22-
if value, ok := env.(T); ok {
23-
return value
24-
}
25-
}
20+
if env := viper.Get(key); env != nil {
21+
// Ensure type safety for retrieved environment variables
22+
if value, ok := env.(T); ok {
23+
return value
24+
}
25+
}
2626

27-
if len(defValue) > 0 {
28-
return defValue[0]
29-
}
27+
if len(defValue) > 0 {
28+
return defValue[0]
29+
}
3030

31-
var zero T
32-
return zero
31+
var zero T
32+
return zero
3333
}
3434

3535
// MustHaveEnv ensure the ENV is exists, otherwise will crashing the app

src/ui/rest/message.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func InitRestMessage(app fiber.Router, service domainMessage.IMessageUsecase) Me
2121
app.Post("/message/:message_id/read", rest.MarkAsRead)
2222
app.Post("/message/:message_id/star", rest.StarMessage)
2323
app.Post("/message/:message_id/unstar", rest.UnstarMessage)
24+
app.Get("/message/:message_id/download", rest.DownloadMedia)
2425
return rest
2526
}
2627

@@ -157,3 +158,21 @@ func (controller *Message) UnstarMessage(c *fiber.Ctx) error {
157158
Results: nil,
158159
})
159160
}
161+
162+
func (controller *Message) DownloadMedia(c *fiber.Ctx) error {
163+
var request domainMessage.DownloadMediaRequest
164+
165+
request.MessageID = c.Params("message_id")
166+
request.Phone = c.Query("phone")
167+
utils.SanitizePhone(&request.Phone)
168+
169+
response, err := controller.Service.DownloadMedia(c.UserContext(), request)
170+
utils.PanicIfNeeded(err)
171+
172+
return c.JSON(utils.ResponseData{
173+
Status: 200,
174+
Code: "SUCCESS",
175+
Message: response.Status,
176+
Results: response,
177+
})
178+
}

src/usecase/message.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ package usecase
33
import (
44
"context"
55
"fmt"
6+
"os"
7+
"path/filepath"
68
"time"
79

10+
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
811
domainChatStorage "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/chatstorage"
912
domainMessage "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/message"
1013
"github.com/aldinokemal/go-whatsapp-web-multidevice/infrastructure/whatsapp"
1114
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils"
1215
"github.com/aldinokemal/go-whatsapp-web-multidevice/validations"
1316
"github.com/sirupsen/logrus"
17+
"go.mau.fi/whatsmeow"
1418
"go.mau.fi/whatsmeow/appstate"
1519
"go.mau.fi/whatsmeow/proto/waCommon"
1620
"go.mau.fi/whatsmeow/proto/waE2E"
@@ -182,3 +186,126 @@ func (service serviceMessage) StarMessage(ctx context.Context, request domainMes
182186
}
183187
return nil
184188
}
189+
190+
// DownloadMedia implements message.IMessageService.
191+
func (service serviceMessage) DownloadMedia(ctx context.Context, request domainMessage.DownloadMediaRequest) (response domainMessage.DownloadMediaResponse, err error) {
192+
if err = validations.ValidateDownloadMedia(ctx, request); err != nil {
193+
return response, err
194+
}
195+
196+
dataWaRecipient, err := utils.ValidateJidWithLogin(whatsapp.GetClient(), request.Phone)
197+
if err != nil {
198+
return response, err
199+
}
200+
201+
// Query the message from chat storage
202+
message, err := service.chatStorageRepo.GetMessageByID(request.MessageID)
203+
if err != nil {
204+
return response, fmt.Errorf("message not found: %v", err)
205+
}
206+
207+
if message == nil {
208+
return response, fmt.Errorf("message with ID %s not found", request.MessageID)
209+
}
210+
211+
// Check if message has media
212+
if message.MediaType == "" || message.URL == "" {
213+
return response, fmt.Errorf("message %s does not contain downloadable media", request.MessageID)
214+
}
215+
216+
// Verify the message is from the specified chat
217+
if message.ChatJID != dataWaRecipient.String() {
218+
return response, fmt.Errorf("message %s does not belong to chat %s", request.MessageID, dataWaRecipient.String())
219+
}
220+
221+
// Create directory structure for organized storage
222+
chatDir := filepath.Join(config.PathMedia, utils.ExtractPhoneNumber(message.ChatJID))
223+
dateDir := filepath.Join(chatDir, message.Timestamp.Format("2006-01-02"))
224+
225+
err = os.MkdirAll(dateDir, 0755)
226+
if err != nil {
227+
return response, fmt.Errorf("failed to create directory: %v", err)
228+
}
229+
230+
// Create a downloadable message interface based on media type
231+
var downloadableMsg interface{}
232+
233+
switch message.MediaType {
234+
case "image":
235+
downloadableMsg = &waE2E.ImageMessage{
236+
URL: proto.String(message.URL),
237+
MediaKey: message.MediaKey,
238+
FileSHA256: message.FileSHA256,
239+
FileEncSHA256: message.FileEncSHA256,
240+
FileLength: proto.Uint64(message.FileLength),
241+
}
242+
case "video":
243+
downloadableMsg = &waE2E.VideoMessage{
244+
URL: proto.String(message.URL),
245+
MediaKey: message.MediaKey,
246+
FileSHA256: message.FileSHA256,
247+
FileEncSHA256: message.FileEncSHA256,
248+
FileLength: proto.Uint64(message.FileLength),
249+
}
250+
case "audio":
251+
downloadableMsg = &waE2E.AudioMessage{
252+
URL: proto.String(message.URL),
253+
MediaKey: message.MediaKey,
254+
FileSHA256: message.FileSHA256,
255+
FileEncSHA256: message.FileEncSHA256,
256+
FileLength: proto.Uint64(message.FileLength),
257+
}
258+
case "document":
259+
downloadableMsg = &waE2E.DocumentMessage{
260+
URL: proto.String(message.URL),
261+
MediaKey: message.MediaKey,
262+
FileSHA256: message.FileSHA256,
263+
FileEncSHA256: message.FileEncSHA256,
264+
FileLength: proto.Uint64(message.FileLength),
265+
FileName: proto.String(message.Filename),
266+
}
267+
case "sticker":
268+
downloadableMsg = &waE2E.StickerMessage{
269+
URL: proto.String(message.URL),
270+
MediaKey: message.MediaKey,
271+
FileSHA256: message.FileSHA256,
272+
FileEncSHA256: message.FileEncSHA256,
273+
FileLength: proto.Uint64(message.FileLength),
274+
}
275+
default:
276+
return response, fmt.Errorf("unsupported media type: %s", message.MediaType)
277+
}
278+
279+
// Download the media using existing utils.ExtractMedia function
280+
extractedMedia, err := utils.ExtractMedia(ctx, whatsapp.GetClient(), dateDir, downloadableMsg.(whatsmeow.DownloadableMessage))
281+
if err != nil {
282+
return response, fmt.Errorf("failed to download media: %v", err)
283+
}
284+
285+
// Get file size
286+
fileInfo, err := os.Stat(extractedMedia.MediaPath)
287+
if err != nil {
288+
logrus.Warnf("Could not get file size for %s: %v", extractedMedia.MediaPath, err)
289+
}
290+
291+
// Build response
292+
response.MessageID = request.MessageID
293+
response.Status = fmt.Sprintf("Media downloaded successfully to %s", extractedMedia.MediaPath)
294+
response.MediaType = message.MediaType
295+
response.Filename = filepath.Base(extractedMedia.MediaPath)
296+
response.FilePath = extractedMedia.MediaPath
297+
if fileInfo != nil {
298+
response.FileSize = fileInfo.Size()
299+
}
300+
301+
logrus.Info(map[string]any{
302+
"message_id": request.MessageID,
303+
"phone": request.Phone,
304+
"chat": dataWaRecipient.String(),
305+
"media_type": response.MediaType,
306+
"file_path": response.FilePath,
307+
"file_size": response.FileSize,
308+
})
309+
310+
return response, nil
311+
}

src/validations/message_validation.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,16 @@ func ValidateStarMessage(ctx context.Context, request domainMessage.StarRequest)
8989

9090
return nil
9191
}
92+
93+
func ValidateDownloadMedia(ctx context.Context, request domainMessage.DownloadMediaRequest) error {
94+
err := validation.ValidateStructWithContext(ctx, &request,
95+
validation.Field(&request.Phone, validation.Required),
96+
validation.Field(&request.MessageID, validation.Required),
97+
)
98+
99+
if err != nil {
100+
return pkgError.ValidationError(err.Error())
101+
}
102+
103+
return nil
104+
}

0 commit comments

Comments
 (0)