From 77beb0fae4a199dce31f1ea7b9812b06626c238d Mon Sep 17 00:00:00 2001 From: Iago Cotta Date: Fri, 24 Jul 2026 16:02:16 -0300 Subject: [PATCH 1/2] =?UTF-8?q?Inclus=C3=A3o=20endpoint=20Encaminhamento?= =?UTF-8?q?=20de=20mensagens=20(forward)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/wiki/guias-api/api-messages.md | 116 + docs/wiki/referencia/api-reference.md | 5 +- routes/routes.go | 287 ++ sendMessage/handler/send_handler.go | 886 ++++++ sendMessage/service/send_service.go | 3621 +++++++++++++++++++++++++ 5 files changed, 4913 insertions(+), 2 deletions(-) create mode 100644 routes/routes.go create mode 100644 sendMessage/handler/send_handler.go create mode 100644 sendMessage/service/send_service.go diff --git a/docs/wiki/guias-api/api-messages.md b/docs/wiki/guias-api/api-messages.md index 35e27471..b633fdf4 100644 --- a/docs/wiki/guias-api/api-messages.md +++ b/docs/wiki/guias-api/api-messages.md @@ -21,6 +21,7 @@ Documentação completa dos endpoints para enviar e gerenciar mensagens WhatsApp - [Marcar como Lida](#marcar-como-lida) - [Editar Mensagem](#editar-mensagem) - [Deletar Mensagem](#deletar-mensagem) +- [Encaminhar Mensagem](#encaminhar-mensagem) - [Presença no Chat](#presença-no-chat) - [Download de Mídia](#download-de-mídia) - [Status da Mensagem](#status-da-mensagem) @@ -933,6 +934,121 @@ curl -X POST http://localhost:4000/message/delete \ --- +### Encaminhar Mensagem + +Encaminha uma mensagem existente para outro chat, com o rótulo **"Encaminhada"** exibido no destinatário (igual ao encaminhamento nativo do WhatsApp). + +**Endpoint**: `POST /message/forward` + +**Body**: +```json +{ + "number": "5511999999999", + "message": { + "extendedTextMessage": { + "text": "Texto da mensagem original" + } + }, + "id": "msg-custom-123", + "delay": 1000, + "formatJid": true, + "forwardingScore": 1 +} +``` + +**Parâmetros**: + +| Campo | Tipo | Obrigatório | Descrição | +|-------|------|-------------|-----------| +| `number` | string | ✅ Sim | Destino do encaminhamento (usuário, grupo `@g.us` ou newsletter) | +| `message` | object | ✅ Sim | Payload completo da mensagem original (`waE2E.Message`) — o objeto `data.Message` recebido no webhook ou retornado pelos endpoints `/send/*` | +| `id` | string | ❌ Não | ID customizado para a cópia encaminhada | +| `delay` | int32 | ❌ Não | Delay em milissegundos ("digitando...") antes de enviar | +| `formatJid` | bool | ❌ Não | Formatar número automaticamente (padrão: true) | +| `forwardingScore` | uint32 | ❌ Não | Sobrescreve o score calculado (padrão: score original + 1; valores ≥ 5 exibem "Encaminhada muitas vezes") | + +**Como funciona**: + +- O `message` deve ser enviado **exatamente como recebido** no webhook (`data.Message`) ou no response de um `/send/*`. O endpoint clona o payload, descarta o contexto antigo (citações/menções do chat de origem) e carimba `isForwarded: true` + `forwardingScore`. +- **Mídia é encaminhada por referência** (`mediaKey`/`directPath` reaproveitados), sem download nem re-upload — por isso é importante enviar o objeto de mídia completo (`URL`, `mediaKey`, `directPath`, `fileSHA256` etc.). +- Tipos suportados: texto (`conversation`/`extendedTextMessage`), imagem, vídeo, PTV, áudio, documento, sticker, localização, contato, enquete, lista, botões e interactive. +- Mensagens **view-once** (visualização única) não podem ser encaminhadas (retorna erro). + +**Resposta de Sucesso (200)**: mesmo formato dos endpoints `/send/*`: +```json +{ + "message": "success", + "data": { + "Info": { + "ID": "3EB0C5A277F7F9B6C599", + "Timestamp": "2025-11-11T10:30:00Z", + "Type": "ExtendedTextMessage" + }, + "Message": { + "extendedTextMessage": { + "text": "Texto da mensagem original", + "contextInfo": { + "forwardingScore": 1, + "isForwarded": true + } + } + } + } +} +``` + +**Respostas de Erro (400)**: +```json +{ "error": "phone number is required" } +``` +```json +{ "error": "message payload is required" } +``` +```json +{ "error": "view-once messages cannot be forwarded" } +``` +```json +{ "error": "unsupported message type for forwarding" } +``` + +**Exemplo cURL (texto)**: +```bash +curl -X POST http://localhost:4000/message/forward \ + -H "Content-Type: application/json" \ + -H "apikey: SUA-CHAVE-API" \ + -d '{ + "number": "5511999999999", + "message": { + "extendedTextMessage": { + "text": "Teste encaminhamento 1" + } + } + }' +``` + +**Exemplo cURL (imagem recebida via webhook)**: +```bash +curl -X POST http://localhost:4000/message/forward \ + -H "Content-Type: application/json" \ + -H "apikey: SUA-CHAVE-API" \ + -d '{ + "number": "5511888888888", + "message": { + "imageMessage": { + "URL": "https://mmg.whatsapp.net/...", + "mimetype": "image/jpeg", + "fileSHA256": "...", + "fileLength": 12345, + "mediaKey": "...", + "fileEncSHA256": "...", + "directPath": "/v/..." + } + } + }' +``` + +--- + ### Presença no Chat Define o status de presença no chat (digitando, gravando áudio, online). diff --git a/docs/wiki/referencia/api-reference.md b/docs/wiki/referencia/api-reference.md index f4f7e817..992f0930 100644 --- a/docs/wiki/referencia/api-reference.md +++ b/docs/wiki/referencia/api-reference.md @@ -41,7 +41,7 @@ http://localhost:4000 --- -### Mensagens (16 endpoints) +### Mensagens (17 endpoints) **Envio:** - `POST /send/text` - Mensagem de texto @@ -59,6 +59,7 @@ http://localhost:4000 - `POST /message/markread` - Marcar como lida - `POST /message/edit` - Editar mensagem - `POST /message/delete` - Deletar mensagem +- `POST /message/forward` - Encaminhar mensagem (com rótulo "Encaminhada") - `POST /message/presence` - Status de presença (digitando/gravando) - `POST /message/downloadmedia` - Download de mídia - `POST /message/status` - Status de entrega/leitura @@ -179,6 +180,6 @@ http://localhost:4000 --- -**Total de endpoints disponíveis: 79** +**Total de endpoints disponíveis: 80** **Documentação Evolution GO v1.0** diff --git a/routes/routes.go b/routes/routes.go new file mode 100644 index 00000000..fb171f4f --- /dev/null +++ b/routes/routes.go @@ -0,0 +1,287 @@ +package routes + +import ( + "net/http" + + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + + _ "github.com/evolution-foundation/evolution-go/docs" + call_handler "github.com/evolution-foundation/evolution-go/pkg/call/handler" + chat_handler "github.com/evolution-foundation/evolution-go/pkg/chat/handler" + community_handler "github.com/evolution-foundation/evolution-go/pkg/community/handler" + group_handler "github.com/evolution-foundation/evolution-go/pkg/group/handler" + instance_handler "github.com/evolution-foundation/evolution-go/pkg/instance/handler" + label_handler "github.com/evolution-foundation/evolution-go/pkg/label/handler" + message_handler "github.com/evolution-foundation/evolution-go/pkg/message/handler" + auth_middleware "github.com/evolution-foundation/evolution-go/pkg/middleware" + newsletter_handler "github.com/evolution-foundation/evolution-go/pkg/newsletter/handler" + poll_handler "github.com/evolution-foundation/evolution-go/pkg/poll/handler" + send_handler "github.com/evolution-foundation/evolution-go/pkg/sendMessage/handler" + server_handler "github.com/evolution-foundation/evolution-go/pkg/server/handler" + user_handler "github.com/evolution-foundation/evolution-go/pkg/user/handler" +) + +type Routes struct { + authMiddleware auth_middleware.Middleware + jidValidationMiddleware *auth_middleware.JIDValidationMiddleware + instanceHandler instance_handler.InstanceHandler + userHandler user_handler.UserHandler + sendHandler send_handler.SendHandler + messageHandler message_handler.MessageHandler + chatHandler chat_handler.ChatHandler + groupHandler group_handler.GroupHandler + callHandler call_handler.CallHandler + communityHandler community_handler.CommunityHandler + labelHandler label_handler.LabelHandler + newsletterHandler newsletter_handler.NewsletterHandler + pollHandler *poll_handler.PollHandler + serverHandler server_handler.ServerHandler +} + +func (r *Routes) AssignRoutes(eng *gin.Engine) { + // Configuração do CORS + eng.Use(func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, Accept, Cache-Control, X-Requested-With, apikey, ApiKey") + c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(200) + return + } + + c.Next() + }) + + eng.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + + eng.GET("/favicon.ico", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + // Rotas para o gerenciador React (sem autenticação) + eng.Static("/assets", "./manager/dist/assets") + + // Exibe a tela inicial do Manager na raiz. + eng.GET("/", func(c *gin.Context) { + c.File("manager/dist/index.html") + }) + + // Ajuste nas rotas do manager para suportar client-side routing do React + eng.GET("/manager/*any", func(c *gin.Context) { + c.File("manager/dist/index.html") + }) + + eng.GET("/manager", func(c *gin.Context) { + c.File("manager/dist/index.html") + }) + + eng.GET("/server/ok", r.serverHandler.ServerOk) + + routes := eng.Group("/instance") + { + routes.Use(r.authMiddleware.AuthAdmin) + { + routes.POST("/create", r.instanceHandler.Create) + routes.GET("/all", r.instanceHandler.All) + routes.GET("/info/:instanceId", r.instanceHandler.Info) + routes.DELETE("/delete/:instanceId", r.instanceHandler.Delete) + routes.POST("/proxy/:instanceId", r.instanceHandler.SetProxy) + routes.DELETE("/proxy/:instanceId", r.instanceHandler.DeleteProxy) + routes.POST("/forcereconnect/:instanceId", r.instanceHandler.ForceReconnect) + routes.GET("/logs/:instanceId", r.instanceHandler.GetLogs) + } + } + + routes = eng.Group("/instance") + { + routes.Use(r.authMiddleware.Auth) + { + routes.POST("/connect", r.instanceHandler.Connect) + routes.GET("/status", r.instanceHandler.Status) + routes.GET("/qr", r.instanceHandler.Qr) + routes.POST("/pair", r.jidValidationMiddleware.ValidateNumberField(), r.instanceHandler.Pair) + routes.POST("/disconnect", r.instanceHandler.Disconnect) + routes.POST("/reconnect", r.instanceHandler.Reconnect) + routes.DELETE("/logout", r.instanceHandler.Logout) + routes.GET("/:instanceId/advanced-settings", r.instanceHandler.GetAdvancedSettings) + routes.PUT("/:instanceId/advanced-settings", r.instanceHandler.UpdateAdvancedSettings) + } + } + + routes = eng.Group("/send") + { + routes.Use(r.authMiddleware.Auth) + { + routes.POST("/text", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.sendHandler.SendText) + routes.POST("/link", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.sendHandler.SendLink) + routes.POST("/media", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.sendHandler.SendMedia) + routes.POST("/poll", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.sendHandler.SendPoll) + routes.POST("/sticker", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.sendHandler.SendSticker) + routes.POST("/location", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.sendHandler.SendLocation) + routes.POST("/contact", r.jidValidationMiddleware.ValidateContactFields(), r.sendHandler.SendContact) // TODO: send multiple contacts + routes.POST("/button", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.sendHandler.SendButton) + routes.POST("/list", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.sendHandler.SendList) + routes.POST("/carousel", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.sendHandler.SendCarousel) + routes.POST("/status/text", r.sendHandler.SendStatusText) + routes.POST("/status/media", r.sendHandler.SendStatusMedia) + } + } + routes = eng.Group("/user") + { + routes.Use(r.authMiddleware.Auth) + { + routes.POST("/info", r.jidValidationMiddleware.ValidateNumberField(), r.userHandler.GetUser) + routes.POST("/check", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.userHandler.CheckUser) + routes.POST("/avatar", r.jidValidationMiddleware.ValidateNumberField(), r.userHandler.GetAvatar) + routes.GET("/contacts", r.userHandler.GetContacts) + routes.GET("/privacy", r.userHandler.GetPrivacy) + routes.POST("/privacy", r.userHandler.SetPrivacy) + routes.POST("/block", r.jidValidationMiddleware.ValidateNumberField(), r.userHandler.BlockContact) + routes.POST("/unblock", r.jidValidationMiddleware.ValidateNumberField(), r.userHandler.UnblockContact) + routes.GET("/blocklist", r.userHandler.GetBlockList) + routes.POST("/profilePicture", r.userHandler.SetProfilePicture) + routes.POST("/profileName", r.userHandler.SetProfileName) + routes.POST("/profileStatus", r.userHandler.SetProfileStatus) + } + } + routes = eng.Group("/message") + { + routes.Use(r.authMiddleware.Auth) + { + routes.POST("/react", r.jidValidationMiddleware.ValidateJIDFields("number"), r.messageHandler.React) + routes.POST("/presence", r.jidValidationMiddleware.ValidateNumberField(), r.messageHandler.ChatPresence) + routes.POST("/markread", r.jidValidationMiddleware.ValidateNumberField(), r.messageHandler.MarkRead) + routes.POST("/markplayed", r.jidValidationMiddleware.ValidateNumberField(), r.messageHandler.MarkPlayed) + routes.POST("/downloadmedia", r.messageHandler.DownloadMedia) + routes.POST("/status", r.messageHandler.GetMessageStatus) + routes.POST("/delete", r.jidValidationMiddleware.ValidateNumberField(), r.messageHandler.DeleteMessageEveryone) + routes.POST("/edit", r.jidValidationMiddleware.ValidateNumberField(), r.messageHandler.EditMessage) // TODO: edit MediaMessage too + routes.POST("/forward", r.jidValidationMiddleware.ValidateNumberFieldWithFormatJid(), r.sendHandler.SendForward) + } + } + routes = eng.Group("/chat") + { + routes.Use(r.authMiddleware.Auth) + { + routes.POST("/pin", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatPin) // TODO: not working + routes.POST("/unpin", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnpin) // TODO: not working + routes.POST("/archive", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatArchive) // TODO: not working + routes.POST("/unarchive", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnarchive) // TODO: not working + routes.POST("/mute", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatMute) // TODO: not working + routes.POST("/unmute", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnmute) // TODO: not working + routes.POST("/history-sync", r.chatHandler.HistorySyncRequest) + } + } + routes = eng.Group("/group") + { + routes.Use(r.authMiddleware.Auth) + { + routes.GET("/list", r.groupHandler.ListGroups) + routes.POST("/info", r.jidValidationMiddleware.ValidateNumberField(), r.groupHandler.GetGroupInfo) + routes.POST("/invitelink", r.jidValidationMiddleware.ValidateNumberField(), r.groupHandler.GetGroupInviteLink) + routes.POST("/photo", r.jidValidationMiddleware.ValidateNumberField(), r.groupHandler.SetGroupPhoto) + routes.POST("/name", r.jidValidationMiddleware.ValidateNumberField(), r.groupHandler.SetGroupName) + routes.POST("/description", r.jidValidationMiddleware.ValidateNumberField(), r.groupHandler.SetGroupDescription) + routes.POST("/create", r.jidValidationMiddleware.ValidateMultipleNumbers("participants"), r.groupHandler.CreateGroup) + routes.POST("/participant", r.jidValidationMiddleware.ValidateJIDFields("number", "participants"), r.groupHandler.UpdateParticipant) + routes.GET("/myall", r.groupHandler.GetMyGroups) // TODO: not working + routes.POST("/join", r.groupHandler.JoinGroupLink) + routes.POST("/leave", r.jidValidationMiddleware.ValidateNumberField(), r.groupHandler.LeaveGroup) + routes.POST("/settings", r.jidValidationMiddleware.ValidateNumberField(), r.groupHandler.UpdateGroupSettings) + } + } + routes = eng.Group("/call") + { + routes.Use(r.authMiddleware.Auth) + { + routes.POST("/reject", r.jidValidationMiddleware.ValidateNumberField(), r.callHandler.RejectCall) + } + } + routes = eng.Group("/community") + { + routes.Use(r.authMiddleware.Auth) + { + routes.POST("/create", r.communityHandler.CreateCommunity) + routes.POST("/add", r.jidValidationMiddleware.ValidateJIDFields("number", "communityId"), r.communityHandler.CommunityAdd) + routes.POST("/remove", r.jidValidationMiddleware.ValidateJIDFields("number", "communityId"), r.communityHandler.CommunityRemove) + } + } + routes = eng.Group("/label") + { + routes.Use(r.authMiddleware.Auth) + { + routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.labelHandler.ChatLabel) + routes.POST("/message", r.labelHandler.MessageLabel) + routes.POST("/edit", r.labelHandler.EditLabel) + routes.GET("/list", r.labelHandler.GetLabels) + } + } + routes = eng.Group("/unlabel") + { + routes.Use(r.authMiddleware.Auth) + { + routes.POST("/chat", r.jidValidationMiddleware.ValidateNumberField(), r.labelHandler.ChatUnlabel) + routes.POST("/message", r.labelHandler.MessageUnlabel) + } + } + routes = eng.Group("/newsletter") + { + routes.Use(r.authMiddleware.Auth) + { + routes.POST("/create", r.newsletterHandler.CreateNewsletter) + routes.GET("/list", r.newsletterHandler.ListNewsletter) + routes.POST("/info", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.GetNewsletter) + routes.POST("/link", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.GetNewsletterInvite) + routes.POST("/subscribe", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.SubscribeNewsletter) + routes.POST("/messages", r.jidValidationMiddleware.ValidateJIDFields("newsletterId"), r.newsletterHandler.GetNewsletterMessages) + } + } + + // NOVO: Rotas de Enquetes (Polls) + routes = eng.Group("/polls") + { + routes.Use(r.authMiddleware.Auth) + { + routes.GET("/:pollMessageId/results", r.pollHandler.GetPollResults) + } + } + +} + +func NewRouter( + authMiddleware auth_middleware.Middleware, + instanceHandler instance_handler.InstanceHandler, + userHandler user_handler.UserHandler, + sendHandler send_handler.SendHandler, + messageHandler message_handler.MessageHandler, + chatHandler chat_handler.ChatHandler, + groupHandler group_handler.GroupHandler, + callHandler call_handler.CallHandler, + communityHandler community_handler.CommunityHandler, + labelHandler label_handler.LabelHandler, + newsletterHandler newsletter_handler.NewsletterHandler, + pollHandler *poll_handler.PollHandler, + serverHandler server_handler.ServerHandler, +) *Routes { + return &Routes{ + authMiddleware: authMiddleware, + jidValidationMiddleware: auth_middleware.NewJIDValidationMiddleware(), + instanceHandler: instanceHandler, + userHandler: userHandler, + sendHandler: sendHandler, + messageHandler: messageHandler, + chatHandler: chatHandler, + groupHandler: groupHandler, + callHandler: callHandler, + communityHandler: communityHandler, + labelHandler: labelHandler, + newsletterHandler: newsletterHandler, + pollHandler: pollHandler, + serverHandler: serverHandler, + } +} diff --git a/sendMessage/handler/send_handler.go b/sendMessage/handler/send_handler.go new file mode 100644 index 00000000..c7b3e984 --- /dev/null +++ b/sendMessage/handler/send_handler.go @@ -0,0 +1,886 @@ +package send_handler + +import ( + "encoding/base64" + "io" + "net/http" + "strconv" + "strings" + + instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" + send_service "github.com/evolution-foundation/evolution-go/pkg/sendMessage/service" + "github.com/gin-gonic/gin" +) + +type SendHandler interface { + SendText(ctx *gin.Context) + SendLink(ctx *gin.Context) + SendMedia(ctx *gin.Context) + SendPoll(ctx *gin.Context) + SendSticker(ctx *gin.Context) + SendLocation(ctx *gin.Context) + SendContact(ctx *gin.Context) + SendButton(ctx *gin.Context) + SendList(ctx *gin.Context) + SendCarousel(ctx *gin.Context) + SendStatusText(ctx *gin.Context) + SendStatusMedia(ctx *gin.Context) + SendForward(ctx *gin.Context) +} + +type sendHandler struct { + sendMessageService send_service.SendService +} + +// Send a text message +// @Summary Send a text message +// @Description Send a text message +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.TextStruct true "Message data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/text [post] +func (s *sendHandler) SendText(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data *send_service.TextStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if data.Text == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "message body is required"}) + return + } + + message, err := s.sendMessageService.SendText(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Forward a message +// @Summary Forward a message +// @Description Forward an existing message to another chat, flagged as forwarded. The `message` field must contain the full waE2E.Message payload of the original message (e.g. `data.Message` as delivered by the webhook). Media is forwarded by reference, without re-upload. +// @Tags Message +// @Accept json +// @Produce json +// @Param message body send_service.ForwardStruct true "Forward data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /message/forward [post] +func (s *sendHandler) SendForward(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data *send_service.ForwardStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if data.Message == nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "message payload is required"}) + return + } + + message, err := s.sendMessageService.SendForward(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Send a link message +// @Summary Send a link message +// @Description Send a link message +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.LinkStruct true "Message data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/link [post] +func (s *sendHandler) SendLink(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data *send_service.LinkStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if data.Text == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "message body is required"}) + return + } + + message, err := s.sendMessageService.SendLink(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Send a media message +// @Summary Send a media message +// @Description Send a media message +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.MediaStruct true "Message data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/media [post] +func (s *sendHandler) SendMedia(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + contentType := ctx.ContentType() + + var data *send_service.MediaStruct + + if strings.HasPrefix(contentType, "multipart/form-data") { + // Handle form-data + number := ctx.PostForm("number") + if number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + mediaType := ctx.PostForm("type") + if mediaType == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "media type is required"}) + return + } + + caption := ctx.PostForm("caption") + filename := ctx.PostForm("filename") + id := ctx.PostForm("id") + delayStr := ctx.PostForm("delay") + delay := int32(0) + if delayStr != "" { + delay64, err := strconv.ParseInt(delayStr, 10, 32) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid delay"}) + return + } + delay = int32(delay64) + } + + mentionAll := ctx.PostForm("mentionAll") == "true" + + var mentionedJID []string + // Accept multiple values (mentionedJid=x&mentionedJid=y) or a single + // comma-separated string (mentionedJid=x,y). + for _, raw := range ctx.PostFormArray("mentionedJid") { + for _, v := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(v); trimmed != "" { + mentionedJID = append(mentionedJID, trimmed) + } + } + } + + var quoted send_service.QuotedStruct + quoted.MessageID = ctx.PostForm("quoted.messageId") + quoted.Participant = ctx.PostForm("quoted.participant") + + // Get file + file, err := ctx.FormFile("file") + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) + return + } + + // Open file + fileData, err := file.Open() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open file"}) + return + } + defer fileData.Close() + fileBytes, err := io.ReadAll(fileData) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot read file"}) + return + } + + // Create MediaStruct + data = &send_service.MediaStruct{ + Number: number, + Type: mediaType, + Caption: caption, + Filename: filename, + Id: id, + Delay: delay, + MentionAll: mentionAll, + MentionedJID: mentionedJID, + Quoted: quoted, + } + + // Pass fileBytes to the send service + message, err := s.sendMessageService.SendMediaFile(data, fileBytes, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) + + } else { + + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if data.Url == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "URL is required"}) + return + } + + if data.Type == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "media type is required"}) + return + } + + var message *send_service.MessageSendStruct + + if !strings.HasPrefix(data.Url, "http://") && !strings.HasPrefix(data.Url, "https://") { + // Treat as base64-encoded media + fileBytes, err := base64.StdEncoding.DecodeString(data.Url) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"}) + return + } + message, err = s.sendMessageService.SendMediaFile(data, fileBytes, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } else { + message, err = s.sendMessageService.SendMediaUrl(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) + } +} + +// Send a poll message +// @Summary Send a poll message +// @Description Send a poll message +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.PollStruct true "Message data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/poll [post] +func (s *sendHandler) SendPoll(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data *send_service.PollStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if data.Question == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "question is required"}) + return + } + + if len(data.Options) < 2 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "minimum 2 options are required"}) + return + } + + message, err := s.sendMessageService.SendPoll(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Send a sticker message +// @Summary Send a sticker message +// @Description Send a sticker message +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.StickerStruct true "Message data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/sticker [post] +func (s *sendHandler) SendSticker(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data *send_service.StickerStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if data.Sticker == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "sticker is required"}) + return + } + + message, err := s.sendMessageService.SendSticker(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Send a location message +// @Summary Send a location message +// @Description Send a location message +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.LocationStruct true "Message data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/location [post] +func (s *sendHandler) SendLocation(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data *send_service.LocationStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if data.Latitude == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "latitude is required"}) + return + } + + if data.Longitude == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "longitude is required"}) + return + } + + if data.Address == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "address is required"}) + return + } + + if data.Name == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + message, err := s.sendMessageService.SendLocation(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Send a contact message +// @Summary Send a contact message +// @Description Send a contact message +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.ContactStruct true "Message data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/contact [post] +func (s *sendHandler) SendContact(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data *send_service.ContactStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if data.Vcard.Phone == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "contact phone number is required"}) + return + } + + if data.Vcard.FullName == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "contact full name is required"}) + return + } + + message, err := s.sendMessageService.SendContact(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Send a button message +// @Summary Send a button message +// @Description Send an interactive message with buttons. Each button has a `type`: `reply`, `copy`, `url`, `call` or `pix`. +// @Description +// @Description Combination rules enforced by the server: +// @Description - Up to 3 `reply` buttons per message. +// @Description - `reply` buttons cannot be mixed with any other type. +// @Description - `pix` button must be sent ALONE (no other button in the same message). +// @Description +// @Description WhatsApp client rendering quirks (NOT enforced by the server, but verified in the field): +// @Description - WhatsApp Web: only `reply`-only messages (up to 3) OR CTAs grouped together (`copy` + `url` + `call`) render correctly. +// @Description - Do NOT mix `reply` with CTA buttons (`copy`/`url`/`call`) — the message will not appear on WhatsApp Web. +// @Description +// @Description Required body fields: `number`, `title`, `description`, `footer`, `buttons`. +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.ButtonStruct true "Message data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/button [post] +func (s *sendHandler) SendButton(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data *send_service.ButtonStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if data.Title == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "title is required"}) + return + } + + if data.Description == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "description is required"}) + return + } + + if data.Footer == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "footer is required"}) + return + } + + message, err := s.sendMessageService.SendButton(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Send a list message +// @Summary Send a list message +// @Description Send an interactive list message (single-select) rendered as a tappable menu. +// @Description +// @Description Required body fields: `number`, `title`, `description`, `footerText`, `buttonText`, `sections`. +// @Description Each section must contain one or more `rows`. When `rowId` is omitted, the server generates a fallback ID. +// @Description When `buttonText` is empty, the server falls back to "Ver Menu". +// @Description +// @Description Uses legacy `ListMessage` format (no ViewOnceMessage wrapper) so it renders on iOS, Android and WhatsApp Web. +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.ListStruct true "Message data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/list [post] +func (s *sendHandler) SendList(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data *send_service.ListStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if data.Title == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "title is required"}) + return + } + + if data.Description == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "description is required"}) + return + } + + if data.FooterText == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "footer is required"}) + return + } + + if data.ButtonText == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "button text is required"}) + return + } + + message, err := s.sendMessageService.SendList(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Send a carousel message +// @Summary Send a carousel message +// @Description Send an interactive carousel (multiple swipeable cards). Each card carries its own image or video, body and optional buttons. +// @Description +// @Description Card button `type` accepted values (case-insensitive, uppercased internally): `REPLY` (default), `URL`, `CALL`, `COPY`. +// @Description The `PIX` button type is NOT supported in carousel cards — use `/send/button` for PIX. +// @Description +// @Description IMPORTANT — `CarouselButtonStruct` is different from the flat button used in `/send/button`: +// @Description - URL button: put the link in the `id` field (NOT in a `url` field). +// @Description - CALL button: put the phone number in the `id` field (NOT in a `phoneNumber` field). +// @Description - COPY button: put the code to be copied in `copyCode`. +// @Description - REPLY button: put the payload/callback ID in `id`. +// @Description +// @Description Per-card combination rules (NOT enforced by the server, but verified in the field): +// @Description - Same WhatsApp Web quirk as `/send/button`: avoid mixing REPLY with CTA buttons (URL/CALL/COPY) in the same card — mixed sets do not render on Web. +// @Description - Stick to either "only REPLY" or "only CTAs grouped together" per card. +// @Description +// @Description Required body fields: `number`, `cards` (at least one). Each card requires `header` + `body`. +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.CarouselStruct true "Message data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/carousel [post] +func (s *sendHandler) SendCarousel(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + var data *send_service.CarouselStruct + err := ctx.ShouldBindBodyWithJSON(&data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Number == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone number is required"}) + return + } + + if len(data.Cards) == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "at least one card is required"}) + return + } + + message, err := s.sendMessageService.SendCarousel(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Send a text status message +// @Summary Send a WhatsApp text status +// @Description Send a WhatsApp text status to status@broadcast +// @Tags Send Message +// @Accept json +// @Produce json +// @Param message body send_service.StatusTextStruct true "Status text data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/status/text [post] +func (s *sendHandler) SendStatusText(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + data := new(send_service.StatusTextStruct) + err := ctx.ShouldBindBodyWithJSON(data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Text == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "text is required"}) + return + } + + message, err := s.sendMessageService.SendStatusText(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +// Send a media status message (image or video) +// @Summary Send a WhatsApp media status (image/video) +// @Description Send an image or video status to status@broadcast. Supports JSON (URL) or multipart/form-data (file upload) +// @Tags Send Message +// @Accept json, multipart/form-data +// @Produce json +// @Param type formData string true "Media type: image or video" +// @Param file formData file false "Media file (for multipart upload)" +// @Param url formData string false "Media URL (for JSON upload)" +// @Param caption formData string false "Caption for the media" +// @Param id formData string false "Custom message ID" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /send/status/media [post] +func (s *sendHandler) SendStatusMedia(ctx *gin.Context) { + getInstance := ctx.MustGet("instance") + + instance, ok := getInstance.(*instance_model.Instance) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"}) + return + } + + contentType := ctx.ContentType() + + data := new(send_service.StatusMediaStruct) + + if strings.HasPrefix(contentType, "multipart/form-data") { + mediaType := ctx.PostForm("type") + if mediaType == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "media type is required"}) + return + } + + if mediaType != "image" && mediaType != "video" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "type must be 'image' or 'video'"}) + return + } + + caption := ctx.PostForm("caption") + id := ctx.PostForm("id") + + file, err := ctx.FormFile("file") + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) + return + } + + fileData, err := file.Open() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open file"}) + return + } + defer fileData.Close() + fileBytes, err := io.ReadAll(fileData) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot read file"}) + return + } + + data = &send_service.StatusMediaStruct{ + Type: mediaType, + Caption: caption, + Id: id, + } + + message, err := s.sendMessageService.SendStatusMediaFile(data, fileBytes, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) + return + } + + err := ctx.ShouldBindBodyWithJSON(data) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if data.Url == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) + return + } + + if data.Type != "image" && data.Type != "video" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "type must be 'image' or 'video'"}) + return + } + + message, err := s.sendMessageService.SendStatusMediaUrl(data, instance) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": message}) +} + +func NewSendHandler( + sendMessageService send_service.SendService, +) SendHandler { + return &sendHandler{ + sendMessageService: sendMessageService, + } +} diff --git a/sendMessage/service/send_service.go b/sendMessage/service/send_service.go new file mode 100644 index 00000000..53bb09b7 --- /dev/null +++ b/sendMessage/service/send_service.go @@ -0,0 +1,3621 @@ +package send_service + +import ( + "bytes" + "context" + crypto_rand "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "mime/multipart" + "net/http" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "github.com/chai2010/webp" + config "github.com/evolution-foundation/evolution-go/pkg/config" + instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" + logger_wrapper "github.com/evolution-foundation/evolution-go/pkg/logger" + "github.com/evolution-foundation/evolution-go/pkg/utils" + whatsmeow_service "github.com/evolution-foundation/evolution-go/pkg/whatsmeow/service" + "github.com/gabriel-vasile/mimetype" + "go.mau.fi/whatsmeow" + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "golang.org/x/net/html" + "google.golang.org/protobuf/proto" +) + +type SendService interface { + SendText(data *TextStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendLink(data *LinkStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendMediaUrl(data *MediaStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendMediaFile(data *MediaStruct, fileData []byte, instance *instance_model.Instance) (*MessageSendStruct, error) + SendPoll(data *PollStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendSticker(data *StickerStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendLocation(data *LocationStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendContact(data *ContactStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendButton(data *ButtonStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendList(data *ListStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendCarousel(data *CarouselStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendStatusText(data *StatusTextStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendStatusMediaUrl(data *StatusMediaStruct, instance *instance_model.Instance) (*MessageSendStruct, error) + SendStatusMediaFile(data *StatusMediaStruct, fileData []byte, instance *instance_model.Instance) (*MessageSendStruct, error) + SendForward(data *ForwardStruct, instance *instance_model.Instance) (*MessageSendStruct, error) +} + +type sendService struct { + clientPointer map[string]*whatsmeow.Client + whatsmeowService whatsmeow_service.WhatsmeowService + config *config.Config + loggerWrapper *logger_wrapper.LoggerManager +} + +type SendDataStruct struct { + Id string + Number string + Delay int32 + MentionAll bool + MentionedJID []string + FormatJid *bool + Quoted QuotedStruct + MediaHandle string + AdditionalNodes *[]waBinary.Node + ForwardingScore *uint32 +} + +type QuotedStruct struct { + MessageID string `json:"messageId"` + Participant string `json:"participant"` +} + +type TextStruct struct { + Number string `json:"number"` + Text string `json:"text"` + Id string `json:"id"` + Delay int32 `json:"delay"` + MentionedJID []string `json:"mentionedJid"` + MentionAll bool `json:"mentionAll"` + FormatJid *bool `json:"formatJid,omitempty"` + Quoted QuotedStruct `json:"quoted"` + ForwardingScore *uint32 `json:"forwardingScore,omitempty"` +} + +// ForwardStruct is the body for POST /message/forward. +// +// Forwards an existing message to another chat, flagged as "forwarded" like +// the official client does. `message` must be the full waE2E.Message payload +// of the original message (e.g. `data.Message` as delivered by the webhook). +// Media is re-sent by reference (mediaKey/directPath), so no re-upload or +// re-download happens. +type ForwardStruct struct { + // Destination phone number or JID (user, group or newsletter). + Number string `json:"number" example:"5582988898565"` + // Full original message payload (waE2E.Message JSON, e.g. webhook data.Message). + Message *waE2E.Message `json:"message"` + // Optional custom message ID for the forwarded copy. + Id string `json:"id,omitempty"` + // Typing delay (milliseconds) applied before sending the message. + Delay int32 `json:"delay,omitempty" example:"1200"` + // If false, skips automatic formatting/validation of `number` into a JID. + FormatJid *bool `json:"formatJid,omitempty"` + // Overrides the computed forwarding score (default: original score + 1). + ForwardingScore *uint32 `json:"forwardingScore,omitempty"` +} + +type LinkStruct struct { + Number string `json:"number"` + Text string `json:"text"` + Title string `json:"title"` + Url string `json:"url"` + Description string `json:"description"` + ImgUrl string `json:"imgUrl"` + Id string `json:"id"` + Delay int32 `json:"delay"` + MentionedJID []string `json:"mentionedJid"` + MentionAll bool `json:"mentionAll"` + FormatJid *bool `json:"formatJid,omitempty"` + Quoted QuotedStruct `json:"quoted"` +} + +type MediaStruct struct { + Number string `json:"number"` + Url string `json:"url"` + Type string `json:"type"` + Caption string `json:"caption"` + Filename string `json:"filename"` + Id string `json:"id"` + Delay int32 `json:"delay"` + MentionedJID []string `json:"mentionedJid"` + MentionAll bool `json:"mentionAll"` + FormatJid *bool `json:"formatJid,omitempty"` + Quoted QuotedStruct `json:"quoted"` + ForwardingScore *uint32 `json:"forwardingScore,omitempty"` +} + +type PollStruct struct { + Id string `json:"id"` + Number string `json:"number"` + Question string `json:"question"` + MaxAnswer int `json:"maxAnswer"` + Options []string `json:"options"` + Delay int32 `json:"delay"` + MentionedJID []string `json:"mentionedJid"` + MentionAll bool `json:"mentionAll"` + FormatJid *bool `json:"formatJid,omitempty"` + Quoted QuotedStruct `json:"quoted"` +} + +type StickerStruct struct { + Number string `json:"number"` + Sticker string `json:"sticker"` + Id string `json:"id"` + Delay int32 `json:"delay"` + MentionedJID []string `json:"mentionedJid"` + MentionAll bool `json:"mentionAll"` + FormatJid *bool `json:"formatJid,omitempty"` + Quoted QuotedStruct `json:"quoted"` +} + +type LocationStruct struct { + Number string `json:"number"` + Id string `json:"id"` + Name string `json:"name"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Address string `json:"address"` + Delay int32 `json:"delay"` + MentionedJID []string `json:"mentionedJid"` + MentionAll bool `json:"mentionAll"` + FormatJid *bool `json:"formatJid,omitempty"` + Quoted QuotedStruct `json:"quoted"` +} + +type ContactStruct struct { + Number string `json:"number"` + Id string `json:"id"` + Vcard utils.VCardStruct `json:"vcard"` + Delay int32 `json:"delay"` + MentionedJID []string `json:"mentionedJid"` + MentionAll bool `json:"mentionAll"` + FormatJid *bool `json:"formatJid,omitempty"` + Quoted QuotedStruct `json:"quoted"` +} + +// Button represents a single interactive button for /send/button. +// The `type` field drives which of the other fields are used: +// - reply: uses `displayText` + `id` +// - copy: uses `displayText` + `copyCode` +// - url: uses `displayText` + `url` +// - call: uses `displayText` + `phoneNumber` +// - pix: uses `currency` + `name` + `keyType` + `key` (must be sent alone) +type Button struct { + // Button kind. One of: reply, copy, url, call, pix. + Type string `json:"type" enums:"reply,copy,url,call,pix" example:"reply"` + // Label rendered inside the button (reply / copy / url / call). Ignored for pix. + DisplayText string `json:"displayText" example:"Quero saber mais"` + // Callback payload for `reply` or code-to-copy internal id for `copy`. + Id string `json:"id" example:"btn_info"` + // Code placed in the clipboard when type=copy. + CopyCode string `json:"copyCode,omitempty" example:"PROMO2026"` + // Target URL when type=url. + URL string `json:"url,omitempty" example:"https://evolutionapi.com"` + // Destination phone number (E.164) when type=call. + PhoneNumber string `json:"phoneNumber,omitempty" example:"+5582988898565"` + // ISO currency code for type=pix (e.g. BRL). + Currency string `json:"currency,omitempty" example:"BRL"` + // Merchant display name shown on the Pix sheet. + Name string `json:"name,omitempty" example:"Minha Loja"` + // Pix key type. One of: phone, email, cpf, cnpj, random. + KeyType string `json:"keyType,omitempty" enums:"phone,email,cpf,cnpj,random" example:"cpf"` + // Pix key value matching the keyType. + Key string `json:"key,omitempty" example:"12345678900"` +} + +// ButtonStruct is the body for POST /send/button. +// +// Server-side validation: +// - up to 3 `reply` buttons per message; +// - `reply` cannot be mixed with any other type; +// - `pix` must be the only button in the message. +// +// WhatsApp Web rendering quirk (NOT enforced by the server): +// - mixing `reply` with CTA buttons (copy/url/call) makes the message invisible on WhatsApp Web; +// - safe combinations: only-reply (up to 3) OR grouped CTAs (copy + url + call). +type ButtonStruct struct { + // Destination phone number. + Number string `json:"number" example:"5582988898565"` + // Header title (required). + Title string `json:"title" example:"Oferta especial"` + // Body description text (required). + Description string `json:"description" example:"Confira as condicoes abaixo"` + // Footer text (required). + Footer string `json:"footer" example:"Evolution GO"` + // Buttons array. See combination rules on the parent type description. + Buttons []Button `json:"buttons"` + // Typing delay (milliseconds) applied before sending the message. + Delay int32 `json:"delay,omitempty" example:"1200"` + // JIDs to mention inside the body text. + MentionedJID []string `json:"mentionedJid,omitempty"` + // Mention every participant (groups only). + MentionAll bool `json:"mentionAll,omitempty"` + // If false, skips automatic formatting/validation of `number` into a JID. + FormatJid *bool `json:"formatJid,omitempty"` + // Quoted (reply-to) context. + Quoted QuotedStruct `json:"quoted,omitempty"` + // Optional image URL used as header for reply-only buttons. + ImageUrl string `json:"imageUrl,omitempty"` + // Optional video URL used as header for reply-only buttons. + VideoUrl string `json:"videoUrl,omitempty"` +} + +// Row is a selectable item inside a list Section. +type Row struct { + // Row main label. + Title string `json:"title" example:"Plano Basico"` + // Optional secondary line below the title. + Description string `json:"description,omitempty" example:"R$ 29,90/mes"` + // Callback payload returned when the user taps the row. Auto-generated if empty. + RowId string `json:"rowId,omitempty" example:"plan_basic"` +} + +// Section groups related Rows under an optional title. +type Section struct { + // Section heading (optional; rendered as a group separator). + Title string `json:"title,omitempty" example:"Planos"` + // Rows inside this section. + Rows []Row `json:"rows"` +} + +// ListStruct is the body for POST /send/list. +// +// Renders as a single-select menu (legacy ListMessage format — compatible with iOS, Android and WhatsApp Web). +type ListStruct struct { + // Destination phone number. + Number string `json:"number" example:"5582988898565"` + // Header title (required). + Title string `json:"title" example:"Nossos planos"` + // Body description text (required). + Description string `json:"description" example:"Escolha o plano ideal para voce"` + // Label of the button that opens the list. Defaults to "Ver Menu" when empty. + ButtonText string `json:"buttonText" example:"Abrir cardapio"` + // Footer text (required). + FooterText string `json:"footerText" example:"Evolution GO"` + // Sections with rows. At least one section with one row is required. + Sections []Section `json:"sections"` + // Typing delay (milliseconds) applied before sending the message. + Delay int32 `json:"delay,omitempty" example:"1200"` + // JIDs to mention inside the body text. + MentionedJID []string `json:"mentionedJid,omitempty"` + // Mention every participant (groups only). + MentionAll bool `json:"mentionAll,omitempty"` + // If false, skips automatic formatting/validation of `number` into a JID. + FormatJid *bool `json:"formatJid,omitempty"` + // Quoted (reply-to) context. + Quoted QuotedStruct `json:"quoted,omitempty"` +} + +// CarouselButtonStruct is a button attached to a single carousel card. +// +// IMPORTANT — this struct is different from `Button` (used in /send/button): +// it has NO dedicated `url` or `phoneNumber` fields. For URL and CALL buttons +// you must put the link / phone number in the `id` field. +// +// - REPLY (default): uses `displayText` + `id` as callback payload. +// - URL: uses `displayText` + `id` (put the URL here). +// - CALL: uses `displayText` + `id` (put the phone number here). +// - COPY: uses `displayText` + `copyCode`. +// +// PIX buttons are NOT supported inside carousel cards — use /send/button instead. +// +// WhatsApp Web rendering quirk (NOT enforced by the server): +// avoid mixing REPLY with CTA buttons (URL/CALL/COPY) in the same card — +// mixed sets do not render on WhatsApp Web. Prefer only-REPLY or only-CTAs per card. +type CarouselButtonStruct struct { + // Button kind (case-insensitive). One of: REPLY (default), URL, CALL, COPY. + Type string `json:"type" enums:"REPLY,URL,CALL,COPY,reply,url,call,copy" example:"REPLY"` + // Label rendered inside the button. + DisplayText string `json:"displayText" example:"Quero saber mais"` + // Context-dependent: REPLY payload, URL target (type=URL) or phone number (type=CALL). + Id string `json:"id" example:"card1_info"` + // Code placed in the clipboard when type=COPY. + CopyCode string `json:"copyCode,omitempty" example:"PROMO2026"` +} + +// CarouselCardHeaderStruct is the top area of a carousel card. +// Either `imageUrl` OR `videoUrl` may be provided (image takes precedence when both are set). +type CarouselCardHeaderStruct struct { + // Optional visible title above the media. + Title string `json:"title,omitempty" example:"Oferta do dia"` + // Optional subtitle rendered below the title. + Subtitle string `json:"subtitle,omitempty" example:"Somente hoje"` + // Public URL to an image. Downloaded, uploaded to WhatsApp servers and used as card media. + ImageUrl string `json:"imageUrl,omitempty" example:"https://picsum.photos/seed/card1/600/400"` + // Public URL to a video. Used only when `imageUrl` is empty. + VideoUrl string `json:"videoUrl,omitempty"` +} + +// CarouselCardBodyStruct is the text area of a carousel card. +type CarouselCardBodyStruct struct { + // Main text of the card. + Text string `json:"text" example:"Card 1 - Oferta especial"` +} + +// CarouselCardStruct is a single card inside a carousel message. +// Each card requires at least `header` + `body`. +type CarouselCardStruct struct { + // Card header (media + title/subtitle). + Header CarouselCardHeaderStruct `json:"header"` + // Card body text (required). + Body CarouselCardBodyStruct `json:"body"` + // Optional footer rendered under the body. + Footer string `json:"footer,omitempty" example:"Por tempo limitado"` + // Buttons shown on the card. See CarouselButtonStruct for combination rules. + Buttons []CarouselButtonStruct `json:"buttons,omitempty"` +} + +// CarouselStruct is the body for POST /send/carousel. +// +// Sends an interactive carousel of swipeable cards. At least one card is required. +// Each card must have `header` + `body`; button rules are described on CarouselButtonStruct. +type CarouselStruct struct { + // Destination phone number. + Number string `json:"number" example:"5582988898565"` + // Optional message body shown above the cards. + Body string `json:"body,omitempty" example:"Confira nossas novidades!"` + // Optional message footer shown below the cards. + Footer string `json:"footer,omitempty" example:"Evolution GO"` + // Typing delay (milliseconds) applied before sending the message. + Delay int32 `json:"delay,omitempty" example:"1200"` + // If false, skips automatic formatting/validation of `number` into a JID. + FormatJid *bool `json:"formatJid,omitempty"` + // Quoted (reply-to) context. + Quoted QuotedStruct `json:"quoted,omitempty"` + // Cards displayed in order. At least one card is required. + Cards []CarouselCardStruct `json:"cards"` +} + +type StatusTextStruct struct { + Text string `json:"text"` + Id string `json:"id"` +} + +type StatusMediaStruct struct { + Type string `json:"type"` + Url string `json:"url"` + Caption string `json:"caption"` + Id string `json:"id"` +} + +type MessageSendStruct struct { + Info types.MessageInfo + Message *waE2E.Message + MessageContextInfo *waE2E.ContextInfo +} + +func (s *sendService) ensureClientConnected(instanceId string) (*whatsmeow.Client, error) { + client := s.clientPointer[instanceId] + s.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Checking client connection status - Client exists: %v", instanceId, client != nil) + + if client == nil { + s.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] No client found, attempting to start new instance", instanceId) + err := s.whatsmeowService.StartInstance(instanceId) + if err != nil { + s.loggerWrapper.GetLogger(instanceId).LogError("[%s] Failed to start instance: %v", instanceId, err) + return nil, errors.New("no active session found") + } + + s.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Instance started, waiting 2 seconds...", instanceId) + time.Sleep(2 * time.Second) + + client = s.clientPointer[instanceId] + s.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Checking new client - Exists: %v, Connected: %v", + instanceId, + client != nil, + client != nil && client.IsConnected()) + + if client == nil || !client.IsConnected() { + s.loggerWrapper.GetLogger(instanceId).LogError("[%s] New client validation failed - Exists: %v, Connected: %v", + instanceId, + client != nil, + client != nil && client.IsConnected()) + return nil, errors.New("no active session found") + } + } else if !client.IsConnected() { + s.loggerWrapper.GetLogger(instanceId).LogError("[%s] Existing client is disconnected - Connected status: %v", + instanceId, + client.IsConnected()) + return nil, errors.New("client disconnected") + } + + s.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Client successfully validated - Connected: %v", instanceId, client.IsConnected()) + return client, nil +} + +// ensureClientConnectedWithRetry attempts to ensure client connection with automatic reconnection and retry +func (s *sendService) ensureClientConnectedWithRetry(instanceId string, maxRetries int) (*whatsmeow.Client, error) { + for attempt := 1; attempt <= maxRetries; attempt++ { + s.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Connection attempt %d/%d", instanceId, attempt, maxRetries) + + client, err := s.ensureClientConnected(instanceId) + if err == nil { + return client, nil + } + + // Check if it's a disconnection error that we can retry + if err.Error() == "client disconnected" || err.Error() == "no active session found" { + s.loggerWrapper.GetLogger(instanceId).LogWarn("[%s] Client disconnected on attempt %d/%d, attempting reconnection...", instanceId, attempt, maxRetries) + + // Attempt to reconnect the client + reconnectErr := s.whatsmeowService.ReconnectClient(instanceId) + if reconnectErr != nil { + s.loggerWrapper.GetLogger(instanceId).LogError("[%s] Failed to reconnect client on attempt %d: %v", instanceId, attempt, reconnectErr) + } else { + s.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Reconnection initiated on attempt %d, waiting 3 seconds...", instanceId, attempt) + time.Sleep(3 * time.Second) + } + + // If this is not the last attempt, continue to retry + if attempt < maxRetries { + waitTime := time.Duration(attempt*2) * time.Second // Progressive backoff + s.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Waiting %v before retry attempt %d", instanceId, waitTime, attempt+1) + time.Sleep(waitTime) + continue + } + } + + // If it's the last attempt or a non-retryable error, return the error + s.loggerWrapper.GetLogger(instanceId).LogError("[%s] Failed to ensure client connection after %d attempts: %v", instanceId, attempt, err) + return nil, err + } + + return nil, fmt.Errorf("failed to connect client after %d attempts", maxRetries) +} + +func validateMessageFields(phone string, formatJid *bool, messageID *string, participant *string) (types.JID, error) { + // Apply formatting if formatJid is true (default) + shouldFormat := true // Default value + if formatJid != nil { + shouldFormat = *formatJid + } + + var finalPhone string + if shouldFormat { + // Extract raw number if it's already a JID, then apply CreateJID formatting + rawNumber := phone + if strings.Contains(phone, "@s.whatsapp.net") { + rawNumber = strings.Split(phone, "@")[0] + } + + normalizedJID, err := utils.CreateJID(rawNumber) + if err != nil { + // If CreateJID fails, try with ParseJID as fallback + recipient, ok := utils.ParseJID(phone) + if !ok { + return types.NewJID("", types.DefaultUserServer), fmt.Errorf("could not parse phone: %s", phone) + } + finalPhone = recipient.String() + } else { + finalPhone = normalizedJID + } + } else { + // Use phone as received without formatting + finalPhone = phone + } + + recipient, ok := utils.ParseJID(finalPhone) + if !ok { + return types.NewJID("", types.DefaultUserServer), errors.New("could not parse formatted phone") + } + + if messageID != nil { + if participant == nil { + return types.NewJID("", types.DefaultUserServer), errors.New("missing Participant in ContextInfo") + } + } + + if participant != nil { + if messageID == nil { + return types.NewJID("", types.DefaultUserServer), errors.New("missing StanzaId in ContextInfo") + } + } + + return recipient, nil +} + +// validateAndCheckUserExists validates message fields and checks if the user exists on WhatsApp +// Now uses the new approach: CheckUser with formatJid=false by default, and uses remoteJID for messaging +func (s *sendService) validateAndCheckUserExists(phone string, formatJid *bool, messageID *string, participant *string, instance *instance_model.Instance) (types.JID, error) { + // Skip WhatsApp check if disabled in config + if !s.config.CheckUserExists { + s.loggerWrapper.GetLogger(instance.Id).LogDebug("[%s] User existence check disabled by configuration", instance.Id) + // Use original validation logic when check is disabled + return validateMessageFields(phone, formatJid, messageID, participant) + } + + // Skip WhatsApp check for group messages, broadcast, newsletter, and LID + if strings.Contains(phone, "@g.us") || strings.Contains(phone, "@broadcast") || strings.Contains(phone, "@newsletter") || strings.Contains(phone, "@lid") { + return validateMessageFields(phone, formatJid, messageID, participant) + } + + // Get the client to check if user exists on WhatsApp + client, err := s.ensureClientConnected(instance.Id) + if err != nil { + return types.NewJID("", types.DefaultUserServer), fmt.Errorf("failed to connect client: %v", err) + } + + // Use CheckUser approach: formatJid=false by default + formatJidForCheck := false + + // First attempt with formatJid=false + remoteJID, found, err := s.checkSingleUserExists(client, phone, formatJidForCheck, instance.Id) + if err != nil { + s.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] Failed to check user existence: %v", instance.Id, err) + // Continue with sending even if check fails (network issues, etc.) + return validateMessageFields(phone, formatJid, messageID, participant) + } + + // If not found with formatJid=false, try with formatJid=true as fallback + if !found { + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] User not found with formatJid=false, trying with formatJid=true", instance.Id) + remoteJIDRetry, foundRetry, errRetry := s.checkSingleUserExists(client, phone, true, instance.Id) + if errRetry == nil && foundRetry { + remoteJID = remoteJIDRetry + found = foundRetry + } + } + + if !found { + return types.NewJID("", types.DefaultUserServer), fmt.Errorf("number %s is not registered on WhatsApp", phone) + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Number %s verified as valid WhatsApp user, using remoteJID: %s", instance.Id, phone, remoteJID) + + // Validate the remoteJID with formatJid=false for message sending + formatJidFalse := false + return validateMessageFields(remoteJID, &formatJidFalse, messageID, participant) +} + +// checkSingleUserExists checks if a single user exists on WhatsApp with the specified formatJid setting +// Returns: remoteJID, found, error +func (s *sendService) checkSingleUserExists(client *whatsmeow.Client, phone string, formatJid bool, instanceId string) (string, bool, error) { + phoneNumbers, err := utils.PrepareNumbersForWhatsAppCheck([]string{phone}, &formatJid) + if err != nil { + return "", false, fmt.Errorf("failed to prepare number for WhatsApp check: %v", err) + } + + // Check if the number exists on WhatsApp + resp, err := client.IsOnWhatsApp(context.Background(), phoneNumbers) + if err != nil { + return "", false, fmt.Errorf("failed to check if number %s exists on WhatsApp: %v", phoneNumbers[0], err) + } + + // Verify if the number was found + if len(resp) == 0 { + return "", false, fmt.Errorf("number %s not found in WhatsApp response", phoneNumbers[0]) + } + + // Check if the first result indicates the number is on WhatsApp + if !resp[0].IsIn { + return "", false, nil // Not an error, just not found + } + + // Return the remoteJID from WhatsApp's response + remoteJID := fmt.Sprintf("%v", resp[0].JID) + return remoteJID, true, nil +} + +func findURL(text string) string { + urlRegex := `http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+` + re := regexp.MustCompile(urlRegex) + urls := re.FindAllString(text, -1) + if len(urls) > 0 { + return urls[0] + } + return "" +} + +func (s *sendService) SendText(data *TextStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + return s.sendTextWithRetry(data, instance, 3) // 3 tentativas máximas +} + +func (s *sendService) sendTextWithRetry(data *TextStruct, instance *instance_model.Instance, maxRetries int) (*MessageSendStruct, error) { + for attempt := 1; attempt <= maxRetries; attempt++ { + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendText attempt %d/%d", instance.Id, attempt, maxRetries) + + _, err := s.ensureClientConnectedWithRetry(instance.Id, 2) + if err != nil { + if attempt == maxRetries { + return nil, err + } + continue + } + + msg := &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: &data.Text, + }, + } + + message, err := s.SendMessage(instance, msg, "ExtendedTextMessage", &SendDataStruct{ + Id: data.Id, + Number: data.Number, + Quoted: data.Quoted, + Delay: data.Delay, + MentionAll: data.MentionAll, + MentionedJID: data.MentionedJID, + FormatJid: data.FormatJid, + ForwardingScore: data.ForwardingScore, + }) + + if err != nil { + // Check if it's a client disconnection error + if strings.Contains(err.Error(), "client disconnected") || strings.Contains(err.Error(), "no active session") { + s.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SendText failed due to disconnection on attempt %d/%d: %v", instance.Id, attempt, maxRetries, err) + if attempt < maxRetries { + waitTime := time.Duration(attempt) * time.Second + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Waiting %v before retry", instance.Id, waitTime) + time.Sleep(waitTime) + continue + } + } + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendText successful on attempt %d", instance.Id, attempt) + return message, nil + } + + return nil, fmt.Errorf("failed to send text after %d attempts", maxRetries) +} + +// forwardableContextInfo returns the ContextInfo of whichever sub-message is +// populated, or nil. Used to read the original ForwardingScore before the +// central SendMessage replaces the ContextInfo. +func forwardableContextInfo(msg *waE2E.Message) *waE2E.ContextInfo { + if msg == nil { + return nil + } + if msg.DeviceSentMessage.GetMessage() != nil { + msg = msg.DeviceSentMessage.GetMessage() + } + if msg.EphemeralMessage.GetMessage() != nil { + msg = msg.EphemeralMessage.GetMessage() + } + switch { + case msg.ExtendedTextMessage != nil: + return msg.ExtendedTextMessage.GetContextInfo() + case msg.ImageMessage != nil: + return msg.ImageMessage.GetContextInfo() + case msg.VideoMessage != nil: + return msg.VideoMessage.GetContextInfo() + case msg.PtvMessage != nil: + return msg.PtvMessage.GetContextInfo() + case msg.AudioMessage != nil: + return msg.AudioMessage.GetContextInfo() + case msg.DocumentMessage != nil: + return msg.DocumentMessage.GetContextInfo() + case msg.DocumentWithCaptionMessage.GetMessage().GetDocumentMessage() != nil: + return msg.DocumentWithCaptionMessage.GetMessage().GetDocumentMessage().GetContextInfo() + case msg.StickerMessage != nil: + return msg.StickerMessage.GetContextInfo() + case msg.LocationMessage != nil: + return msg.LocationMessage.GetContextInfo() + case msg.ContactMessage != nil: + return msg.ContactMessage.GetContextInfo() + case msg.PollCreationMessage != nil: + return msg.PollCreationMessage.GetContextInfo() + case msg.PollCreationMessageV2 != nil: + return msg.PollCreationMessageV2.GetContextInfo() + case msg.PollCreationMessageV3 != nil: + return msg.PollCreationMessageV3.GetContextInfo() + case msg.ListMessage != nil: + return msg.ListMessage.GetContextInfo() + case msg.ButtonsMessage != nil: + return msg.ButtonsMessage.GetContextInfo() + case msg.InteractiveMessage != nil: + return msg.InteractiveMessage.GetContextInfo() + } + return nil +} + +// normalizeForwardMessage clones and sanitizes the original payload so it can +// be re-sent to another chat: unwraps device-sent/ephemeral envelopes, folds +// Conversation and poll V2/V3 variants into the fields the central +// SendMessage switch handles, and drops per-message crypto/metadata that must +// not be replayed. +func normalizeForwardMessage(src *waE2E.Message) (*waE2E.Message, error) { + if src == nil { + return nil, errors.New("message payload is required") + } + + msg := proto.Clone(src).(*waE2E.Message) + + if msg.DeviceSentMessage.GetMessage() != nil { + msg = msg.DeviceSentMessage.Message + } + if msg.EphemeralMessage.GetMessage() != nil { + msg = msg.EphemeralMessage.Message + } + + if msg.ViewOnceMessage != nil || msg.ViewOnceMessageV2 != nil || msg.ViewOnceMessageV2Extension != nil { + return nil, errors.New("view-once messages cannot be forwarded") + } + + if msg.GetConversation() != "" && msg.ExtendedTextMessage == nil { + msg.ExtendedTextMessage = &waE2E.ExtendedTextMessage{Text: proto.String(msg.GetConversation())} + } + msg.Conversation = nil + + // Poll V2/V3 share the PollCreationMessage type; fold them into the field + // the central SendMessage switch handles. + if msg.PollCreationMessage == nil { + if msg.PollCreationMessageV3 != nil { + msg.PollCreationMessage = msg.PollCreationMessageV3 + } else if msg.PollCreationMessageV2 != nil { + msg.PollCreationMessage = msg.PollCreationMessageV2 + } + } + msg.PollCreationMessageV2 = nil + msg.PollCreationMessageV3 = nil + + msg.MessageContextInfo = nil + msg.SenderKeyDistributionMessage = nil + + return msg, nil +} + +// forwardMessageType maps the populated sub-message to the messageType label +// used by the central SendMessage switch. +func forwardMessageType(msg *waE2E.Message) (string, error) { + switch { + case msg.ExtendedTextMessage != nil: + return "ExtendedTextMessage", nil + case msg.ImageMessage != nil: + return "ImageMessage", nil + case msg.VideoMessage != nil: + return "VideoMessage", nil + case msg.PtvMessage != nil: + return "PtvMessage", nil + case msg.AudioMessage != nil: + return "AudioMessage", nil + case msg.DocumentMessage != nil, + msg.DocumentWithCaptionMessage.GetMessage().GetDocumentMessage() != nil: + return "DocumentMessage", nil + case msg.StickerMessage != nil: + return "StickerMessage", nil + case msg.LocationMessage != nil: + return "LocationMessage", nil + case msg.ContactMessage != nil: + return "ContactMessage", nil + case msg.PollCreationMessage != nil: + return "PollCreationMessage", nil + case msg.ListMessage != nil: + return "ListMessage", nil + case msg.ButtonsMessage != nil: + return "ButtonsMessage", nil + case msg.InteractiveMessage != nil: + return "InteractiveMessage", nil + } + return "", errors.New("unsupported message type for forwarding") +} + +func (s *sendService) SendForward(data *ForwardStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + return s.sendForwardWithRetry(data, instance, 3) +} + +func (s *sendService) sendForwardWithRetry(data *ForwardStruct, instance *instance_model.Instance, maxRetries int) (*MessageSendStruct, error) { + // WhatsApp increments the score on each hop; any value > 0 renders the + // "forwarded" label on the recipient side. + score := uint32(1) + if origCtx := forwardableContextInfo(data.Message); origCtx.GetForwardingScore() > 0 { + score = origCtx.GetForwardingScore() + 1 + } + if data.ForwardingScore != nil && *data.ForwardingScore > 0 { + score = *data.ForwardingScore + } + + for attempt := 1; attempt <= maxRetries; attempt++ { + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendForward attempt %d/%d", instance.Id, attempt, maxRetries) + + _, err := s.ensureClientConnectedWithRetry(instance.Id, 2) + if err != nil { + if attempt == maxRetries { + return nil, err + } + continue + } + + // Clone per attempt so a retry never re-sends a payload mutated by the + // previous attempt. + msg, err := normalizeForwardMessage(data.Message) + if err != nil { + return nil, err + } + + messageType, err := forwardMessageType(msg) + if err != nil { + return nil, err + } + + // The central SendMessage only applies ForwardingScore to sub-messages + // whose ContextInfo it (re)creates; list/button/interactive keep their + // original ContextInfo there, so stamp those here (also stripping stale + // quotes/mentions from the source chat). + switch messageType { + case "ListMessage", "ButtonsMessage", "InteractiveMessage": + forwardedCtx := &waE2E.ContextInfo{ + ForwardingScore: proto.Uint32(score), + IsForwarded: proto.Bool(true), + } + if msg.ListMessage != nil { + msg.ListMessage.ContextInfo = forwardedCtx + } + if msg.ButtonsMessage != nil { + msg.ButtonsMessage.ContextInfo = forwardedCtx + } + if msg.InteractiveMessage != nil { + msg.InteractiveMessage.ContextInfo = forwardedCtx + } + } + + message, err := s.SendMessage(instance, msg, messageType, &SendDataStruct{ + Id: data.Id, + Number: data.Number, + Delay: data.Delay, + FormatJid: data.FormatJid, + ForwardingScore: proto.Uint32(score), + }) + + if err != nil { + if strings.Contains(err.Error(), "client disconnected") || strings.Contains(err.Error(), "no active session") { + s.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SendForward failed due to disconnection on attempt %d/%d: %v", instance.Id, attempt, maxRetries, err) + if attempt < maxRetries { + waitTime := time.Duration(attempt) * time.Second + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Waiting %v before retry", instance.Id, waitTime) + time.Sleep(waitTime) + continue + } + } + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendForward successful on attempt %d", instance.Id, attempt) + return message, nil + } + + return nil, fmt.Errorf("failed to forward message after %d attempts", maxRetries) +} + +func fetchLinkMetadata(url string) (string, string, string, error) { + resp, err := http.Get(url) + if err != nil { + return "", "", "", err + } + defer resp.Body.Close() + + doc, err := html.Parse(resp.Body) + if err != nil { + return "", "", "", err + } + + var title, description, imgURL string + + var f func(*html.Node) + f = func(n *html.Node) { + if n.Type == html.ElementNode { + if n.Data == "title" && n.FirstChild != nil { + title = n.FirstChild.Data + } + if n.Data == "meta" { + var property, content string + for _, attr := range n.Attr { + if attr.Key == "property" || attr.Key == "name" { + property = attr.Val + } + if attr.Key == "content" { + content = attr.Val + } + } + + if (property == "description" || property == "og:description") && content != "" { + description = content + } + + if property == "og:image" && content != "" { + imgURL = content + } + } + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + + f(doc) + + return title, description, imgURL, nil +} + +func (s *sendService) SendLink(data *LinkStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + return s.sendLinkWithRetry(data, instance, 3) +} + +func (s *sendService) sendLinkWithRetry(data *LinkStruct, instance *instance_model.Instance, maxRetries int) (*MessageSendStruct, error) { + for attempt := 1; attempt <= maxRetries; attempt++ { + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendLink attempt %d/%d", instance.Id, attempt, maxRetries) + + _, err := s.ensureClientConnectedWithRetry(instance.Id, 2) + if err != nil { + if attempt == maxRetries { + return nil, err + } + continue + } + + matchedText := findURL(data.Text) + + if matchedText != "" { + title, description, imgUrl, err := fetchLinkMetadata(matchedText) + if err != nil { + if attempt == maxRetries { + return nil, err + } + continue + } + + data.Title = title + data.Description = description + data.ImgUrl = imgUrl + } + + var fileData []byte + if data.ImgUrl != "" { + resp, err := http.Get(data.ImgUrl) + if err != nil { + if attempt == maxRetries { + return nil, err + } + continue + } + defer resp.Body.Close() + fileData, _ = io.ReadAll(resp.Body) + } + + previewType := waE2E.ExtendedTextMessage_VIDEO + msg := &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: &data.Text, + Title: &data.Title, + MatchedText: &matchedText, + JPEGThumbnail: fileData, + Description: &data.Description, + PreviewType: &previewType, + }, + } + + message, err := s.SendMessage(instance, msg, "ExtendedTextMessage", &SendDataStruct{ + Id: data.Id, + Number: data.Number, + Quoted: data.Quoted, + Delay: data.Delay, + MentionAll: data.MentionAll, + MentionedJID: data.MentionedJID, + FormatJid: data.FormatJid, + }) + + if err != nil { + // Check if it's a client disconnection error + if strings.Contains(err.Error(), "client disconnected") || strings.Contains(err.Error(), "no active session") { + s.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SendLink failed due to disconnection on attempt %d/%d: %v", instance.Id, attempt, maxRetries, err) + if attempt < maxRetries { + waitTime := time.Duration(attempt) * time.Second + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Waiting %v before retry", instance.Id, waitTime) + time.Sleep(waitTime) + continue + } + } + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendLink successful on attempt %d", instance.Id, attempt) + return message, nil + } + + return nil, fmt.Errorf("failed to send link after %d attempts", maxRetries) +} + +type ConvertAudio struct { + Url string `json:"url,omitempty"` + Base64 string `json:"base64,omitempty"` +} + +type ApiResponse struct { + Duration int `json:"duration"` + Audio string `json:"audio"` +} + +func convertAudioWithApi(apiUrl string, apiKey string, convertData ConvertAudio) ([]byte, int, error) { + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + // Adiciona o campo "url" ao form-data se a URL for fornecida + if convertData.Url != "" { + err := writer.WriteField("url", convertData.Url) + if err != nil { + return nil, 0, fmt.Errorf("erro ao adicionar a URL no form-data: %v", err) + } + } + + // Adiciona o campo "base64" ao form-data se a string base64 for fornecida + if convertData.Base64 != "" { + err := writer.WriteField("base64", convertData.Base64) + if err != nil { + return nil, 0, fmt.Errorf("erro ao adicionar o base64 no form-data: %v", err) + } + } + + // Fecha o writer multipart + err := writer.Close() + if err != nil { + return nil, 0, fmt.Errorf("erro ao finalizar o form-data: %v", err) + } + + req, err := http.NewRequest("POST", apiUrl, &requestBody) + if err != nil { + return nil, 0, fmt.Errorf("erro ao criar a requisição: %v", err) + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("apikey", apiKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("erro ao enviar a requisição: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, 0, fmt.Errorf("erro ao ler a resposta: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("requisição falhou com status: %d, resposta: %s", resp.StatusCode, string(body)) + } + + var apiResponse ApiResponse + err = json.Unmarshal(body, &apiResponse) + if err != nil { + return nil, 0, fmt.Errorf("erro ao deserializar a resposta: %v", err) + } + + base64ToBytes, err := base64.StdEncoding.DecodeString(apiResponse.Audio) + if err != nil { + return nil, 0, fmt.Errorf("erro ao decodificar o áudio: %v", err) + } + + return base64ToBytes, apiResponse.Duration, nil +} + +func convertAudioToOpusWithDuration(inputData []byte) ([]byte, int, error) { + cmd := exec.Command("ffmpeg", "-i", "pipe:0", + "-f", + "ogg", + "-vn", + "-c:a", + "libopus", + "-avoid_negative_ts", + "make_zero", + "-b:a", + "128k", + "-ar", + "48000", + "-ac", + "1", + "-write_xing", + "0", + "-compression_level", + "10", + "-application", + "voip", + "-fflags", + "+bitexact", + "-flags", + "+bitexact", + "-id3v2_version", + "0", + "-map_metadata", + "-1", + "-map_chapters", + "-1", + "-write_bext", + "0", + "pipe:1", + ) + + var outBuffer bytes.Buffer + var errBuffer bytes.Buffer + + cmd.Stdin = bytes.NewReader(inputData) + cmd.Stdout = &outBuffer + cmd.Stderr = &errBuffer + + err := cmd.Run() + if err != nil { + return nil, 0, fmt.Errorf("error during conversion: %v, details: %s", err, errBuffer.String()) + } + + convertedData := outBuffer.Bytes() + + outputText := errBuffer.String() + + splitTime := strings.Split(outputText, "time=") + + if len(splitTime) < 2 { + return nil, 0, errors.New("duração não encontrada") + } + + // Use the last occurrence of time= in case there are multiple + timeString := splitTime[len(splitTime)-1] + + re := regexp.MustCompile(`(\d+):(\d+):(\d+\.\d+)`) + matches := re.FindStringSubmatch(timeString) + if len(matches) != 4 { + return nil, 0, errors.New("formato de duração não encontrado") + } + + hours, _ := strconv.ParseFloat(matches[1], 64) + minutes, _ := strconv.ParseFloat(matches[2], 64) + seconds, _ := strconv.ParseFloat(matches[3], 64) + duration := int(hours*3600 + minutes*60 + seconds) + + return convertedData, duration, nil +} + +func (s *sendService) SendMediaFile(data *MediaStruct, fileData []byte, instance *instance_model.Instance) (*MessageSendStruct, error) { + return s.sendMediaFileWithRetry(data, fileData, instance, 3) +} + +func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte, instance *instance_model.Instance, maxRetries int) (*MessageSendStruct, error) { + for attempt := 1; attempt <= maxRetries; attempt++ { + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendMediaFile attempt %d/%d", instance.Id, attempt, maxRetries) + + client, err := s.ensureClientConnectedWithRetry(instance.Id, 2) + if err != nil { + if attempt == maxRetries { + return nil, err + } + continue + } + + mime, _ := mimetype.DetectReader(bytes.NewReader(fileData)) + mimeType := mime.String() + + var uploadType whatsmeow.MediaType + var duration int + + switch data.Type { + case "image": + if mimeType != "image/jpeg" && mimeType != "image/png" && mimeType != "image/webp" { + errMsg := fmt.Sprintf("Invalid file format: '%s'. Only 'image/jpeg', 'image/png' and 'image/webp' are accepted", mimeType) + return nil, errors.New(errMsg) + } + if mimeType == "image/webp" { + mimeType = "image/jpeg" + } + uploadType = whatsmeow.MediaImage + case "video": + if mimeType != "video/mp4" { + errMsg := fmt.Sprintf("Invalid file format: '%s'. Only 'video/mp4' is accepted", mimeType) + return nil, errors.New(errMsg) + } + uploadType = whatsmeow.MediaVideo + case "audio": + converterApiUrl := s.config.ApiAudioConverter + converterApiKey := s.config.ApiAudioConverterKey + var convertedData []byte + var err error + if converterApiUrl == "" { + + convertedData, duration, err = convertAudioToOpusWithDuration(fileData) + if err != nil { + return nil, err + } + } else { + convertedData, duration, err = convertAudioWithApi(converterApiUrl, converterApiKey, ConvertAudio{Base64: base64.StdEncoding.EncodeToString(fileData)}) + if err != nil { + return nil, err + } + } + + fileData = convertedData + mimeType = "audio/ogg; codecs=opus" + uploadType = whatsmeow.MediaAudio + case "document": + uploadType = whatsmeow.MediaDocument + default: + return nil, errors.New("invalid media type") + } + + // Detectar se é newsletter para usar upload sem criptografia + isNewsletter := strings.Contains(data.Number, "@newsletter") + + // Validar se é documento em newsletter (não suportado) + if isNewsletter && data.Type == "document" { + return nil, errors.New("documentos não são suportados em canais do WhatsApp. Use imagem, vídeo, áudio ou enquete") + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendMediaFile - Upload iniciado (Newsletter: %v)...", instance.Id, isNewsletter) + + var uploaded whatsmeow.UploadResponse + if isNewsletter { + // Newsletter: upload SEM criptografia + uploaded, err = client.UploadNewsletter(context.Background(), fileData, uploadType) + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Newsletter upload - Handle: %s", instance.Id, uploaded.Handle) + } else { + // Normal: upload COM criptografia + uploaded, err = client.Upload(context.Background(), fileData, uploadType) + } + + if err != nil { + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Media uploaded with size %d", instance.Id, uploaded.FileLength) + + var media *waE2E.Message + var mediaType string + + switch data.Type { + case "image": + // Generate a JPEG preview thumbnail for better client UX (iOS in + // particular). On failure jpegThumb is nil and the message is sent + // without a preview rather than failing the request. + jpegThumb := makeJPEGThumbnail(fileData, 72) + if isNewsletter { + // Newsletter: SEM MediaKey e FileEncSHA256 + media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, + }} + } else { + // Normal: COM MediaKey e FileEncSHA256 + media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ + Caption: proto.String(data.Caption), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, + }} + } + mediaType = "ImageMessage" + case "video": + if isNewsletter { + media = &waE2E.Message{VideoMessage: &waE2E.VideoMessage{ + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + }} + } else { + media = &waE2E.Message{VideoMessage: &waE2E.VideoMessage{ + Caption: proto.String(data.Caption), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + }} + } + mediaType = "VideoMessage" + case "ptv": + if isNewsletter { + media = &waE2E.Message{PtvMessage: &waE2E.VideoMessage{ + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + }} + } else { + media = &waE2E.Message{PtvMessage: &waE2E.VideoMessage{ + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + }} + } + mediaType = "PtvMessage" + case "audio": + if isNewsletter { + media = &waE2E.Message{AudioMessage: &waE2E.AudioMessage{ + URL: &uploaded.URL, + PTT: proto.Bool(true), + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + Seconds: proto.Uint32(uint32(duration)), + }} + } else { + media = &waE2E.Message{AudioMessage: &waE2E.AudioMessage{ + URL: proto.String(uploaded.URL), + PTT: proto.Bool(true), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uploaded.FileLength), + Seconds: proto.Uint32(uint32(duration)), + }} + } + mediaType = "AudioMessage" + case "document": + // For PDF documents, rasterize page 1 into a JPEG preview thumbnail. + // A missing pdftoppm or a failure yields nil and the document is + // sent without a preview instead of failing the request. + var jpegThumb []byte + if mimeType == "application/pdf" { + jpegThumb = makePDFThumbnail(fileData, 200) + } + if isNewsletter { + media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ + FileName: &data.Filename, + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, + }} + } else { + media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ + FileName: &data.Filename, + Caption: proto.String(data.Caption), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, + }} + } + + if media.GetDocumentMessage().GetCaption() != "" { + media.DocumentWithCaptionMessage = &waE2E.FutureProofMessage{ + Message: &waE2E.Message{ + DocumentMessage: media.DocumentMessage, + }, + } + media.DocumentMessage = nil + } + + mediaType = "DocumentMessage" + default: + return nil, errors.New("invalid media type") + } + + message, err := s.SendMessage(instance, media, mediaType, &SendDataStruct{ + Id: data.Id, + Number: data.Number, + Quoted: data.Quoted, + Delay: data.Delay, + MentionAll: data.MentionAll, + MentionedJID: data.MentionedJID, + FormatJid: data.FormatJid, + MediaHandle: uploaded.Handle, + ForwardingScore: data.ForwardingScore, + }) + + if err != nil { + // Check if it's a client disconnection error + if strings.Contains(err.Error(), "client disconnected") || strings.Contains(err.Error(), "no active session") { + s.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SendMediaFile failed due to disconnection on attempt %d/%d: %v", instance.Id, attempt, maxRetries, err) + if attempt < maxRetries { + waitTime := time.Duration(attempt) * time.Second + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Waiting %v before retry", instance.Id, waitTime) + time.Sleep(waitTime) + continue + } + } + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendMediaFile successful on attempt %d", instance.Id, attempt) + return message, nil + } + + return nil, fmt.Errorf("failed to send media file after %d attempts", maxRetries) +} + +func (s *sendService) SendMediaUrl(data *MediaStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + return s.sendMediaUrlWithRetry(data, instance, 3) +} + +func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instance_model.Instance, maxRetries int) (*MessageSendStruct, error) { + for attempt := 1; attempt <= maxRetries; attempt++ { + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendMediaUrl attempt %d/%d for URL: %s", instance.Id, attempt, maxRetries, data.Url) + startTime := time.Now() + + client, err := s.ensureClientConnectedWithRetry(instance.Id, 2) + if err != nil { + if attempt == maxRetries { + return nil, err + } + continue + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Iniciando download da URL: %s", instance.Id, data.Url) + + resp, err := http.Get(data.Url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Download concluído em %v. Lendo dados...", instance.Id, time.Since(startTime)) + + downloadStart := time.Now() + fileData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Leitura dos dados concluída em %v. Tamanho: %d bytes", instance.Id, time.Since(downloadStart), len(fileData)) + + mime, _ := mimetype.DetectReader(bytes.NewReader(fileData)) + mimeType := mime.String() + if strings.HasSuffix(strings.ToLower(data.Url), ".mp4") { + mimeType = "video/mp4" + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Tipo MIME detectado: %s", instance.Id, mimeType) + + var uploadType whatsmeow.MediaType + var duration int + + processingStart := time.Now() + switch data.Type { + case "image": + if mimeType != "image/jpeg" && mimeType != "image/png" && mimeType != "image/webp" { + errMsg := fmt.Sprintf("Invalid file format: '%s'. Only 'image/jpeg', 'image/png' and 'image/webp' are accepted", mimeType) + return nil, errors.New(errMsg) + } + if mimeType == "image/webp" { + mimeType = "image/jpeg" + } + uploadType = whatsmeow.MediaImage + + case "video", "ptv": + if mimeType != "video/mp4" { + errMsg := fmt.Sprintf("Invalid file format: '%s'. Only 'video/mp4' are accepted", mimeType) + return nil, errors.New(errMsg) + } + uploadType = whatsmeow.MediaVideo + case "audio": + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Iniciando conversão de áudio...", instance.Id) + converterApiUrl := s.config.ApiAudioConverter + converterApiKey := s.config.ApiAudioConverterKey + var convertedData []byte + var err error + if converterApiUrl == "" { + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Usando conversão local...", instance.Id) + convertedData, duration, err = convertAudioToOpusWithDuration(fileData) + } else { + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Usando API de conversão...", instance.Id) + convertedData, duration, err = convertAudioWithApi(converterApiUrl, converterApiKey, ConvertAudio{Base64: base64.StdEncoding.EncodeToString(fileData)}) + } + if err != nil { + return nil, err + } + fileData = convertedData + mimeType = "audio/ogg; codecs=opus" + uploadType = whatsmeow.MediaAudio + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Conversão de áudio concluída em %v", instance.Id, time.Since(processingStart)) + case "document": + uploadType = whatsmeow.MediaDocument + default: + return nil, errors.New("invalid media type") + } + + // Detectar se é newsletter para usar upload sem criptografia + isNewsletter := strings.Contains(data.Number, "@newsletter") + + // Validar se é documento em newsletter (não suportado) + if isNewsletter && data.Type == "document" { + return nil, errors.New("documentos não são suportados em canais do WhatsApp. Use imagem, vídeo, áudio ou enquete") + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Iniciando upload para WhatsApp (Newsletter: %v)...", instance.Id, isNewsletter) + uploadStart := time.Now() + + var uploaded whatsmeow.UploadResponse + if isNewsletter { + // Newsletter: upload sem criptografia + uploaded, err = client.UploadNewsletter(context.Background(), fileData, uploadType) + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Newsletter upload - Handle: %s", instance.Id, uploaded.Handle) + } else { + // Upload normal com criptografia + uploaded, err = client.Upload(context.Background(), fileData, uploadType) + } + + if err != nil { + return nil, err + } + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Upload concluído em %v. Tamanho: %d", instance.Id, time.Since(uploadStart), uploaded.FileLength) + + var media *waE2E.Message + var mediaType string + + switch data.Type { + case "image": + // Generate a JPEG preview thumbnail for better client UX (iOS in + // particular). On failure jpegThumb is nil and the message is sent + // without a preview rather than failing the request. + jpegThumb := makeJPEGThumbnail(fileData, 72) + if isNewsletter { + // Newsletter: sem criptografia (sem MediaKey e FileEncSHA256) + media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, + }} + } else { + // Normal: com criptografia + media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ + Caption: proto.String(data.Caption), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, + }} + } + mediaType = "ImageMessage" + case "video": + if isNewsletter { + media = &waE2E.Message{VideoMessage: &waE2E.VideoMessage{ + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + }} + } else { + media = &waE2E.Message{VideoMessage: &waE2E.VideoMessage{ + Caption: proto.String(data.Caption), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + }} + } + mediaType = "VideoMessage" + case "ptv": + if isNewsletter { + media = &waE2E.Message{PtvMessage: &waE2E.VideoMessage{ + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + }} + } else { + media = &waE2E.Message{PtvMessage: &waE2E.VideoMessage{ + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + }} + } + mediaType = "PtvMessage" + case "audio": + if isNewsletter { + media = &waE2E.Message{AudioMessage: &waE2E.AudioMessage{ + URL: &uploaded.URL, + PTT: proto.Bool(true), + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + StreamingSidecar: []byte(*proto.String("QpmXDsU7YLagdg==")), + Waveform: []byte(*proto.String("OjAnExISDgsKCAkJBwgkHAQEBBEFAwMNAxAcKCgkFzM0QUE4Jh4eKAoKChcLCwkeFgkJCQo3JiQmIiIRPz8/Ow==")), + Seconds: proto.Uint32(uint32(duration)), + }} + } else { + media = &waE2E.Message{AudioMessage: &waE2E.AudioMessage{ + URL: proto.String(uploaded.URL), + PTT: proto.Bool(true), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uploaded.FileLength), + StreamingSidecar: []byte(*proto.String("QpmXDsU7YLagdg==")), + Waveform: []byte(*proto.String("OjAnExISDgsKCAkJBwgkHAQEBBEFAwMNAxAcKCgkFzM0QUE4Jh4eKAoKChcLCwkeFgkJCQo3JiQmIiIRPz8/Ow==")), + Seconds: proto.Uint32(uint32(duration)), + }} + } + mediaType = "AudioMessage" + case "document": + // For PDF documents, rasterize page 1 into a JPEG preview thumbnail. + // A missing pdftoppm or a failure yields nil and the document is + // sent without a preview instead of failing the request. + var jpegThumb []byte + if mimeType == "application/pdf" { + jpegThumb = makePDFThumbnail(fileData, 200) + } + if isNewsletter { + media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ + URL: &uploaded.URL, + FileName: &data.Filename, + Caption: proto.String(data.Caption), + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, + }} + } else { + media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ + URL: proto.String(uploaded.URL), + FileName: &data.Filename, + Caption: proto.String(data.Caption), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, + }} + } + + if media.GetDocumentMessage().GetCaption() != "" { + media.DocumentWithCaptionMessage = &waE2E.FutureProofMessage{ + Message: &waE2E.Message{ + DocumentMessage: media.DocumentMessage, + }, + } + media.DocumentMessage = nil + } + + mediaType = "DocumentMessage" + default: + return nil, errors.New("invalid media type") + } + + messageStart := time.Now() + message, err := s.SendMessage(instance, media, mediaType, &SendDataStruct{ + Id: data.Id, + Number: data.Number, + Quoted: data.Quoted, + Delay: data.Delay, + MentionAll: data.MentionAll, + MentionedJID: data.MentionedJID, + FormatJid: data.FormatJid, + MediaHandle: uploaded.Handle, + ForwardingScore: data.ForwardingScore, + }) + + if err != nil { + // Check if it's a client disconnection error + if strings.Contains(err.Error(), "client disconnected") || strings.Contains(err.Error(), "no active session") { + s.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SendMediaUrl failed due to disconnection on attempt %d/%d: %v", instance.Id, attempt, maxRetries, err) + if attempt < maxRetries { + waitTime := time.Duration(attempt) * time.Second + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Waiting %v before retry", instance.Id, waitTime) + time.Sleep(waitTime) + continue + } + } + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Mensagem enviada em %v", instance.Id, time.Since(messageStart)) + + totalTime := time.Since(startTime) + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendMediaUrl successful on attempt %d, processo completo em %v", instance.Id, attempt, totalTime) + + return message, nil + } + + return nil, fmt.Errorf("failed to send media url after %d attempts", maxRetries) +} + +func (s *sendService) SendPoll(data *PollStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + return s.sendPollWithRetry(data, instance, 3) +} + +func (s *sendService) sendPollWithRetry(data *PollStruct, instance *instance_model.Instance, maxRetries int) (*MessageSendStruct, error) { + for attempt := 1; attempt <= maxRetries; attempt++ { + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendPoll attempt %d/%d", instance.Id, attempt, maxRetries) + + client, err := s.ensureClientConnectedWithRetry(instance.Id, 2) + if err != nil { + if attempt == maxRetries { + return nil, err + } + continue + } + + msg := client.BuildPollCreation(data.Question, data.Options, data.MaxAnswer) + + message, err := s.SendMessage(instance, msg, "PollCreationMessage", &SendDataStruct{ + Id: data.Id, + Number: data.Number, + Quoted: data.Quoted, + Delay: data.Delay, + MentionAll: data.MentionAll, + MentionedJID: data.MentionedJID, + FormatJid: data.FormatJid, + }) + + if err != nil { + // Check if it's a client disconnection error + if strings.Contains(err.Error(), "client disconnected") || strings.Contains(err.Error(), "no active session") { + s.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SendPoll failed due to disconnection on attempt %d/%d: %v", instance.Id, attempt, maxRetries, err) + if attempt < maxRetries { + waitTime := time.Duration(attempt) * time.Second + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Waiting %v before retry", instance.Id, waitTime) + time.Sleep(waitTime) + continue + } + } + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendPoll successful on attempt %d", instance.Id, attempt) + return message, nil + } + + return nil, fmt.Errorf("failed to send poll after %d attempts", maxRetries) +} + +func convertToWebP(imageData string) ([]byte, error) { + var img image.Image + var err error + + resp, err := http.Get(imageData) + if err != nil { + return nil, fmt.Errorf("failed to fetch image from URL: %v", err) + } + defer resp.Body.Close() + + img, _, err = image.Decode(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %v", err) + } + + var webpBuffer bytes.Buffer + err = webp.Encode(&webpBuffer, img, &webp.Options{Lossless: false, Quality: 80}) + if err != nil { + return nil, fmt.Errorf("failed to encode image to WebP: %v", err) + } + + return webpBuffer.Bytes(), nil +} + +func (s *sendService) SendSticker(data *StickerStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + client, err := s.ensureClientConnected(instance.Id) + if err != nil { + return nil, err + } + + var uploaded whatsmeow.UploadResponse + var filedata []byte + + if strings.HasPrefix(data.Sticker, "http") { + webpData, err := convertToWebP(data.Sticker) + if err != nil { + return nil, fmt.Errorf("failed to convert image to WebP: %v", err) + } + + filedata = webpData + + uploaded, err = client.Upload(context.Background(), filedata, whatsmeow.MediaImage) + if err != nil { + return nil, fmt.Errorf("failed to upload sticker: %v", err) + } + } else { + return nil, fmt.Errorf("invalid sticker URL") + } + + msg := &waE2E.Message{StickerMessage: &waE2E.StickerMessage{ + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(http.DetectContentType(filedata)), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(filedata))), + }} + + message, err := s.SendMessage(instance, msg, "StickerMessage", &SendDataStruct{ + Id: data.Id, + Number: data.Number, + Quoted: data.Quoted, + Delay: data.Delay, + MentionAll: data.MentionAll, + MentionedJID: data.MentionedJID, + FormatJid: data.FormatJid, + }) + if err != nil { + return nil, err + } + + return message, nil +} + +func (s *sendService) SendLocation(data *LocationStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + _, err := s.ensureClientConnected(instance.Id) + if err != nil { + return nil, err + } + + msg := &waE2E.Message{LocationMessage: &waE2E.LocationMessage{ + DegreesLatitude: &data.Latitude, + DegreesLongitude: &data.Longitude, + Name: &data.Name, + Address: &data.Address, + }} + + message, err := s.SendMessage(instance, msg, "LocationMessage", &SendDataStruct{ + Id: data.Id, + Number: data.Number, + Quoted: data.Quoted, + Delay: data.Delay, + MentionAll: data.MentionAll, + MentionedJID: data.MentionedJID, + FormatJid: data.FormatJid, + }) + if err != nil { + return nil, err + } + + return message, nil +} + +func (s *sendService) SendContact(data *ContactStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + _, err := s.ensureClientConnected(instance.Id) + if err != nil { + return nil, err + } + + VCstring := utils.GenerateVC(utils.VCardStruct{ + FullName: data.Vcard.FullName, + Phone: data.Vcard.Phone, + Organization: data.Vcard.Organization, + }) + + fmt.Println(VCstring) + + msg := &waE2E.Message{ContactMessage: &waE2E.ContactMessage{ + DisplayName: &data.Vcard.FullName, + Vcard: &VCstring, + }} + + messaged, err := s.SendMessage(instance, msg, "ContactMessage", &SendDataStruct{ + Id: data.Id, + Number: data.Number, + Quoted: data.Quoted, + Delay: data.Delay, + MentionAll: data.MentionAll, + MentionedJID: data.MentionedJID, + FormatJid: data.FormatJid, + }) + if err != nil { + return nil, err + } + + return messaged, nil +} + +func mapKeyType(keyType string) string { + switch keyType { + case "phone": + return "PHONE" + case "email": + return "EMAIL" + case "cpf": + return "CPF" + case "cnpj": + return "CNPJ" + case "random": + return "EVP" + default: + return keyType + } +} + +func (s *sendService) SendButton(data *ButtonStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + client, err := s.ensureClientConnected(instance.Id) + if err != nil { + return nil, err + } + + hasReply := false + hasPix := false + hasOtherTypes := false + replyCount := 0 + + for _, v := range data.Buttons { + switch v.Type { + case "reply": + hasReply = true + replyCount++ + case "pix": + hasPix = true + default: + hasOtherTypes = true + } + } + + if hasReply { + if replyCount > 3 { + return nil, errors.New("máximo de 3 botões do tipo 'reply' permitidos") + } + if hasOtherTypes { + return nil, errors.New("botões do tipo 'reply' não podem ser misturados com outros tipos") + } + } + + if hasPix { + if len(data.Buttons) > 1 { + return nil, errors.New("botão do tipo 'pix' não pode ser combinado com outros botões") + } + } + + buttons := []*waE2E.InteractiveMessage_NativeFlowMessage_NativeFlowButton{} + + for _, v := range data.Buttons { + var paramsJSON *string + var name *string + + switch v.Type { + case "reply": + name = proto.String("quick_reply") + jsonBytes, _ := json.Marshal(map[string]string{"display_text": v.DisplayText, "id": v.Id}) + paramsJSON = proto.String(string(jsonBytes)) + case "copy": + name = proto.String("cta_copy") + copyCode := v.CopyCode + if copyCode == "" { + copyCode = v.Id + } + copyId := v.Id + if copyId == "" { + copyId = "copy_" + strconv.FormatInt(time.Now().UnixNano(), 10) + } + jsonBytes, _ := json.Marshal(map[string]string{"display_text": v.DisplayText, "id": copyId, "copy_code": copyCode}) + paramsJSON = proto.String(string(jsonBytes)) + case "url": + name = proto.String("cta_url") + jsonBytes, _ := json.Marshal(map[string]string{"display_text": v.DisplayText, "url": v.URL, "merchant_url": v.URL}) + paramsJSON = proto.String(string(jsonBytes)) + case "call": + name = proto.String("cta_call") + jsonBytes, _ := json.Marshal(map[string]string{"display_text": v.DisplayText, "phone_number": v.PhoneNumber}) + paramsJSON = proto.String(string(jsonBytes)) + case "pix": + randomId := utils.GenerateRandomString(11) + name = proto.String("payment_info") + paymentPayload := map[string]interface{}{ + "currency": v.Currency, + "total_amount": map[string]interface{}{"value": 0, "offset": 100}, + "reference_id": randomId, + "type": "physical-goods", + "order": map[string]interface{}{ + "status": "pending", + "subtotal": map[string]interface{}{"value": 0, "offset": 100}, + "order_type": "ORDER", + "items": []map[string]interface{}{ + { + "name": "", + "amount": map[string]interface{}{"value": 0, "offset": 100}, + "quantity": 0, + "sale_amount": map[string]interface{}{"value": 0, "offset": 100}, + }, + }, + }, + "payment_settings": []map[string]interface{}{ + { + "type": "pix_static_code", + "pix_static_code": map[string]string{ + "merchant_name": v.Name, + "key": v.Key, + "key_type": mapKeyType(v.KeyType), + }, + }, + }, + "share_payment_status": false, + } + jsonBytes, _ := json.Marshal(paymentPayload) + paramsJSON = proto.String(string(jsonBytes)) + } + + buttons = append(buttons, &waE2E.InteractiveMessage_NativeFlowMessage_NativeFlowButton{ + Name: name, + ButtonParamsJSON: paramsJSON, + }) + } + + templateId := strconv.FormatInt(time.Now().UnixNano()/1000000, 10) + messageParamsJSON := `{"from":"api","templateId":` + templateId + `}` + + // MessageSecret (32 random bytes) — required for iOS to render interactive messages. + btnMsgSecret := make([]byte, 32) + _, _ = crypto_rand.Read(btnMsgSecret) + + var msg *waE2E.Message + var msgType string + + if hasReply && !hasOtherTypes && !hasPix { + // Reply-only: native ButtonsMessage wrapped in DocumentWithCaptionMessage (Baileys PR #36). + var replyButtons []*waE2E.ButtonsMessage_Button + for _, v := range data.Buttons { + replyButtons = append(replyButtons, &waE2E.ButtonsMessage_Button{ + ButtonID: proto.String(v.Id), + ButtonText: &waE2E.ButtonsMessage_Button_ButtonText{ + DisplayText: proto.String(v.DisplayText), + }, + Type: waE2E.ButtonsMessage_Button_RESPONSE.Enum(), + }) + } + + buttonsMsg := &waE2E.ButtonsMessage{ + ContentText: proto.String(data.Description), + FooterText: proto.String(data.Footer), + HeaderType: waE2E.ButtonsMessage_EMPTY.Enum(), + Buttons: replyButtons, + } + + // Optional media header (image or video URL). + if data.ImageUrl != "" { + if resp, err := http.Get(data.ImageUrl); err == nil { + fileData, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr == nil { + if uploaded, upErr := client.Upload(context.Background(), fileData, whatsmeow.MediaImage); upErr == nil { + buttonsMsg.HeaderType = waE2E.ButtonsMessage_IMAGE.Enum() + buttonsMsg.Header = &waE2E.ButtonsMessage_ImageMessage{ + ImageMessage: &waE2E.ImageMessage{ + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String("image/jpeg"), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + }, + } + } + } + } + } else if data.VideoUrl != "" { + if resp, err := http.Get(data.VideoUrl); err == nil { + fileData, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr == nil { + if uploaded, upErr := client.Upload(context.Background(), fileData, whatsmeow.MediaVideo); upErr == nil { + buttonsMsg.HeaderType = waE2E.ButtonsMessage_VIDEO.Enum() + buttonsMsg.Header = &waE2E.ButtonsMessage_VideoMessage{ + VideoMessage: &waE2E.VideoMessage{ + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String("video/mp4"), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + }, + } + } + } + } + } + + msg = &waE2E.Message{ + DocumentWithCaptionMessage: &waE2E.FutureProofMessage{ + Message: &waE2E.Message{ + ButtonsMessage: buttonsMsg, + }, + }, + MessageContextInfo: &waE2E.MessageContextInfo{ + MessageSecret: btnMsgSecret, + }, + } + msgType = "ButtonsMessage" + } else if hasPix { + // Pix: NativeFlowMessage wrapped in DocumentWithCaptionMessage. + paymentMsgParams := `{"native_flow_name":"order_details","version":1}` + + var interactiveBody *waE2E.InteractiveMessage_Body + if data.Title != "" { + bodyText := data.Title + interactiveBody = &waE2E.InteractiveMessage_Body{Text: &bodyText} + } + + msg = &waE2E.Message{ + DocumentWithCaptionMessage: &waE2E.FutureProofMessage{ + Message: &waE2E.Message{ + InteractiveMessage: &waE2E.InteractiveMessage{ + Body: interactiveBody, + InteractiveMessage: &waE2E.InteractiveMessage_NativeFlowMessage_{ + NativeFlowMessage: &waE2E.InteractiveMessage_NativeFlowMessage{ + Buttons: buttons, + MessageParamsJSON: &paymentMsgParams, + MessageVersion: proto.Int32(1), + }, + }, + }, + }, + }, + MessageContextInfo: &waE2E.MessageContextInfo{ + MessageSecret: btnMsgSecret, + }, + } + msgType = "InteractiveMessage" + } else { + // Mixed CTA buttons (url/copy/call): NativeFlowMessage wrapped in DocumentWithCaptionMessage. + body := func() string { + t := "*" + data.Title + "*" + if data.Description != "" { + t += "\n\n" + data.Description + "\n" + } + return t + }() + + msg = &waE2E.Message{ + DocumentWithCaptionMessage: &waE2E.FutureProofMessage{ + Message: &waE2E.Message{ + InteractiveMessage: &waE2E.InteractiveMessage{ + Body: &waE2E.InteractiveMessage_Body{ + Text: &body, + }, + Footer: &waE2E.InteractiveMessage_Footer{ + Text: &data.Footer, + }, + InteractiveMessage: &waE2E.InteractiveMessage_NativeFlowMessage_{ + NativeFlowMessage: &waE2E.InteractiveMessage_NativeFlowMessage{ + Buttons: buttons, + MessageParamsJSON: &messageParamsJSON, + MessageVersion: proto.Int32(1), + }, + }, + }, + }, + }, + MessageContextInfo: &waE2E.MessageContextInfo{ + MessageSecret: btnMsgSecret, + }, + } + msgType = "InteractiveMessage" + } + + // Build biz/bot nodes injected directly in the XMPP stanza — required for mobile rendering. + // Reply-only buttons get ; CTA/Pix get . + // The node is required for 1:1 chats (skipped on groups). + var bizInteractiveContent waBinary.Node + if hasReply && !hasOtherTypes && !hasPix { + bizInteractiveContent = waBinary.Node{ + Tag: "interactive", + Attrs: waBinary.Attrs{ + "type": "native_flow", + "v": "1", + }, + Content: []waBinary.Node{{ + Tag: "native_flow", + Attrs: waBinary.Attrs{ + "name": "quick_reply", + }, + }}, + } + } else if hasPix { + bizInteractiveContent = waBinary.Node{ + Tag: "interactive", + Attrs: waBinary.Attrs{ + "type": "native_flow", + "v": "1", + }, + Content: []waBinary.Node{{ + Tag: "native_flow", + Attrs: waBinary.Attrs{ + "name": "payment_info", + }, + }}, + } + } else { + // Mixed CTA buttons (url/copy/call) — name="mixed" is the WhatsApp convention. + bizInteractiveContent = waBinary.Node{ + Tag: "interactive", + Attrs: waBinary.Attrs{ + "type": "native_flow", + "v": "1", + }, + Content: []waBinary.Node{{ + Tag: "native_flow", + Attrs: waBinary.Attrs{ + "name": "mixed", + }, + }}, + } + } + + bizNodes := []waBinary.Node{ + { + Tag: "biz", + Content: []waBinary.Node{bizInteractiveContent}, + }, + } + if !strings.Contains(data.Number, "@g.us") { + bizNodes = append(bizNodes, waBinary.Node{ + Tag: "bot", + Attrs: waBinary.Attrs{"biz_bot": "1"}, + }) + } + + // Route through centralized SendMessage for ContextInfo, webhooks, quotes, mentions. + message, err := s.SendMessage(instance, msg, msgType, &SendDataStruct{ + Number: data.Number, + Delay: data.Delay, + MentionAll: data.MentionAll, + MentionedJID: data.MentionedJID, + FormatJid: data.FormatJid, + Quoted: data.Quoted, + AdditionalNodes: &bizNodes, + }) + if err != nil { + s.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Error sending button message: %v", instance.Id, err) + return nil, err + } + + return message, nil +} + +func stringPointer(s string) *string { + return &s +} + +// makeJPEGThumbnail decodes raw image bytes and produces a small JPEG +// thumbnail suitable for the JPEGThumbnail field of WhatsApp media messages. +// The thumbnail keeps the original aspect ratio and is capped at maxWidth +// pixels wide. It returns nil if the image cannot be decoded so callers can +// fall back to sending the message without a preview thumbnail. +func makeJPEGThumbnail(fileData []byte, maxWidth int) []byte { + if maxWidth < 1 { + maxWidth = 72 + } + + img, _, err := image.Decode(bytes.NewReader(fileData)) + if err != nil { + return nil + } + + bounds := img.Bounds() + srcWidth := bounds.Dx() + srcHeight := bounds.Dy() + if srcWidth < 1 || srcHeight < 1 { + return nil + } + + thumbWidth := maxWidth + if srcWidth < thumbWidth { + thumbWidth = srcWidth + } + thumbHeight := int(float64(srcHeight) * float64(thumbWidth) / float64(srcWidth)) + if thumbHeight < 1 { + thumbHeight = 1 + } + + thumbImg := image.NewRGBA(image.Rect(0, 0, thumbWidth, thumbHeight)) + for y := 0; y < thumbHeight; y++ { + for x := 0; x < thumbWidth; x++ { + srcX := x * srcWidth / thumbWidth + srcY := y * srcHeight / thumbHeight + thumbImg.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)) + } + } + + var thumbBuf bytes.Buffer + if err := jpeg.Encode(&thumbBuf, thumbImg, &jpeg.Options{Quality: 50}); err != nil { + return nil + } + return thumbBuf.Bytes() +} + +// makePDFThumbnail rasterizes the first page of a PDF into a JPEG thumbnail +// using the external "pdftoppm" tool (poppler-utils). It returns nil when +// pdftoppm is not installed or rasterization fails, so callers can gracefully +// send the document without a preview instead of failing the request. +func makePDFThumbnail(fileData []byte, maxWidth int) []byte { + if _, err := exec.LookPath("pdftoppm"); err != nil { + return nil + } + + scaleWidth := maxWidth + if scaleWidth < 1 { + scaleWidth = 72 + } + + // Render only the first page to a PNG on stdout, scaled to scaleWidth. + // "-scale-to-y -1" keeps the original aspect ratio. + cmd := exec.Command("pdftoppm", + "-png", + "-f", "1", + "-l", "1", + "-singlefile", + "-scale-to-x", strconv.Itoa(scaleWidth), + "-scale-to-y", "-1", + ) + cmd.Stdin = bytes.NewReader(fileData) + + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return nil + } + if out.Len() == 0 { + return nil + } + + // Re-encode the rendered PNG as a JPEG thumbnail for consistency with images. + return makeJPEGThumbnail(out.Bytes(), maxWidth) +} + +func sectionsToString(data *ListStruct) (string, error) { + type row struct { + Header string `json:"header"` + Title string `json:"title"` + Description string `json:"description"` + ID string `json:"id"` + } + + type listSection struct { + Title string `json:"title"` + HighlightLabel string `json:"highlight_label"` + Rows []row `json:"rows"` + } + + type list struct { + Title string `json:"title"` + Sections []listSection `json:"sections"` + } + + sections := []listSection{} + + for _, s := range data.Sections { + sectionTitle := s.Title + if sectionTitle == "" { + sectionTitle = " " + } + rows := []row{} + + for _, r := range s.Rows { + rowTitle := r.Title + if rowTitle == "" { + rowTitle = " " + } + rowDesc := r.Description + if rowDesc == "" { + rowDesc = " " + } + rowId := r.RowId + if rowId == "" { + rowId = fmt.Sprintf("row_%d", len(rows)) + } + rows = append(rows, row{ + Header: rowTitle, + Title: rowTitle, + Description: rowDesc, + ID: rowId, + }) + } + + section := listSection{ + Title: sectionTitle, + HighlightLabel: "", + Rows: rows, + } + + sections = append(sections, section) + } + + buttonText := data.ButtonText + if buttonText == "" { + buttonText = "Ver Menu" + } + + listData := list{ + Title: buttonText, + Sections: sections, + } + + jsonData, err := json.Marshal(listData) + if err != nil { + return "", err + } + + return string(jsonData), nil +} + +func (s *sendService) SendList(data *ListStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + // Legacy ListMessage format - works on iOS, Android and Web + // Matching PAPI Node.js default (non-modern) path exactly + + buttonText := data.ButtonText + if buttonText == "" { + buttonText = "Ver Menu" + } + + // Build sections in legacy ListMessage format + var sections []*waE2E.ListMessage_Section + for _, sec := range data.Sections { + sectionTitle := sec.Title + if sectionTitle == "" { + sectionTitle = " " + } + var rows []*waE2E.ListMessage_Row + for i, r := range sec.Rows { + rowTitle := r.Title + if rowTitle == "" { + rowTitle = " " + } + rowId := r.RowId + if rowId == "" { + rowId = fmt.Sprintf("row_%d_%d", i, len(rows)) + } + rows = append(rows, &waE2E.ListMessage_Row{ + Title: proto.String(rowTitle), + Description: proto.String(r.Description), + RowID: proto.String(rowId), + }) + } + sections = append(sections, &waE2E.ListMessage_Section{ + Title: proto.String(sectionTitle), + Rows: rows, + }) + } + + listType := waE2E.ListMessage_SINGLE_SELECT + listMessage := &waE2E.ListMessage{ + Title: proto.String(data.Title), + Description: proto.String(data.Description), + ButtonText: proto.String(buttonText), + FooterText: proto.String(data.FooterText), + ListType: &listType, + Sections: sections, + } + + // Wrap ListMessage in DocumentWithCaptionMessage (Baileys PR #36) so modern WhatsApp renders it. + // MessageSecret (32 random bytes) is required for iOS rendering. + listMsgSecret := make([]byte, 32) + _, _ = crypto_rand.Read(listMsgSecret) + + msg := &waE2E.Message{ + DocumentWithCaptionMessage: &waE2E.FutureProofMessage{ + Message: &waE2E.Message{ + ListMessage: listMessage, + }, + }, + MessageContextInfo: &waE2E.MessageContextInfo{ + MessageSecret: listMsgSecret, + }, + } + + // Build biz node — required for mobile rendering of modern lists. + listBizNodes := []waBinary.Node{ + { + Tag: "biz", + Content: []waBinary.Node{{ + Tag: "list", + Attrs: waBinary.Attrs{ + "v": "2", + "type": "single_select", + }, + }}, + }, + } + if !strings.Contains(data.Number, "@g.us") { + listBizNodes = append(listBizNodes, waBinary.Node{ + Tag: "bot", + Attrs: waBinary.Attrs{"biz_bot": "1"}, + }) + } + + message, err := s.SendMessage(instance, msg, "ListMessage", &SendDataStruct{ + Number: data.Number, + Delay: data.Delay, + MentionAll: data.MentionAll, + MentionedJID: data.MentionedJID, + FormatJid: data.FormatJid, + Quoted: data.Quoted, + AdditionalNodes: &listBizNodes, + }) + + if err != nil { + s.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Error sending list: %v", instance.Id, err) + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] List sent to %s", instance.Id, data.Number) + return message, nil +} + +func (s *sendService) SendMessage(instance *instance_model.Instance, msg *waE2E.Message, messageType string, data *SendDataStruct) (*MessageSendStruct, error) { + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] SendMessage called for number: %s, type: %s", instance.Id, data.Number, messageType) + + recipient, err := s.validateAndCheckUserExists(data.Number, data.FormatJid, &data.Quoted.MessageID, &data.Quoted.MessageID, instance) + if err != nil { + s.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Error validating message fields or user check: %v", instance.Id, err) + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Recipient validated: %s (Server: %s)", instance.Id, recipient.String(), recipient.Server) + + var message string + if data.Id == "" { + message = s.clientPointer[instance.Id].GenerateMessageID() + } else { + message = data.Id + } + + if data.Delay > 0 { + media := "" + if messageType == "AudioMessage" { + media = "audio" + } + + err := s.clientPointer[instance.Id].SendChatPresence(context.Background(), recipient, types.ChatPresence("composing"), types.ChatPresenceMedia(media)) + if err != nil { + return nil, err + } + + time.Sleep(time.Duration(data.Delay) * time.Millisecond) + + err = s.clientPointer[instance.Id].SendChatPresence(context.Background(), recipient, types.ChatPresence("paused"), types.ChatPresenceMedia(media)) + if err != nil { + return nil, err + } + } + + isMedia := false + + if data.Quoted.MessageID != "" { + switch messageType { + case "ExtendedTextMessage": + msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + case "ImageMessage": + msg.ImageMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + isMedia = true + case "VideoMessage": + msg.VideoMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + isMedia = true + case "PtvMessage": + msg.PtvMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + isMedia = true + case "AudioMessage": + msg.AudioMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + isMedia = true + case "DocumentMessage": + if msg.DocumentMessage != nil { + msg.DocumentMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + } else if msg.DocumentWithCaptionMessage != nil { + msg.DocumentWithCaptionMessage.Message.DocumentMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + } + isMedia = true + case "PollCreationMessage": + msg.PollCreationMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + case "StickerMessage": + msg.StickerMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + isMedia = true + case "LocationMessage": + msg.LocationMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + case "ContactMessage": + msg.ContactMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + case "InteractiveMessage": + if msg.InteractiveMessage != nil { + msg.InteractiveMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + } else if msg.DocumentWithCaptionMessage != nil && + msg.DocumentWithCaptionMessage.Message != nil && + msg.DocumentWithCaptionMessage.Message.InteractiveMessage != nil { + msg.DocumentWithCaptionMessage.Message.InteractiveMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + } + case "ListMessage": + if msg.ListMessage != nil { + msg.ListMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + } else if msg.DocumentWithCaptionMessage != nil && + msg.DocumentWithCaptionMessage.Message != nil && + msg.DocumentWithCaptionMessage.Message.ListMessage != nil { + msg.DocumentWithCaptionMessage.Message.ListMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + } + case "ButtonsMessage": + if msg.ButtonsMessage != nil { + msg.ButtonsMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + } else if msg.DocumentWithCaptionMessage != nil && + msg.DocumentWithCaptionMessage.Message != nil && + msg.DocumentWithCaptionMessage.Message.ButtonsMessage != nil { + msg.DocumentWithCaptionMessage.Message.ButtonsMessage.ContextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + } + } + default: + return nil, fmt.Errorf("invalid messageType: %s", messageType) + } + } else { + switch messageType { + case "ExtendedTextMessage": + msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{} + case "ImageMessage": + msg.ImageMessage.ContextInfo = &waE2E.ContextInfo{} + isMedia = true + case "VideoMessage": + msg.VideoMessage.ContextInfo = &waE2E.ContextInfo{} + isMedia = true + case "PtvMessage": + msg.PtvMessage.ContextInfo = &waE2E.ContextInfo{} + isMedia = true + case "AudioMessage": + msg.AudioMessage.ContextInfo = &waE2E.ContextInfo{} + isMedia = true + case "DocumentMessage": + if msg.DocumentMessage != nil { + msg.DocumentMessage.ContextInfo = &waE2E.ContextInfo{} + } else if msg.DocumentWithCaptionMessage != nil { + msg.DocumentWithCaptionMessage.Message.DocumentMessage.ContextInfo = &waE2E.ContextInfo{} + } + isMedia = true + case "PollCreationMessage": + msg.PollCreationMessage.ContextInfo = &waE2E.ContextInfo{} + case "StickerMessage": + msg.StickerMessage.ContextInfo = &waE2E.ContextInfo{} + case "LocationMessage": + msg.LocationMessage.ContextInfo = &waE2E.ContextInfo{} + case "ContactMessage": + msg.ContactMessage.ContextInfo = &waE2E.ContextInfo{} + case "InteractiveMessage": + // ContextInfo already set in SendCarousel/SendButton/SendList + case "ListMessage": + // ContextInfo already set in SendList + case "ButtonsMessage": + // Reply-only buttons: ContextInfo already set in SendButton + default: + return nil, fmt.Errorf("invalid messageType: %s", messageType) + } + } + + // Apply ForwardingScore to whichever ContextInfo was set above. + // WhatsApp renders "Encaminhada" when ContextInfo.ForwardingScore > 0. + if data.ForwardingScore != nil && *data.ForwardingScore > 0 { + switch messageType { + case "ExtendedTextMessage": + if msg.ExtendedTextMessage != nil && msg.ExtendedTextMessage.ContextInfo != nil { + msg.ExtendedTextMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.ExtendedTextMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "ImageMessage": + if msg.ImageMessage != nil && msg.ImageMessage.ContextInfo != nil { + msg.ImageMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.ImageMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "VideoMessage": + if msg.VideoMessage != nil && msg.VideoMessage.ContextInfo != nil { + msg.VideoMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.VideoMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "PtvMessage": + if msg.PtvMessage != nil && msg.PtvMessage.ContextInfo != nil { + msg.PtvMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.PtvMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "AudioMessage": + if msg.AudioMessage != nil && msg.AudioMessage.ContextInfo != nil { + msg.AudioMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.AudioMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "DocumentMessage": + if msg.DocumentMessage != nil && msg.DocumentMessage.ContextInfo != nil { + msg.DocumentMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.DocumentMessage.ContextInfo.IsForwarded = proto.Bool(true) + } else if msg.DocumentWithCaptionMessage != nil && msg.DocumentWithCaptionMessage.Message != nil && msg.DocumentWithCaptionMessage.Message.DocumentMessage != nil && msg.DocumentWithCaptionMessage.Message.DocumentMessage.ContextInfo != nil { + msg.DocumentWithCaptionMessage.Message.DocumentMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.DocumentWithCaptionMessage.Message.DocumentMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "PollCreationMessage": + if msg.PollCreationMessage != nil && msg.PollCreationMessage.ContextInfo != nil { + msg.PollCreationMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.PollCreationMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "StickerMessage": + if msg.StickerMessage != nil && msg.StickerMessage.ContextInfo != nil { + msg.StickerMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.StickerMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "LocationMessage": + if msg.LocationMessage != nil && msg.LocationMessage.ContextInfo != nil { + msg.LocationMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.LocationMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "ContactMessage": + if msg.ContactMessage != nil && msg.ContactMessage.ContextInfo != nil { + msg.ContactMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.ContactMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "InteractiveMessage": + if msg.InteractiveMessage != nil && msg.InteractiveMessage.ContextInfo != nil { + msg.InteractiveMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.InteractiveMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + case "ListMessage": + if msg.ListMessage != nil && msg.ListMessage.ContextInfo != nil { + msg.ListMessage.ContextInfo.ForwardingScore = data.ForwardingScore + msg.ListMessage.ContextInfo.IsForwarded = proto.Bool(true) + } + } + } + + isGroup := strings.Contains(data.Number, "@g.us") + isNewsletter := strings.Contains(data.Number, "@newsletter") + + // Only try to get participants for actual groups, not newsletters + if isGroup && !isNewsletter { + if data.MentionAll { + groupInfo, err := s.clientPointer[instance.Id].GetGroupInfo(context.Background(), recipient) + if err != nil { + return nil, err + } + + var mentionedJIDs []string + for _, participant := range groupInfo.Participants { + mentionedJIDs = append(mentionedJIDs, participant.JID.String()) + } + + switch messageType { + case "ExtendedTextMessage": + if msg.ExtendedTextMessage.ContextInfo == nil { + msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.ExtendedTextMessage.ContextInfo.MentionedJID = mentionedJIDs + case "ImageMessage": + if msg.ImageMessage.ContextInfo == nil { + msg.ImageMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.ImageMessage.ContextInfo.MentionedJID = mentionedJIDs + case "VideoMessage": + if msg.VideoMessage.ContextInfo == nil { + msg.VideoMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.VideoMessage.ContextInfo.MentionedJID = mentionedJIDs + case "PtvMessage": + if msg.PtvMessage.ContextInfo == nil { + msg.PtvMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.PtvMessage.ContextInfo.MentionedJID = mentionedJIDs + case "AudioMessage": + if msg.AudioMessage.ContextInfo == nil { + msg.AudioMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.AudioMessage.ContextInfo.MentionedJID = mentionedJIDs + case "DocumentMessage": + if msg.DocumentMessage.ContextInfo == nil { + msg.DocumentMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.DocumentMessage.ContextInfo.MentionedJID = mentionedJIDs + case "PollCreationMessage": + if msg.PollCreationMessage.ContextInfo == nil { + msg.PollCreationMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.PollCreationMessage.ContextInfo.MentionedJID = mentionedJIDs + case "StickerMessage": + if msg.StickerMessage.ContextInfo == nil { + msg.StickerMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.StickerMessage.ContextInfo.MentionedJID = mentionedJIDs + case "LocationMessage": + if msg.LocationMessage.ContextInfo == nil { + msg.LocationMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.LocationMessage.ContextInfo.MentionedJID = mentionedJIDs + case "ContactMessage": + if msg.ContactMessage.ContextInfo == nil { + msg.ContactMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.ContactMessage.ContextInfo.MentionedJID = mentionedJIDs + } + + } + + if len(data.MentionedJID) > 0 { + switch messageType { + case "ExtendedTextMessage": + if msg.ExtendedTextMessage.ContextInfo == nil { + msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.ExtendedTextMessage.ContextInfo.MentionedJID = data.MentionedJID + case "ImageMessage": + if msg.ImageMessage.ContextInfo == nil { + msg.ImageMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.ImageMessage.ContextInfo.MentionedJID = data.MentionedJID + case "VideoMessage": + if msg.VideoMessage.ContextInfo == nil { + msg.VideoMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.VideoMessage.ContextInfo.MentionedJID = data.MentionedJID + case "PtvMessage": + if msg.PtvMessage.ContextInfo == nil { + msg.PtvMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.PtvMessage.ContextInfo.MentionedJID = data.MentionedJID + case "AudioMessage": + if msg.AudioMessage.ContextInfo == nil { + msg.AudioMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.AudioMessage.ContextInfo.MentionedJID = data.MentionedJID + case "DocumentMessage": + if msg.DocumentMessage.ContextInfo == nil { + msg.DocumentMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.DocumentMessage.ContextInfo.MentionedJID = data.MentionedJID + case "PollCreationMessage": + if msg.PollCreationMessage.ContextInfo == nil { + msg.PollCreationMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.PollCreationMessage.ContextInfo.MentionedJID = data.MentionedJID + case "StickerMessage": + if msg.StickerMessage.ContextInfo == nil { + msg.StickerMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.StickerMessage.ContextInfo.MentionedJID = data.MentionedJID + case "LocationMessage": + if msg.LocationMessage.ContextInfo == nil { + msg.LocationMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.LocationMessage.ContextInfo.MentionedJID = data.MentionedJID + case "ContactMessage": + if msg.ContactMessage.ContextInfo == nil { + msg.ContactMessage.ContextInfo = &waE2E.ContextInfo{} + } + msg.ContactMessage.ContextInfo.MentionedJID = data.MentionedJID + } + } + } + + recipient.User = strings.ReplaceAll(recipient.User, "+", "") + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Sending message to %s with ID %s", instance.Id, recipient.String(), message) + + // Preparar extra parameters para o envio + sendExtra := whatsmeow.SendRequestExtra{ID: message} + + // Para newsletters/canais, adicionar o MediaHandle se houver mídia + if recipient.Server == "newsletter" && data.MediaHandle != "" { + sendExtra.MediaHandle = data.MediaHandle + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Newsletter detected, using MediaHandle: %s", instance.Id, data.MediaHandle) + } + + // Injetar nodes biz/bot customizados (PIX, botões interativos, etc.) no stanza XMPP. + if data.AdditionalNodes != nil { + sendExtra.AdditionalNodes = data.AdditionalNodes + } + + response, err := s.clientPointer[instance.Id].SendMessage(context.Background(), recipient, msg, sendExtra) + if err != nil { + s.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Error sending message: %v", instance.Id, err) + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Message sent successfully! ServerID: %d", instance.Id, response.ServerID) + + messageInfo := types.MessageInfo{ + MessageSource: types.MessageSource{ + Chat: recipient, + Sender: *s.clientPointer[instance.Id].Store.ID, + IsFromMe: true, + IsGroup: isGroup, + }, + ID: message, + Timestamp: time.Now(), + ServerID: response.ServerID, + Type: messageType, + } + + messageSent := &MessageSendStruct{ + Info: messageInfo, + Message: msg, + MessageContextInfo: &waE2E.ContextInfo{ + StanzaID: proto.String(data.Quoted.MessageID), + Participant: proto.String(data.Quoted.Participant), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + }, + } + + postMap := make(map[string]interface{}) + postMap["event"] = "SendMessage" + + // Convertendo o MessageSendStruct para map antes de atribuir + messageData := make(map[string]interface{}) + messageData["Info"] = messageSent.Info + + // Convertendo a mensagem para map usando json marshal/unmarshal + msgBytes, err := json.Marshal(messageSent.Message) + if err != nil { + return nil, fmt.Errorf("failed to marshal message: %v", err) + } + + var msgMap map[string]interface{} + if err := json.Unmarshal(msgBytes, &msgMap); err != nil { + return nil, fmt.Errorf("failed to unmarshal message: %v", err) + } + + messageData["Message"] = msgMap + messageData["MessageContextInfo"] = messageSent.MessageContextInfo + + postMap["data"] = messageData + + if isMedia && s.config.WebhookFiles { + var data []byte + var err error + + img := msg.GetImageMessage() + audio := msg.GetAudioMessage() + document := msg.GetDocumentMessage() + video := msg.GetVideoMessage() + sticker := msg.GetStickerMessage() + + if img != nil { + data, err = s.clientPointer[instance.Id].Download(context.Background(), img) + } else if audio != nil { + data, err = s.clientPointer[instance.Id].Download(context.Background(), audio) + } else if document != nil { + data, err = s.clientPointer[instance.Id].Download(context.Background(), document) + } else if video != nil { + data, err = s.clientPointer[instance.Id].Download(context.Background(), video) + } else if sticker != nil { + data, err = s.clientPointer[instance.Id].Download(context.Background(), sticker) + + webpReader := bytes.NewReader(data) + img, err := webp.Decode(webpReader) + if err == nil { + var pngBuffer bytes.Buffer + err = png.Encode(&pngBuffer, img) + if err == nil { + data = pngBuffer.Bytes() + } + } + } + + if err == nil { + // Acessando o Message do map já convertido + messageMap := msgMap + if messageMap == nil { + messageMap = make(map[string]interface{}) + } + + encodeData := base64.StdEncoding.EncodeToString(data) + messageMap["base64"] = encodeData + + messageData["Message"] = messageMap + } + } + + postMap["instanceToken"] = instance.Token + postMap["instanceId"] = instance.Id + postMap["instanceName"] = instance.Name + + var queueName string + + if _, ok := postMap["event"]; ok { + queueName = strings.ToLower(fmt.Sprintf("%s.%s", instance.Id, postMap["event"])) + } + + values, err := json.Marshal(postMap) + if err != nil { + s.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Failed to marshal JSON for queue", instance.Id) + return nil, err + } + + go s.whatsmeowService.CallWebhook(instance, queueName, values) + + if s.config.AmqpGlobalEnabled || s.config.NatsGlobalEnabled { + go s.whatsmeowService.SendToGlobalQueues(postMap["event"].(string), values, instance.Id) + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Message sent to %s", instance.Id, data.Number) + return messageSent, nil +} + +func (s *sendService) SendCarousel(data *CarouselStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + client, err := s.ensureClientConnected(instance.Id) + if err != nil { + return nil, err + } + + formatJid := true + if data.FormatJid != nil { + formatJid = *data.FormatJid + } + + var recipient types.JID + var ok bool + recipient, ok = utils.ParseJID(data.Number) + if !ok && formatJid { + s.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Error validating message fields", instance.Id) + return nil, errors.New("invalid phone number") + } else if !ok && !formatJid { + recipient = types.JID{ + User: data.Number, + Server: types.DefaultUserServer, + } + } + + // Build carousel cards + cards := make([]*waE2E.InteractiveMessage, len(data.Cards)) + messageVersion := int32(1) + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Building carousel for %s with %d cards", instance.Id, recipient.String(), len(data.Cards)) + + for i, card := range data.Cards { + // Each card MUST have both header and body for carousel to work + interactiveCard := &waE2E.InteractiveMessage{ + Body: &waE2E.InteractiveMessage_Body{ + Text: proto.String(card.Body.Text), + }, + Header: &waE2E.InteractiveMessage_Header{ + Title: proto.String(card.Header.Title), + Subtitle: proto.String(card.Header.Subtitle), + HasMediaAttachment: proto.Bool(false), + }, + } + + // Add media to header if URL provided + if card.Header.ImageUrl != "" || card.Header.VideoUrl != "" { + header := interactiveCard.Header + + if card.Header.ImageUrl != "" { + // Download image + resp, err := http.Get(card.Header.ImageUrl) + if err == nil { + defer resp.Body.Close() + fileData, err := io.ReadAll(resp.Body) + if err == nil { + uploaded, err := client.Upload(context.Background(), fileData, whatsmeow.MediaImage) + if err == nil { + // Generate JPEG thumbnail for iOS compatibility + jpegThumb := makeJPEGThumbnail(fileData, 72) + + header.HasMediaAttachment = proto.Bool(true) + header.Media = &waE2E.InteractiveMessage_Header_ImageMessage{ + ImageMessage: &waE2E.ImageMessage{ + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String("image/jpeg"), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, + }, + } + } + } + } + } else if card.Header.VideoUrl != "" { + // Download and upload video + resp, err := http.Get(card.Header.VideoUrl) + if err == nil { + defer resp.Body.Close() + fileData, err := io.ReadAll(resp.Body) + if err == nil { + uploaded, err := client.Upload(context.Background(), fileData, whatsmeow.MediaVideo) + if err == nil { + header.HasMediaAttachment = proto.Bool(true) + header.Media = &waE2E.InteractiveMessage_Header_VideoMessage{ + VideoMessage: &waE2E.VideoMessage{ + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String("video/mp4"), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + }, + } + } + } + } + } + } + + // Add footer if exists + if card.Footer != "" { + interactiveCard.Footer = &waE2E.InteractiveMessage_Footer{ + Text: proto.String(card.Footer), + } + } + + // Add buttons if exist + if len(card.Buttons) > 0 { + buttons := make([]*waE2E.InteractiveMessage_NativeFlowMessage_NativeFlowButton, len(card.Buttons)) + for j, btn := range card.Buttons { + buttonType := strings.ToUpper(btn.Type) + if buttonType == "" { + buttonType = "REPLY" // Default type + } + + var buttonName string + var buttonParams string + + switch buttonType { + case "URL": + // URL button - opens a link + buttonName = "cta_url" + buttonParams = fmt.Sprintf(`{"display_text":"%s","url":"%s"}`, btn.DisplayText, btn.Id) + case "CALL": + // Call button - initiates a phone call + buttonName = "cta_call" + buttonParams = fmt.Sprintf(`{"display_text":"%s","phone_number":"%s"}`, btn.DisplayText, btn.Id) + case "COPY": + // Copy button - copies text to clipboard + buttonName = "cta_copy" + buttonParams = fmt.Sprintf(`{"display_text":"%s","copy_code":"%s"}`, btn.DisplayText, btn.CopyCode) + case "REPLY": + fallthrough + default: + // Quick reply button (default) + buttonName = "quick_reply" + buttonParams = fmt.Sprintf(`{"display_text":"%s","id":"%s"}`, btn.DisplayText, btn.Id) + } + + buttons[j] = &waE2E.InteractiveMessage_NativeFlowMessage_NativeFlowButton{ + Name: proto.String(buttonName), + ButtonParamsJSON: proto.String(buttonParams), + } + } + + // Cards in carousel: do NOT set MessageParamsJSON or MessageVersion + // (matching PAPI Node.js behavior for iOS compatibility) + interactiveCard.InteractiveMessage = &waE2E.InteractiveMessage_NativeFlowMessage_{ + NativeFlowMessage: &waE2E.InteractiveMessage_NativeFlowMessage{ + Buttons: buttons, + }, + } + } + + cards[i] = interactiveCard + } + + // Build carousel message (do NOT set CarouselCardType - matching PAPI Node.js for iOS) + interactiveMsg := &waE2E.InteractiveMessage{ + InteractiveMessage: &waE2E.InteractiveMessage_CarouselMessage_{ + CarouselMessage: &waE2E.InteractiveMessage_CarouselMessage{ + Cards: cards, + MessageVersion: &messageVersion, + }, + }, + } + + // Add body if provided (main message above carousel) + if data.Body != "" { + interactiveMsg.Body = &waE2E.InteractiveMessage_Body{ + Text: proto.String(data.Body), + } + } + + // Add footer if provided (text below carousel) + if data.Footer != "" { + interactiveMsg.Footer = &waE2E.InteractiveMessage_Footer{ + Text: proto.String(data.Footer), + } + } + + // ContextInfo is REQUIRED for iOS compatibility + // Even if empty, iOS requires this field to display carousel + contextInfo := &waE2E.ContextInfo{} + + // Add quoted message if exists + if data.Quoted.MessageID != "" { + contextInfo.StanzaID = proto.String(data.Quoted.MessageID) + if data.Quoted.Participant != "" { + participantJID, ok := utils.ParseJID(data.Quoted.Participant) + if ok { + contextInfo.Participant = proto.String(participantJID.String()) + } + } + } + + // Always set ContextInfo (required for iOS) + interactiveMsg.ContextInfo = contextInfo + + // Build final message with MessageContextInfo for proper notification delivery + msg := &waE2E.Message{ + InteractiveMessage: interactiveMsg, + MessageContextInfo: &waE2E.MessageContextInfo{ + DeviceListMetadata: &waE2E.DeviceListMetadata{}, + }, + } + + message, err := s.SendMessage(instance, msg, "InteractiveMessage", &SendDataStruct{ + Number: data.Number, + Delay: data.Delay, + }) + + if err != nil { + s.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Error sending carousel: %v", instance.Id, err) + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Carousel sent to %s with %d cards", instance.Id, data.Number, len(data.Cards)) + return message, nil +} + +func (s *sendService) SendStatusText(data *StatusTextStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + client, err := s.ensureClientConnected(instance.Id) + if err != nil { + return nil, err + } + + if data.Text == "" { + return nil, errors.New("text is required") + } + + msg := &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: &data.Text, + }, + } + + messageID := data.Id + if messageID == "" { + messageID = client.GenerateMessageID() + } + + recipient := types.NewJID("status", "broadcast") + + response, err := client.SendMessage(context.Background(), recipient, msg, whatsmeow.SendRequestExtra{ID: messageID}) + if err != nil { + return nil, err + } + + messageInfo := types.MessageInfo{ + MessageSource: types.MessageSource{ + Chat: recipient, + Sender: *client.Store.ID, + IsFromMe: true, + IsGroup: false, + }, + ID: messageID, + Timestamp: time.Now(), + ServerID: response.ServerID, + Type: "StatusTextMessage", + } + + messageSent := &MessageSendStruct{ + Info: messageInfo, + Message: msg, + MessageContextInfo: &waE2E.ContextInfo{ + StanzaID: proto.String(""), + Participant: proto.String(""), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + }, + } + + s.sendStatusWebhook(messageSent, instance, "text") + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Status text sent successfully", instance.Id) + return messageSent, nil +} + +func (s *sendService) SendStatusMediaUrl(data *StatusMediaStruct, instance *instance_model.Instance) (*MessageSendStruct, error) { + client, err := s.ensureClientConnected(instance.Id) + if err != nil { + return nil, err + } + + if data.Url == "" { + return nil, errors.New("url is required") + } + if data.Type != "image" && data.Type != "video" { + return nil, errors.New("type must be 'image' or 'video'") + } + + req, err := http.NewRequest("GET", data.Url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Evolution-GO/1.0") + + httpClient := &http.Client{} + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to download file from URL: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("failed to download file: HTTP status %d", resp.StatusCode) + } + + fileData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return s.sendStatusMedia(client, data, fileData, instance) +} + +func (s *sendService) SendStatusMediaFile(data *StatusMediaStruct, fileData []byte, instance *instance_model.Instance) (*MessageSendStruct, error) { + client, err := s.ensureClientConnected(instance.Id) + if err != nil { + return nil, err + } + + if data.Type != "image" && data.Type != "video" { + return nil, errors.New("type must be 'image' or 'video'") + } + + return s.sendStatusMedia(client, data, fileData, instance) +} + +func (s *sendService) sendStatusMedia(client *whatsmeow.Client, data *StatusMediaStruct, fileData []byte, instance *instance_model.Instance) (*MessageSendStruct, error) { + mime, _ := mimetype.DetectReader(bytes.NewReader(fileData)) + mimeType := mime.String() + + var uploadType whatsmeow.MediaType + switch data.Type { + case "image": + if mimeType != "image/jpeg" && mimeType != "image/png" && mimeType != "image/webp" { + return nil, fmt.Errorf("invalid file format: '%s'. Only 'image/jpeg', 'image/png' and 'image/webp' are accepted", mimeType) + } + if mimeType == "image/webp" { + mimeType = "image/jpeg" + } + uploadType = whatsmeow.MediaImage + case "video": + if mimeType != "video/mp4" { + return nil, fmt.Errorf("invalid file format: '%s'. Only 'video/mp4' is accepted", mimeType) + } + uploadType = whatsmeow.MediaVideo + default: + return nil, errors.New("invalid media type") + } + + uploaded, err := client.Upload(context.Background(), fileData, uploadType) + if err != nil { + return nil, err + } + + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Status media uploaded, size: %d", instance.Id, uploaded.FileLength) + + var media *waE2E.Message + var mediaType string + + switch data.Type { + case "image": + // Generate a JPEG preview thumbnail so status/story images render an + // inline preview instead of the gray camera placeholder. On failure + // jpegThumb is nil and the status is posted without a preview. + jpegThumb := makeJPEGThumbnail(fileData, 72) + media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ + Caption: proto.String(data.Caption), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, + }} + mediaType = "ImageMessage" + case "video": + media = &waE2E.Message{VideoMessage: &waE2E.VideoMessage{ + Caption: proto.String(data.Caption), + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(fileData))), + }} + mediaType = "VideoMessage" + } + + messageID := data.Id + if messageID == "" { + messageID = client.GenerateMessageID() + } + + recipient := types.NewJID("status", "broadcast") + + response, err := client.SendMessage(context.Background(), recipient, media, whatsmeow.SendRequestExtra{ID: messageID}) + if err != nil { + return nil, err + } + + messageInfo := types.MessageInfo{ + MessageSource: types.MessageSource{ + Chat: recipient, + Sender: *client.Store.ID, + IsFromMe: true, + IsGroup: false, + }, + ID: messageID, + Timestamp: time.Now(), + ServerID: response.ServerID, + Type: mediaType, + } + + messageSent := &MessageSendStruct{ + Info: messageInfo, + Message: media, + MessageContextInfo: &waE2E.ContextInfo{ + StanzaID: proto.String(""), + Participant: proto.String(""), + QuotedMessage: &waE2E.Message{Conversation: proto.String("")}, + }, + } + + s.sendStatusWebhook(messageSent, instance, "media") + return messageSent, nil +} + +func (s *sendService) sendStatusWebhook(messageSent *MessageSendStruct, instance *instance_model.Instance, messageType string) { + postMap := make(map[string]interface{}) + postMap["event"] = "SendStatus" + messageData := make(map[string]interface{}) + messageData["Info"] = messageSent.Info + msgBytes, err := json.Marshal(messageSent.Message) + if err != nil { + s.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Failed to marshal status message: %v", instance.Id, err) + return + } + var msgMap map[string]interface{} + if err := json.Unmarshal(msgBytes, &msgMap); err != nil { + s.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Failed to unmarshal status message: %v", instance.Id, err) + return + } + messageData["Message"] = msgMap + messageData["MessageContextInfo"] = messageSent.MessageContextInfo + postMap["data"] = messageData + postMap["instanceToken"] = instance.Token + postMap["instanceId"] = instance.Id + postMap["instanceName"] = instance.Name + + values, err := json.Marshal(postMap) + if err != nil { + s.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Failed to marshal webhook payload: %v", instance.Id, err) + return + } + go s.whatsmeowService.CallWebhook(instance, "sendstatus", values) + if s.config.AmqpGlobalEnabled || s.config.NatsGlobalEnabled { + go s.whatsmeowService.SendToGlobalQueues("SendStatus", values, instance.Id) + } + s.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Status %s sent successfully", instance.Id, messageType) +} + +func NewSendService( + clientPointer map[string]*whatsmeow.Client, + whatsmeowService whatsmeow_service.WhatsmeowService, + config *config.Config, + loggerWrapper *logger_wrapper.LoggerManager, +) SendService { + return &sendService{ + clientPointer: clientPointer, + whatsmeowService: whatsmeowService, + config: config, + loggerWrapper: loggerWrapper, + } +} From 9944cc051aca28b176a495ea0224bb4b5617ba4b Mon Sep 17 00:00:00 2001 From: Iago Cotta Date: Fri, 31 Jul 2026 14:01:31 -0300 Subject: [PATCH 2/2] fix(instance): prevent GetQr from disconnecting active logged-in session --- pkg/instance/service/instance_service.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/pkg/instance/service/instance_service.go b/pkg/instance/service/instance_service.go index 3a54748d..e8b3abe9 100644 --- a/pkg/instance/service/instance_service.go +++ b/pkg/instance/service/instance_service.go @@ -407,12 +407,20 @@ func (i instances) GetQr(instance *instance_model.Instance) (*QrcodeStruct, erro logger := i.loggerWrapper.GetLogger(instance.Id) client := i.clientPointer[instance.Id] - // Se não há cliente ou o cliente está logado, precisamos iniciar um novo cliente - if client == nil || client.IsLoggedIn() { - if client != nil && client.IsLoggedIn() { - logger.LogInfo("[%s] Client is logged in, starting new instance for QR code", instance.Id) - } else { + // Se o cliente já está logado, NÃO reiniciar — apenas informar. Chamar StartInstance + // numa sessão logada derruba o whatsmeow (Disconnected → novo QR), o que pode ser + // disparado por polling do frontend logo após PairSuccess, quebrando a conexão. + if client != nil && client.IsLoggedIn() { + logger.LogInfo("[%s] Client is already logged in — returning 'session already logged in' (StartInstance skipped to avoid disconnect)", instance.Id) + return nil, fmt.Errorf("session already logged in") + } + + // Se não há cliente OU cliente sem sessão logada, iniciar/reiniciar para gerar QR + if client == nil || (!client.IsConnected() && !client.IsLoggedIn()) { + if client == nil { logger.LogInfo("[%s] No client found, starting new instance for QR code", instance.Id) + } else { + logger.LogInfo("[%s] Client exists but not connected/logged, restarting for QR code", instance.Id) } // Iniciar nova instância para gerar QR code