package usecase import ( "context" "fmt" "os" "path/filepath" "time" "github.com/aldinokemal/go-whatsapp-web-multidevice/config" domainChatStorage "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/chatstorage" domainMessage "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/message" "github.com/aldinokemal/go-whatsapp-web-multidevice/infrastructure/whatsapp" pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils" "github.com/aldinokemal/go-whatsapp-web-multidevice/validations" "github.com/sirupsen/logrus" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waSyncAction" "go.mau.fi/whatsmeow/types" "google.golang.org/protobuf/proto" ) type serviceMessage struct { chatStorageRepo domainChatStorage.IChatStorageRepository } func NewMessageService(chatStorageRepo domainChatStorage.IChatStorageRepository) domainMessage.IMessageUsecase { return &serviceMessage{ chatStorageRepo: chatStorageRepo, } } func (service serviceMessage) MarkAsRead(ctx context.Context, request domainMessage.MarkAsReadRequest) (response domainMessage.GenericResponse, err error) { if err = validations.ValidateMarkAsRead(ctx, request); err != nil { return response, err } client := whatsapp.ClientFromContext(ctx) if client == nil { return response, pkgError.ErrWaCLI } dataWaRecipient, err := utils.ValidateJidWithLogin(client, request.Phone) if err != nil { return response, err } ids := []types.MessageID{request.MessageID} if err = client.MarkRead(ctx, ids, time.Now(), dataWaRecipient, *client.Store.ID); err != nil { return response, err } logrus.Info(map[string]any{ "phone": request.Phone, "message_id": request.MessageID, "chat": dataWaRecipient.String(), "sender": client.Store.ID.String(), }) response.MessageID = request.MessageID response.Status = fmt.Sprintf("Mark as read success %s", request.MessageID) return response, nil } func (service serviceMessage) ReactMessage(ctx context.Context, request domainMessage.ReactionRequest) (response domainMessage.GenericResponse, err error) { if err = validations.ValidateReactMessage(ctx, request); err != nil { return response, err } client := whatsapp.ClientFromContext(ctx) if client == nil { return response, pkgError.ErrWaCLI } dataWaRecipient, err := utils.ValidateJidWithLogin(client, request.Phone) if err != nil { return response, err } // Determine the sender of the original message for BuildReaction. // BuildReaction uses BuildMessageKey internally, which correctly sets the // Participant field for group chats — required by the WhatsApp protocol. // An empty JID means "message was from me". senderJID := types.EmptyJID message, err := service.chatStorageRepo.GetMessageByID(request.MessageID) if err != nil { logrus.Warnf("Failed to lookup message %s for reaction: %v, using heuristic", request.MessageID, err) if len(request.MessageID) > 22 { if dataWaRecipient.Server == types.GroupServer { logrus.Warnf("Cannot determine original sender for group reaction to %s — reaction may not be delivered", request.MessageID) } } } else if message != nil { if !message.IsFromMe && message.Sender != "" { parsed, parseErr := utils.ParseJID(message.Sender) if parseErr == nil { senderJID = parsed } else { logrus.Warnf("Failed to parse sender JID '%s' for reaction: %v", message.Sender, parseErr) } } } else { logrus.Debugf("Message %s not found in database, assuming sent by me", request.MessageID) } // BuildReaction correctly constructs the MessageKey with Participant field // for group chats, which is required for the reaction to be delivered. msg := client.BuildReaction(dataWaRecipient, senderJID, request.MessageID, request.Emoji) ts, err := client.SendMessage(ctx, dataWaRecipient, msg) if err != nil { return response, err } response.MessageID = ts.ID response.Status = fmt.Sprintf("Reaction sent to %s (server timestamp: %s)", request.Phone, ts.Timestamp) return response, nil } func (service serviceMessage) RevokeMessage(ctx context.Context, request domainMessage.RevokeRequest) (response domainMessage.GenericResponse, err error) { if err = validations.ValidateRevokeMessage(ctx, request); err != nil { return response, err } client := whatsapp.ClientFromContext(ctx) if client == nil { return response, pkgError.ErrWaCLI } dataWaRecipient, err := utils.ValidateJidWithLogin(client, request.Phone) if err != nil { return response, err } // Resolve the original sender so group admins can revoke other members' // messages. BuildRevoke treats types.EmptyJID as "message was from me"; // any other JID is admin-revoke and requires the bot to be group admin. // WhatsApp message IDs are globally unique, so a cross-device lookup // via GetMessageByID yields the same sender regardless of which device // owns the row. senderJID := types.EmptyJID message, lookupErr := service.chatStorageRepo.GetMessageByID(request.MessageID) if lookupErr != nil { logrus.Warnf("Failed to lookup message %s for revoke: %v, assuming self-revoke", request.MessageID, lookupErr) } else if message != nil && !message.IsFromMe && message.Sender != "" { parsed, parseErr := utils.ParseJID(message.Sender) if parseErr != nil { logrus.Warnf("Failed to parse sender JID '%s' for revoke: %v", message.Sender, parseErr) } else { // Stored senders can still be @lid; whatsmeow's Revoke needs // the phone-number form or it rejects the request at the wire. senderJID = whatsapp.NormalizeJIDFromLID(ctx, parsed, client) } } ts, err := client.SendMessage(ctx, dataWaRecipient, client.BuildRevoke(dataWaRecipient, senderJID, request.MessageID)) if err != nil { return response, err } response.MessageID = ts.ID response.Status = fmt.Sprintf("Revoke success %s (server timestamp: %s)", request.Phone, ts.Timestamp) return response, nil } func (service serviceMessage) DeleteMessage(ctx context.Context, request domainMessage.DeleteRequest) (err error) { if err = validations.ValidateDeleteMessage(ctx, request); err != nil { return err } client := whatsapp.ClientFromContext(ctx) if client == nil { return pkgError.ErrWaCLI } dataWaRecipient, err := utils.ValidateJidWithLogin(client, request.Phone) if err != nil { return err } isFromMe := "1" if len(request.MessageID) > 22 { isFromMe = "0" } patchInfo := appstate.PatchInfo{ Timestamp: time.Now(), Type: appstate.WAPatchRegularHigh, Mutations: []appstate.MutationInfo{{ Index: []string{appstate.IndexDeleteMessageForMe, dataWaRecipient.String(), request.MessageID, isFromMe, client.Store.ID.String()}, Value: &waSyncAction.SyncActionValue{ DeleteMessageForMeAction: &waSyncAction.DeleteMessageForMeAction{ DeleteMedia: proto.Bool(true), MessageTimestamp: proto.Int64(time.Now().UnixMilli()), }, }, }}, } if err = client.SendAppState(ctx, patchInfo); err != nil { return err } return nil } func (service serviceMessage) UpdateMessage(ctx context.Context, request domainMessage.UpdateMessageRequest) (response domainMessage.GenericResponse, err error) { if err = validations.ValidateUpdateMessage(ctx, request); err != nil { return response, err } client := whatsapp.ClientFromContext(ctx) if client == nil { return response, pkgError.ErrWaCLI } dataWaRecipient, err := utils.ValidateJidWithLogin(client, request.Phone) if err != nil { return response, err } msg := &waE2E.Message{Conversation: proto.String(request.Message)} ts, err := client.SendMessage(ctx, dataWaRecipient, client.BuildEdit(dataWaRecipient, request.MessageID, msg)) if err != nil { return response, err } response.MessageID = ts.ID response.Status = fmt.Sprintf("Update message success %s (server timestamp: %s)", request.Phone, ts.Timestamp) return response, nil } // StarMessage implements message.IMessageService. func (service serviceMessage) StarMessage(ctx context.Context, request domainMessage.StarRequest) (err error) { if err = validations.ValidateStarMessage(ctx, request); err != nil { return err } client := whatsapp.ClientFromContext(ctx) if client == nil { return pkgError.ErrWaCLI } dataWaRecipient, err := utils.ValidateJidWithLogin(client, request.Phone) if err != nil { return err } isFromMe := true if len(request.MessageID) > 22 { isFromMe = false } patchInfo := appstate.BuildStar(dataWaRecipient.ToNonAD(), *client.Store.ID, request.MessageID, isFromMe, request.IsStarred) if err = client.SendAppState(ctx, patchInfo); err != nil { return err } return nil } // DownloadMedia implements message.IMessageService. func (service serviceMessage) DownloadMedia(ctx context.Context, request domainMessage.DownloadMediaRequest) (response domainMessage.DownloadMediaResponse, err error) { if err = validations.ValidateDownloadMedia(ctx, request); err != nil { return response, err } client := whatsapp.ClientFromContext(ctx) if client == nil { return response, pkgError.ErrWaCLI } dataWaRecipient, err := utils.ValidateJidWithLogin(client, request.Phone) if err != nil { return response, err } // Query the message from chat storage message, err := service.chatStorageRepo.GetMessageByID(request.MessageID) if err != nil { return response, fmt.Errorf("message not found: %v", err) } if message == nil { return response, fmt.Errorf("message with ID %s not found", request.MessageID) } // Check if message has media if message.MediaType == "" || message.URL == "" { return response, fmt.Errorf("message %s does not contain downloadable media", request.MessageID) } // Verify the message is from the specified chat if message.ChatJID != dataWaRecipient.String() { return response, fmt.Errorf("message %s does not belong to chat %s", request.MessageID, dataWaRecipient.String()) } // Create directory structure for organized storage chatDir := filepath.Join(config.PathMedia, utils.ExtractPhoneNumber(message.ChatJID)) dateDir := filepath.Join(chatDir, message.Timestamp.Format("2006-01-02")) err = os.MkdirAll(dateDir, 0755) if err != nil { return response, fmt.Errorf("failed to create directory: %v", err) } // Create a downloadable message interface based on media type var downloadableMsg any switch message.MediaType { case "image": downloadableMsg = &waE2E.ImageMessage{ URL: proto.String(message.URL), MediaKey: message.MediaKey, FileSHA256: message.FileSHA256, FileEncSHA256: message.FileEncSHA256, FileLength: proto.Uint64(message.FileLength), } case "video": downloadableMsg = &waE2E.VideoMessage{ URL: proto.String(message.URL), MediaKey: message.MediaKey, FileSHA256: message.FileSHA256, FileEncSHA256: message.FileEncSHA256, FileLength: proto.Uint64(message.FileLength), } case "audio": downloadableMsg = &waE2E.AudioMessage{ URL: proto.String(message.URL), MediaKey: message.MediaKey, FileSHA256: message.FileSHA256, FileEncSHA256: message.FileEncSHA256, FileLength: proto.Uint64(message.FileLength), } case "document": downloadableMsg = &waE2E.DocumentMessage{ URL: proto.String(message.URL), MediaKey: message.MediaKey, FileSHA256: message.FileSHA256, FileEncSHA256: message.FileEncSHA256, FileLength: proto.Uint64(message.FileLength), FileName: proto.String(message.Filename), } case "sticker": downloadableMsg = &waE2E.StickerMessage{ URL: proto.String(message.URL), MediaKey: message.MediaKey, FileSHA256: message.FileSHA256, FileEncSHA256: message.FileEncSHA256, FileLength: proto.Uint64(message.FileLength), } default: return response, fmt.Errorf("unsupported media type: %s", message.MediaType) } // Download the media using existing utils.ExtractMedia function extractedMedia, err := utils.ExtractMedia(ctx, client, dateDir, downloadableMsg.(whatsmeow.DownloadableMessage)) if err != nil { return response, fmt.Errorf("failed to download media: %v", err) } // Get file size fileInfo, err := os.Stat(extractedMedia.MediaPath) if err != nil { logrus.Warnf("Could not get file size for %s: %v", extractedMedia.MediaPath, err) } // Build response response.MessageID = request.MessageID response.Status = fmt.Sprintf("Media downloaded successfully to %s", extractedMedia.MediaPath) response.MediaType = message.MediaType response.Filename = filepath.Base(extractedMedia.MediaPath) response.FilePath = extractedMedia.MediaPath if fileInfo != nil { response.FileSize = fileInfo.Size() } logrus.Info(map[string]any{ "message_id": request.MessageID, "phone": request.Phone, "chat": dataWaRecipient.String(), "media_type": response.MediaType, "file_path": response.FilePath, "file_size": response.FileSize, }) return response, nil }